15 Commits

Author SHA1 Message Date
a8e4a710f1 Merge pull request 'security(server): bind grant revocation state (revoked_at) to integrity hash' (#83) from security-hash-revoke_at into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #83
2026-06-11 09:44:28 +00:00
CleverWild
d99c87c473 fix: lints
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 21:07:01 +02:00
CleverWild
303120c9ac Merge branch 'main' into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 20:58:20 +02:00
CleverWild
32f317384d security(evm): remove client-controlled wallet_access_id from grant revocation
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:36:44 +02:00
CleverWild
4bb2c062dc feat(evm): add wallet_access_id to grant deletion requests and revocation logic
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:16:21 +02:00
CleverWild
b0a3f37cea refactor(evm): implement revoke_grant method for grant revocation 2026-06-09 19:11:39 +02:00
CleverWild
58a72da46c Merge branch 'security-hash-revoke_at' of ssh://git.markettakers.org:22222/MarketTakers/arbiter into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:10:57 +02:00
CleverWild
e287459b10 revert(server): bind grant revocation state (revoked_at) to integrity hash 2026-06-09 18:45:30 +02:00
CleverWild
3c482da917 fix(smlang::statemachine): macro invocation requires inner types to be public
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-06-08 18:00:52 +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
CleverWild
5a34463228 security(server): bind grant revocation state (revoked_at) to integrity hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-08 12:09:54 +02:00
77 changed files with 1186 additions and 1368 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"

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

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

631
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"] }
@@ -79,6 +76,7 @@ needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
# restriction lints
alloc_instead_of_core = "warn"

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
@@ -100,7 +100,7 @@ async fn send_auth_challenge_solution(
key: &SigningKey,
challenge: AuthChallenge,
) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
let challenge = authn::AuthChallenge {
nonce: *challenge
.random

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

@@ -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,21 @@ 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
[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,13 @@ 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_client (
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_client_public_key on operator_client (public_key);
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_client::table
.count()
.get_result(&mut conn)
.await?

View File

@@ -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,
@@ -160,33 +160,18 @@ impl EvmActor {
}
#[message]
#[expect(clippy::unused_async, reason = "reserved for impl")]
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let vault = self.vault.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
pub async fn useragent_delete_grant(
&mut self,
grant_id: i32,
) -> Result<(), Error> {
self.engine
.revoke_grant(grant_id)
.await
.map_err(Error::from)
}
#[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

@@ -119,31 +119,29 @@ impl Vault {
let mut conn = pool.get().await?;
let nonce = conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(conn)
.await?;
.exclusive_transaction(async |conn| {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(&mut *conn)
.await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(conn)
.await?;
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(&mut *conn)
.await?;
Result::<_, Error>::Ok(nonce)
})
Result::<_, Error>::Ok(nonce)
})
.await?;
@@ -185,28 +183,26 @@ impl Vault {
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.transaction(|conn| {
Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes,
schema_version: 1,
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await?;
.transaction(async |conn| {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(),
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1,
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
.await?;
@@ -350,12 +346,10 @@ impl Vault {
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
@@ -379,12 +373,10 @@ impl Vault {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);
@@ -427,12 +419,13 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
State::Unsealed(Unsealed {
root_key_history_id,
..
}) => root_key_history_id,
_ => panic!("expected unsealed state"),
let State::Unsealed(Unsealed {
root_key_history_id,
..
}) = actor.state
else {
panic!("expected unsealed state")
};
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -444,8 +437,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,28 +174,26 @@ impl TlsManager {
{
let mut conn = db.get().await?;
conn.transaction(|conn| {
Box::pin(async {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(),
};
conn.transaction(async |conn| {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(),
};
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(conn)
.await?;
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(&mut *conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(())
})
Result::<_, diesel::result::Error>::Ok(())
})
.await?;
}

View File

@@ -248,8 +248,8 @@ pub struct ProgramClient {
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient {
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient {
pub id: i32,
pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp,

View File

@@ -186,7 +186,7 @@ diesel::table! {
}
diesel::table! {
useragent_client (id) {
operator_client (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
@@ -233,5 +233,5 @@ diesel::allow_tables_to_appear_in_same_query!(
program_client,
root_key_history,
tls_history,
useragent_client,
operator_client,
);

View File

@@ -1,28 +1,34 @@
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::vault::Vault,
crypto::integrity,
db::{
self, DatabaseError,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget,
EvmEtherTransferLimit, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{self, evm_transaction_log},
},
evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
};
use alloy::{
consensus::TxEip1559,
primitives::{TxKind, U256},
primitives::{Address, TxKind, U256},
};
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper,
insert_into, sqlite::Sqlite, update,
};
pub mod abi;
pub mod safe_signer;
@@ -179,24 +185,22 @@ impl Engine {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain.into(),
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(conn)
.await?;
conn.transaction(async |conn| {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain.into(),
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(&mut *conn)
.await?;
P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?;
QueryResult::Ok(())
})
QueryResult::Ok(())
})
.await
.map_err(DatabaseError::from)?;
@@ -222,60 +226,203 @@ impl Engine {
let vault = self.vault.clone();
let id = conn
.transaction(|conn| {
Box::pin(async move {
use schema::evm_basic_grant;
.transaction(async |conn| {
use schema::evm_basic_grant;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(),
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(conn)
.await?;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(),
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(&mut *conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?;
integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
QueryResult::Ok(basic_grant.id)
})
.await?;
Ok(id)
}
pub async fn revoke_grant(
&self,
basic_grant_id: i32,
) -> Result<(), DatabaseError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let vault = self.vault.clone();
conn.transaction(async move |conn| {
use crate::db::schema::{
evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target,
evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_volume_limit,
};
update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(basic_grant_id))
.set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now())))
.execute(&mut *conn)
.await?;
let basic_grant: EvmBasicGrant = evm_basic_grant::table
.filter(evm_basic_grant::id.eq(basic_grant_id))
.select(EvmBasicGrant::as_select())
.first(&mut *conn)
.await?;
let shared = SharedGrantSettings::try_from_model(basic_grant)?;
if let Some(ether_grant) = evm_ether_transfer_grant::table
.filter(evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmEtherTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let target_rows: Vec<EvmEtherTransferGrantTarget> =
evm_ether_transfer_grant_target::table
.filter(evm_ether_transfer_grant_target::grant_id.eq(ether_grant.id))
.select(EvmEtherTransferGrantTarget::as_select())
.load(&mut *conn)
.await?;
let targets: Vec<Address> = target_rows
.into_iter()
.filter_map(|target| {
let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr))
})
.collect();
let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq(ether_grant.limit_id))
.select(EvmEtherTransferLimit::as_select())
.first(&mut *conn)
.await?;
let settings = CombinedSettings {
shared: shared.clone(),
specific: policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(limit.window_secs.into()),
},
},
};
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
if let Some(token_grant) = evm_token_transfer_grant::table
.filter(evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmTokenTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let volume_limit_rows: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id))
.select(EvmTokenTransferVolumeLimit::as_select())
.load(&mut *conn)
.await?;
let volume_limits: Vec<VolumeRateLimit> = volume_limit_rows
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;
let target: Option<Address> = match token_grant.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let token_contract: [u8; 20] =
token_grant.token_contract.clone().try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
)
})?;
let settings = CombinedSettings {
shared,
specific: policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
};
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
Err(diesel::result::Error::NotFound)
})
.await
.map_err(DatabaseError::from)
}
async fn list_one_kind<Kind: Policy, Y>(
&self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
@@ -355,11 +502,15 @@ impl Engine {
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use rstest::rstest;
use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}};
use crate::crypto::integrity;
use crate::db::{
self, DatabaseConnection,
models::{
@@ -367,8 +518,10 @@ mod tests {
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::ether_transfer::EtherTransfer;
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
CombinedSettings, EvalContext, EvalViolation, Policy, SharedGrantSettings,
TransactionRateLimit, VolumeRateLimit,
};
use super::check_shared_constraints;
@@ -400,6 +553,7 @@ mod tests {
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
@@ -608,4 +762,115 @@ mod tests {
assert!(violations.is_empty());
}
}
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn revoke_grant_preserves_revoked_integrity() {
use crate::db::schema::evm_basic_grant;
use diesel::ExpressionMethods as _;
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let engine = super::Engine::new(db.clone(), vault.clone());
let full_grant = CombinedSettings {
shared: SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
},
specific: super::policies::ether_transfer::Settings {
target: vec![RECIPIENT],
limit: VolumeRateLimit {
max_volume: U256::from(100u64),
window: Duration::hours(1),
},
},
};
let grant_id = engine
.create_grant::<EtherTransfer>(full_grant)
.await
.unwrap();
engine.revoke_grant(grant_id).await.unwrap();
let mut conn = db.get().await.unwrap();
diesel::update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(grant_id))
.set(evm_basic_grant::revoked_at.eq::<Option<SqliteTimestamp>>(None))
.execute(&mut conn)
.await
.unwrap();
let wallet_access = EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
};
let context = EvalContext {
target: wallet_access,
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ONE,
calldata: Bytes::new(),
max_fee_per_gas: 1,
max_priority_fee_per_gas: 1,
};
let grant = EtherTransfer::try_find_grant(
&context, &mut conn,
)
.await
.unwrap()
.unwrap();
let result =
integrity::verify_entity(&mut conn, &vault, &grant.settings, grant.id).await;
assert!(matches!(
result,
Err(integrity::Error::MacMismatch { .. })
));
}
#[test]
fn shared_settings_hash_changes_when_revoked_at_changes() {
use arbiter_crypto::hashing::Hashable;
use sha2::Digest;
let active = shared_settings();
let revoked = SharedGrantSettings {
revoked_at: Some(Utc::now()),
..shared_settings()
};
let mut active_hash = sha2::Sha256::new();
active.hash(&mut active_hash);
let mut revoked_hash = sha2::Sha256::new();
revoked.hash(&mut revoked_hash);
assert_ne!(active_hash.finalize(), revoked_hash.finalize());
}
}

View File

@@ -144,6 +144,7 @@ pub struct SharedGrantSettings {
pub valid_from: Option<DateTime<Utc>>,
pub valid_until: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub max_gas_fee_per_gas: Option<U256>,
pub max_priority_fee_per_gas: Option<U256>,
@@ -158,6 +159,7 @@ impl SharedGrantSettings {
chain: model.chain_id.into(),
valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into),
revoked_at: model.revoked_at.map(Into::into),
max_gas_fee_per_gas: model
.max_gas_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b))

View File

@@ -79,6 +79,7 @@ fn shared() -> SharedGrantSettings {
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,

View File

@@ -98,6 +98,7 @@ fn shared() -> SharedGrantSettings {
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,

View File

@@ -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
@@ -200,7 +200,7 @@ impl Convert for auth::Outbound {
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
.cast_unsigned(),
random: challenge.nonce.to_vec(),
})
}

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
@@ -80,7 +80,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
.cast_unsigned(),
random: challenge.nonce.to_vec(),
})
}
@@ -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,8 +82,8 @@ 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
@@ -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

@@ -14,7 +14,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};
@@ -87,6 +87,7 @@ impl TryConvert for ProtoSharedSettings {
.valid_until
.map(ProtoTimestamp::try_convert)
.transpose()?,
revoked_at: None,
max_gas_fee_per_gas: self
.max_gas_fee_per_gas
.as_deref()

View File

@@ -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};

View File

@@ -1,8 +1,8 @@
use crate::{
db::models::NewEvmWalletAccess,
grpc::Convert,
peers::user_agent::{
OutOfBand, UserAgentSession,
peers::operator::{
OutOfBand, OperatorSession,
session::handlers::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList,
@@ -12,7 +12,7 @@ use crate::{
use arbiter_crypto::authn;
use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{
operator::{
sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
ConnectionRequest as ProtoSdkClientConnectionRequest,
@@ -24,7 +24,7 @@ use arbiter_proto::proto::{
request::Payload as SdkClientRequestPayload,
response::Payload as SdkClientResponsePayload,
},
user_agent_response::Payload as UserAgentResponsePayload,
operator_response::Payload as OperatorResponsePayload,
},
};
@@ -32,13 +32,13 @@ 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,8 +108,8 @@ 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
@@ -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,11 +1,11 @@
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,
@@ -17,16 +17,16 @@ 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,8 +42,8 @@ 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,

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,12 @@
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},
@@ -22,28 +22,28 @@ use arbiter_proto::proto::{
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::Sealed => ProtoVaultState::Sealed,
@@ -54,9 +54,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 +66,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,46 +171,42 @@ async fn insert_client(
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
conn.exclusive_transaction(async |conn| {
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut *conn)
.await?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.to_bytes()),
program_client::metadata_id.eq(metadata_id),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(conn)
.await?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.to_bytes()),
program_client::metadata_id.eq(metadata_id),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(&mut *conn)
.await?;
integrity::sign_entity(
conn,
&vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?;
integrity::sign_entity(
&mut *conn,
vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?;
Ok(client_id)
})
Ok(client_id)
})
.await
}
@@ -229,55 +225,51 @@ 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
.find(client_id)
.inner_join(client_metadata::table)
.select((
program_client::metadata_id,
ProgramClientMetadata::as_select(),
))
.first(conn)
.await?;
conn.exclusive_transaction(async |conn| {
let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table
.find(client_id)
.inner_join(client_metadata::table)
.select((
program_client::metadata_id,
ProgramClientMetadata::as_select(),
))
.first(&mut *conn)
.await?;
let unchanged = current.name == metadata.name
&& current.description == metadata.description
&& current.version == metadata.version;
if unchanged {
return Ok(());
}
let unchanged = current.name == metadata.name
&& current.description == metadata.description
&& current.version == metadata.version;
if unchanged {
return Ok(());
}
insert_into(client_metadata_history::table)
.values((
client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id),
))
.execute(conn)
.await?;
insert_into(client_metadata_history::table)
.values((
client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id),
))
.execute(&mut *conn)
.await?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut *conn)
.await?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(conn)
.await?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(&mut *conn)
.await?;
Ok::<(), diesel::result::Error>(())
})
Ok::<(), diesel::result::Error>(())
})
.await
.map_err(|e| {
@@ -306,7 +298,7 @@ where
let signature = expect_message(transport, |req: Inbound| match req {
Inbound::AuthChallengeSolution { signature } => Some(signature),
_ => None,
Inbound::AuthChallengeRequest { .. } => None,
})
.await
.map_err(|e| {

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_client},
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};
@@ -19,7 +19,7 @@ pub(super) struct ChallengeRequest {
pub(super) bootstrap_token: Option<String>,
}
pub(super) struct ChallengeContext {
pub struct ChallengeContext {
pub(super) challenge: AuthChallenge,
pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<String>,
@@ -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_client::table
.filter(operator_client::public_key.eq(pubkey.to_bytes()))
.select(operator_client::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_client::table)
.values((operator_client::public_key.eq(pubkey_bytes),))
.returning(operator_client::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 }
}
}
@@ -127,8 +127,6 @@ where
})
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
@@ -143,7 +141,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,8 +1,8 @@
use super::{Error, UserAgentSession};
use super::{Error, OperatorSession};
use crate::{
actors::evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
},
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
actors::vault::VaultState,
@@ -36,7 +36,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 +44,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 +54,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 {
@@ -82,10 +82,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 +104,7 @@ impl UserAgentSession {
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.ask(OperatorCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
@@ -121,7 +121,7 @@ impl UserAgentSession {
// .props
// .actors
// .evm
// .ask(UseragentDeleteGrant { grant_id })
// .ask(OperatorDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
@@ -157,7 +157,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,20 +169,18 @@ impl UserAgentSession {
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
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)
.await?;
}
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
Result::<_, Error>::Ok(())
})
.await?;
Ok(())
@@ -194,18 +192,16 @@ impl UserAgentSession {
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
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(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
Result::<_, Error>::Ok(())
})
.await?;
Ok(())
@@ -216,8 +212,7 @@ impl UserAgentSession {
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
let access_entries = crate::db::schema::evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
@@ -226,7 +221,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,18 +58,18 @@ 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,
pending_client_approvals: Default::default(),
pending_client_approvals: HashMap::default(),
}
}
}
#[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_client::table)
.values((schema::operator_client::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_client::table
.select(schema::operator_client::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_client::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_client::table)
.values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_client::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_client::table)
.values((schema::operator_client::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
@@ -400,12 +400,12 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
other @ auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got {other:?}"),
},
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_client::table)
.values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_client::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

@@ -14,7 +14,7 @@ use diesel_async::RunQueryDsl;
#[tokio::test]
#[test_log::test]
async fn test_bootstrap() {
async fn bootstrap() {
let db = db::create_test_pool().await;
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
@@ -39,7 +39,7 @@ async fn test_bootstrap() {
#[tokio::test]
#[test_log::test]
async fn test_bootstrap_rejects_double() {
async fn bootstrap_rejects_double() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
@@ -50,7 +50,7 @@ async fn test_bootstrap_rejects_double() {
#[tokio::test]
#[test_log::test]
async fn test_create_new_before_bootstrap_fails() {
async fn create_new_before_bootstrap_fails() {
let db = db::create_test_pool().await;
let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await
@@ -65,7 +65,7 @@ async fn test_create_new_before_bootstrap_fails() {
#[tokio::test]
#[test_log::test]
async fn test_decrypt_before_bootstrap_fails() {
async fn decrypt_before_bootstrap_fails() {
let db = db::create_test_pool().await;
let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await
@@ -77,7 +77,7 @@ async fn test_decrypt_before_bootstrap_fails() {
#[tokio::test]
#[test_log::test]
async fn test_new_restores_sealed_state() {
async fn new_restores_sealed_state() {
let db = db::create_test_pool().await;
let actor = common::bootstrapped_vault(&db).await;
drop(actor);
@@ -91,7 +91,7 @@ async fn test_new_restores_sealed_state() {
#[tokio::test]
#[test_log::test]
async fn test_unseal_correct_password() {
async fn unseal_correct_password() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
@@ -114,7 +114,7 @@ async fn test_unseal_correct_password() {
#[tokio::test]
#[test_log::test]
async fn test_unseal_wrong_then_correct_password() {
async fn unseal_wrong_then_correct_password() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;

View File

@@ -12,7 +12,7 @@ use std::collections::HashSet;
#[tokio::test]
#[test_log::test]
async fn test_create_decrypt_roundtrip() {
async fn create_decrypt_roundtrip() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
@@ -28,7 +28,7 @@ async fn test_create_decrypt_roundtrip() {
#[tokio::test]
#[test_log::test]
async fn test_decrypt_nonexistent_returns_not_found() {
async fn decrypt_nonexistent_returns_not_found() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
@@ -38,7 +38,7 @@ async fn test_decrypt_nonexistent_returns_not_found() {
#[tokio::test]
#[test_log::test]
async fn test_ciphertext_differs_across_entries() {
async fn ciphertext_differs_across_entries() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
@@ -76,7 +76,7 @@ async fn test_ciphertext_differs_across_entries() {
#[tokio::test]
#[test_log::test]
async fn test_nonce_never_reused() {
async fn nonce_never_reused() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;

View File

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