1 Commits

Author SHA1 Message Date
Skipper
ea59e1f80a merge: refactor-integrity-check into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:41:47 +02:00
106 changed files with 1798 additions and 1637 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

129
CLAUDE.md
View File

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

View File

@@ -8,10 +8,10 @@ This document covers concrete technology choices and dependencies. For the archi
### Authentication Result Semantics
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
Authentication no longer uses an implicit success-only response shape. Both `client` and `user-agent` return explicit auth status enums over the wire.
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
- **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_USER_AGENTS_ONLINE`, or `INTERNAL`
- **User-agent:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
This makes transport-level failures and actor/domain-level auth failures distinct:
@@ -22,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 operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel.
```mermaid
flowchart TD
@@ -31,10 +31,10 @@ flowchart TD
C -- yes --> G[Generate AuthChallenge]
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
E --> F{First response}
F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nOperator requests]
F -- approved --> F2[Cancel remaining\nUserAgent requests]
F2 --> F3[INSERT client]
F3 --> G
@@ -45,13 +45,7 @@ flowchart TD
K -- yes --> J([Session started])
```
Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
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.
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`.
---
@@ -62,7 +56,7 @@ The authentication schema stores peer identity, not replay counters:
### User-Agent Authentication
Operator authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
User-agent 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
@@ -86,7 +80,7 @@ Operator authentication supports multiple signature schemes because platform-pro
### Request Multiplexing
Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
Both `client` and `user-agent` connections support multiple in-flight requests over one gRPC bidi stream.
- Every request carries a monotonically increasing request ID
- Every normal response echoes the request ID it corresponds to
@@ -141,7 +135,7 @@ flowchart TD
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{Operator decision}
N --> O{User-agent 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

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

View File

@@ -111,7 +111,7 @@ String shortAddress(List<int> bytes) {
- [ ] **Step 2: Verify**
```sh
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart
```
Expected: no errors.
@@ -168,7 +168,7 @@ class GrantCreation extends _$GrantCreation {
- [ ] **Step 2: Run code generator**
```sh
cd operator && dart run build_runner build --delete-conflicting-outputs
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors.
@@ -176,7 +176,7 @@ Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors.
- [ ] **Step 3: Verify**
```sh
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart
```
Expected: no errors.
@@ -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/operator.pb.dart';
import 'package:arbiter/proto/user_agent.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/operator.pb.dart';
import 'package:arbiter/proto/user_agent.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 operator && dart analyze lib/screens/dashboard/evm/grants/create/fields/
cd useragent && 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 operator && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
```
Expected: no errors.
@@ -978,7 +978,7 @@ class _TokenVolumeLimitRow extends HookWidget {
- [ ] **Step 4: Run code generator for token_transfer_grant.g.dart**
```sh
cd operator && dart run build_runner build --delete-conflicting-outputs
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: generates `token_transfer_grant.g.dart`, no errors.
@@ -986,7 +986,7 @@ Expected: generates `token_transfer_grant.g.dart`, no errors.
- [ ] **Step 5: Verify**
```sh
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/grants/
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/grants/
```
Expected: no errors.
@@ -1265,7 +1265,7 @@ String _formatError(Object error) {
- [ ] **Step 2: Verify the full create/ directory**
```sh
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/
```
Expected: no errors.

View File

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

View File

@@ -4,7 +4,7 @@
## Overview
Add a "Grants" dashboard tab to the Flutter operator app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
## Scope
@@ -23,7 +23,7 @@ Add a "Grants" dashboard tab to the Flutter operator app that displays all EVM g
### `walletAccessListProvider`
**File:** `operator/lib/providers/sdk_clients/wallet_access_list.dart`
**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- `@riverpod` class, watches `connectionManagerProvider.future`
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
@@ -85,7 +85,7 @@ NavigationDestination(
## Screen: `EvmGrantsScreen`
**File:** `operator/lib/screens/dashboard/evm/grants/grants.dart`
**File:** `useragent/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.1"
version = "0.42.0"
backend = "aqua:ast-grep/ast-grep"
[tools.ast-grep."platforms.linux-arm64"]
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-arm64-musl"]
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64"]
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64-musl"]
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.macos-arm64"]
checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip"
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
[tools.ast-grep."platforms.macos-x64"]
checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip"
checksum = "sha256:979ffe611327056f4730a1ae71b0209b3b830f58b22c6ed194cda34f55400db2"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-apple-darwin.zip"
[tools.ast-grep."platforms.windows-x64"]
checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip"
checksum = "sha256:55836fa1b2c65dc7d61615a4d9368622a0d2371a76d28b9a165e5a3ab6ae32a4"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-pc-windows-msvc.zip"
[[tools."cargo:cargo-audit"]]
version = "0.22.1"
backend = "cargo:cargo-audit"
[[tools."cargo:cargo-edit"]]
version = "0.13.10"
version = "0.13.9"
backend = "cargo:cargo-edit"
[[tools."cargo:cargo-features-manager"]]
version = "0.12.0"
version = "0.11.1"
backend = "cargo:cargo-features-manager"
[[tools."cargo:cargo-insta"]]
version = "1.47.2"
version = "1.46.3"
backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
@@ -53,7 +53,7 @@ version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]]
version = "0.9.133"
version = "0.9.126"
backend = "cargo:cargo-nextest"
[[tools."cargo:cargo-shear"]]
@@ -65,7 +65,7 @@ version = "0.10.2"
backend = "cargo:cargo-vet"
[[tools."cargo:diesel_cli"]]
version = "2.3.7"
version = "2.3.6"
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.41.7-stable"
version = "3.38.9-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.4"
version = "3.14.3"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
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"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
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"
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"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
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"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
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"
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"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
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"
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"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:d51250a32fa5d9f0799c7bcb71720c27b10a3afd4a7de288120f96085d508a5a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-apple-darwin-install_only_stripped.tar.gz"
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"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:a976991dcd085c1bb5d9a8084823a6bc8b7f9b079d8c432574a6ddd68c3a6fe1"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
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"
provenance = "github-attestations"
[[tools.rust]]
version = "1.95.0"
version = "1.93.0"
backend = "core:rust"

View File

@@ -1,17 +1,17 @@
[tools]
"cargo:diesel_cli" = { version = "2.3.7", features = "sqlite,sqlite-bundled", default-features = "false" }
"cargo:diesel_cli" = { version = "2.3.6", features = "sqlite,sqlite-bundled", default-features = false }
"cargo:cargo-audit" = "0.22.1"
"cargo:cargo-vet" = "0.10.2"
flutter = "3.41.7-stable"
flutter = "3.38.9-stable"
protoc = "29.6"
rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
"cargo:cargo-features-manager" = "0.12.0"
"cargo:cargo-nextest" = "0.9.133"
"rust" = {version = "1.93.0", components = "clippy,rust-analyzer"}
"cargo:cargo-features-manager" = "0.11.1"
"cargo:cargo-nextest" = "0.9.126"
"cargo:cargo-shear" = "latest"
"cargo:cargo-insta" = "1.47.2"
python = "3.14.4"
ast-grep = "0.42.1"
"cargo:cargo-edit" = "0.13.10"
"cargo:cargo-insta" = "1.46.3"
python = "3.14.3"
ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9"
"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 "operator.proto";
import "user_agent.proto";
message ServerInfo {
string version = 1;
@@ -12,5 +12,5 @@ message ServerInfo {
service ArbiterService {
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
rpc UserAgent(stream arbiter.user_agent.UserAgentRequest) returns (stream arbiter.user_agent.UserAgentResponse);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

630
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ hmac.workspace = true
alloy.workspace = true
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
chrono.workspace = true
thiserror.workspace = true
[lints]
workspace = true
@@ -20,6 +19,3 @@ workspace = true
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"]
safecell = ["dep:memsafe"]
[lib]
doctest = false

View File

@@ -7,17 +7,10 @@ use ml_dsa::{
use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidLength {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone)]
pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE],
@@ -50,12 +43,9 @@ impl AuthChallenge {
}
}
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
let random_nonce = nonce.as_array().ok_or(InvalidLength {
expected: NONCE_SIZE,
actual: nonce.len(),
})?;
Ok(Self {
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, ()> {
let random_nonce = nonce.as_array().ok_or(())?;
Ok(AuthChallenge {
nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp),
})
@@ -84,7 +74,6 @@ impl PublicKey {
self.0.encode().0.to_vec()
}
#[must_use]
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
let challenge = challenge.format();
self.0
@@ -192,7 +181,7 @@ mod tests {
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
#[test]
fn public_key_round_trip_decodes() {
@@ -227,7 +216,7 @@ mod tests {
.expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
}
#[test]

View File

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

View File

@@ -31,7 +31,7 @@ pub trait SafeCellHandle<T> {
let mut cell = Self::new(T::default());
{
let mut handle = cell.write();
f(&mut *handle);
f(handle.deref_mut());
}
cell
}

View File

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

View File

@@ -18,7 +18,7 @@ pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
}
}
fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream {
fn hashable_struct(input: &DeriveInput, struct_data: &syn::DataStruct) -> TokenStream {
let ident = &input.ident;
let hashable_trait = HASHABLE_TRAIT_PATH.to_path();
let hmac_digest = HMAC_DIGEST_PATH.to_path();

View File

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

View File

@@ -9,6 +9,7 @@ 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
@@ -18,18 +19,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"]
ignored = ["tonic-prost", "prost", "kameo"]

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,8 @@ license = "Apache-2.0"
workspace = true
[dependencies]
diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.9.0", features = [
diesel = { version = "2.3.7", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.8.0", features = [
"bb8",
"migrations",
"sqlite",
@@ -27,12 +27,17 @@ tokio.workspace = true
rustls.workspace = true
smlang.workspace = true
thiserror.workspace = true
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
async-trait.workspace = true
secrecy = "0.10.3"
futures.workspace = true
tokio-stream.workspace = true
dashmap = "6.1.0"
rand.workspace = true
rcgen.workspace = true
chrono.workspace = true
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"] }
@@ -41,21 +46,23 @@ 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 operator_client (
create table if not exists useragent_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_operator_client_public_key on operator_client (public_key);
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key);
create table if not exists client_metadata (
id integer not null primary key,

View File

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

View File

@@ -16,9 +16,7 @@ use crate::{
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
};
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
};
@@ -134,7 +132,7 @@ impl EvmActor {
#[messages]
impl EvmActor {
#[message]
pub async fn operator_create_grant(
pub async fn useragent_create_grant(
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
@@ -160,8 +158,7 @@ impl EvmActor {
}
#[message]
#[expect(clippy::unused_async, reason = "reserved for impl")]
pub async fn operator_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
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();
@@ -186,7 +183,7 @@ impl EvmActor {
}
#[message]
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
@@ -270,6 +267,7 @@ impl EvmActor {
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
use crate::{
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault,
useragent_registry::UserAgentRegistry, vault::Vault,
},
db,
};
@@ -13,7 +13,7 @@ use thiserror::Error;
pub mod bootstrap;
pub mod evm;
pub mod flow_coordinator;
pub mod operator_registry;
pub mod useragent_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 operator_registry: ActorRef<OperatorRegistry>,
pub useragent_registry: ActorRef<UserAgentRegistry>,
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 operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
let useragent_registry = UserAgentRegistry::spawn(UserAgentRegistry::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(
operator_registry.clone(),
useragent_registry.clone(),
)),
operator_registry,
useragent_registry,
events: message_bus,
})
}

View File

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

View File

@@ -18,7 +18,7 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{KeyInit as _, Mac as _};
use hmac::Mac as _;
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant};
@@ -80,7 +80,6 @@ enum State {
}
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)]
@@ -119,36 +118,38 @@ impl Vault {
let mut conn = pool.get().await?;
let nonce = conn
.exclusive_transaction(async |conn| {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(&mut *conn)
.await?;
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(conn)
.await?;
let mut nonce = 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(&mut *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(conn)
.await?;
Result::<_, Error>::Ok(nonce)
Result::<_, Error>::Ok(nonce)
})
})
.await?;
Ok(nonce)
}
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped),
@@ -183,26 +184,28 @@ impl Vault {
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.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?;
.transaction(|conn| {
Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes,
schema_version: 1,
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
})
.await?;
@@ -245,11 +248,12 @@ impl Vault {
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
|_| {
error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase
})?;
},
)?;
seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
@@ -287,7 +291,7 @@ impl Vault {
.ok_or(Error::NotFound)?
};
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for aead_encrypted id={}",
aead_id
@@ -404,8 +408,15 @@ impl Vault {
#[cfg(test)]
mod tests {
use crate::actors::GlobalActors;
use arbiter_crypto::safecell::SafeCellHandle as _;
use diesel::SelectableHelper;
use diesel_async::RunQueryDsl;
use crate::{
actors::GlobalActors,
db::{self},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::*;

View File

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

View File

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

View File

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

View File

@@ -64,11 +64,6 @@ fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #85"
)]
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
@@ -120,7 +115,7 @@ pub async fn sign_entity<E: Integrable>(
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
SendError::HandlerError(inner) => Error::Vault(inner),
kameo::error::SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend,
})?;
@@ -130,7 +125,7 @@ pub async fn sign_entity<E: Integrable>(
entity_id,
payload_version: E::VERSION,
key_version,
mac: mac.clone(),
mac: mac.to_vec(),
})
.on_conflict((
integrity_envelope::entity_id,
@@ -244,12 +239,12 @@ mod tests {
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
const ENTITY_ID: &[u8] = b"entity-id-7";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
@@ -275,12 +270,12 @@ mod tests {
#[tokio::test]
async fn tampered_mac_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-11";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
@@ -306,12 +301,12 @@ mod tests {
#[tokio::test]
async fn changed_payload_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-21";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),

View File

@@ -10,6 +10,7 @@ use rand::{
Rng as _, SeedableRng as _,
rngs::{StdRng, SysRng},
};
use std::ops::Deref as _;
pub mod encryption;
pub mod integrity;
@@ -38,8 +39,11 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic");
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(key_buffer);
});
@@ -53,7 +57,8 @@ impl KeyCell {
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let cipher = XChaCha20Poly1305::new(&key_reader);
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
@@ -65,7 +70,8 @@ impl KeyCell {
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let cipher = XChaCha20Poly1305::new(&key_reader);
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut();
@@ -79,7 +85,8 @@ impl KeyCell {
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let mut cipher = XChaCha20Poly1305::new(&key_reader);
let key_ref = key_reader.deref();
let mut cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
@@ -107,15 +114,20 @@ pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
}
};
#[allow(clippy::unwrap_used)]
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
#[allow(
clippy::unwrap_used,
reason = "Better fail completely than return a weak key"
)]
hasher
.hash_password_into(password_source, salt, key_buffer)
.expect("Better fail completely than return a weak key");
.hash_password_into(password_source.deref(), salt, key_buffer)
.unwrap();
});
key.into()

View File

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

View File

@@ -1,7 +1,5 @@
#![allow(
clippy::duplicated_attributes,
reason = "restructed's #[view] causes false positives"
)]
#![allow(unused)]
#![allow(clippy::all)]
use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
@@ -9,6 +7,7 @@ use crate::db::schema::{
integrity_envelope, root_key_history, tls_history,
};
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models;
@@ -28,16 +27,16 @@ pub mod types {
pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp {
pub fn now() -> Self {
Self(Utc::now())
SqliteTimestamp(Utc::now())
}
}
impl From<DateTime<Utc>> for SqliteTimestamp {
fn from(dt: DateTime<Utc>) -> Self {
Self(dt)
impl From<chrono::DateTime<Utc>> for SqliteTimestamp {
fn from(dt: chrono::DateTime<Utc>) -> Self {
SqliteTimestamp(dt)
}
}
impl From<SqliteTimestamp> for DateTime<Utc> {
impl From<SqliteTimestamp> for chrono::DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self {
ts.0
}
@@ -48,11 +47,6 @@ pub mod types {
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84; this will break up in 2038 :3"
)]
let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp);
Ok(IsNull::No)
@@ -75,47 +69,7 @@ pub mod types {
let datetime =
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
Ok(Self(datetime))
}
}
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
#[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct ChainId(pub i32);
#[expect(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
)]
const _: () = {
impl From<ChainId> for alloy::primitives::ChainId {
fn from(chain_id: ChainId) -> Self {
chain_id.0 as Self
}
}
impl From<alloy::primitives::ChainId> for ChainId {
fn from(chain_id: alloy::primitives::ChainId) -> Self {
Self(chain_id as _)
}
}
};
impl FromSql<Integer, Sqlite> for ChainId {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for ChainId {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
Ok(SqliteTimestamp(datetime))
}
}
}
@@ -248,8 +202,8 @@ pub struct ProgramClient {
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient {
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient {
pub id: i32,
pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp,
@@ -281,7 +235,7 @@ pub struct EvmEtherTransferLimit {
pub struct EvmBasicGrant {
pub id: i32,
pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: ChainId,
pub chain_id: i32,
pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>,
@@ -304,7 +258,7 @@ pub struct EvmTransactionLog {
pub id: i32,
pub grant_id: i32,
pub wallet_access_id: i32,
pub chain_id: ChainId,
pub chain_id: i32,
pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp,
}
@@ -379,7 +333,7 @@ pub struct EvmTokenTransferLog {
pub id: i32,
pub grant_id: i32,
pub log_id: i32,
pub chain_id: ChainId,
pub chain_id: i32,
pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>,
pub value: Vec<u8>,

View File

@@ -186,7 +186,7 @@ diesel::table! {
}
diesel::table! {
operator_client (id) {
useragent_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,
operator_client,
useragent_client,
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1;
const CHAIN_ID: u64 = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222");
@@ -48,7 +48,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -161,7 +161,7 @@ async fn evaluate_passes_when_volume_within_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -203,7 +203,7 @@ async fn evaluate_rejects_volume_over_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -246,7 +246,7 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})

View File

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

View File

@@ -62,7 +62,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -241,11 +241,12 @@ async fn evaluate_passes_volume_at_exact_limit() {
.unwrap();
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
insert_into(db::schema::evm_token_transfer_log::table)
.values(db::models::NewEvmTokenTransferLog {
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
grant_id,
log_id: 0,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
@@ -285,11 +286,12 @@ async fn evaluate_rejects_volume_over_limit() {
.await
.unwrap();
insert_into(db::schema::evm_token_transfer_log::table)
.values(db::models::NewEvmTokenTransferLog {
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
grant_id,
log_id: 0,
chain_id: CHAIN_ID.into(),
chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),

View File

@@ -82,8 +82,8 @@ impl SafeSigner {
})
}
#[expect(clippy::significant_drop_tightening, reason = "false positive")]
fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> {
#[allow(clippy::expect_used)]
let mut cell = self.key.lock().expect("SafeSigner mutex poisoned");
let reader = cell.read();
let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?;
@@ -96,6 +96,7 @@ impl SafeSigner {
{
return Err(Error::TransactionChainIdMismatch {
signer: chain_id,
#[allow(clippy::expect_used)]
tx: tx.chain_id().expect("Chain ID is guaranteed to be set"),
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
use crate::{
grpc::request_tracker::RequestTracker,
peers::operator::{OutOfBand, OperatorConnection, OperatorSession},
peers::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
};
use arbiter_proto::{
proto::operator::{
OperatorRequest, OperatorResponse,
operator_request::Payload as OperatorRequestPayload,
operator_response::Payload as OperatorResponsePayload,
proto::user_agent::{
UserAgentRequest, UserAgentResponse,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
};
@@ -38,8 +38,8 @@ impl Sender<OutOfBand> for OutOfBandAdapter {
}
async fn dispatch_loop(
mut bi: GrpcBi<OperatorRequest, OperatorResponse>,
actor: ActorRef<OperatorSession>,
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>,
actor: ActorRef<UserAgentSession>,
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(OperatorResponse { id: None, payload: Some(payload) })).await.is_err() {
if bi.send(Ok(UserAgentResponse { 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 operator request");
warn!(error = ?err, "Failed to receive user agent request");
return;
}
};
@@ -78,13 +78,13 @@ async fn dispatch_loop(
};
let Some(payload) = conn.payload else {
let _ = bi.send(Err(Status::invalid_argument("Missing operator request payload"))).await;
let _ = bi.send(Err(Status::invalid_argument("Missing user-agent request payload"))).await;
return;
};
match dispatch_inner(&actor, payload).await {
Ok(Some(response)) => {
if bi.send(Ok(OperatorResponse {
if bi.send(Ok(UserAgentResponse {
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 operator request");
error!(?status, "Failed to process user agent request");
let _ = bi.send(Err(status)).await;
return;
}
@@ -104,23 +104,23 @@ async fn dispatch_loop(
}
async fn dispatch_inner(
actor: &ActorRef<OperatorSession>,
payload: OperatorRequestPayload,
) -> Result<Option<OperatorResponsePayload>, Status> {
actor: &ActorRef<UserAgentSession>,
payload: UserAgentRequestPayload,
) -> Result<Option<UserAgentResponsePayload>, Status> {
match payload {
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"))
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"))
}
}
}
pub async fn start(
mut conn: OperatorConnection,
mut bi: GrpcBi<OperatorRequest, OperatorResponse>,
mut conn: UserAgentConnection,
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>,
) {
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::operator::start(&mut conn, transport, Box::new(oob_adapter)).await {
match crate::peers::user_agent::start(&mut conn, transport, Box::new(oob_adapter)).await {
Ok(actor) => actor,
Err(e) => {
warn!(error = ?e, "Operator connection failed");
warn!(error = ?e, "User agent connection failed");
return;
}
}
};
info!("Operator session established");
info!("User agent 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::operator::auth};
use crate::{grpc::request_tracker::RequestTracker, peers::user_agent::auth};
use arbiter_crypto::authn;
use arbiter_proto::{
proto::operator::{
OperatorRequest, OperatorResponse,
proto::user_agent::{
UserAgentRequest, UserAgentResponse,
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,
},
operator_request::Payload as OperatorRequestPayload,
operator_response::Payload as OperatorResponsePayload,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
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<OperatorRequest, OperatorResponse>,
pub(super) bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
pub(super) request_tracker: &'a mut RequestTracker,
}
impl<'a> AuthTransportAdapter<'a> {
pub(super) const fn new(
bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>,
pub(super) fn new(
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &'a mut RequestTracker,
) -> Self {
Self {
@@ -35,31 +35,31 @@ impl<'a> AuthTransportAdapter<'a> {
}
}
pub(super) const fn bi_mut(&mut self) -> &mut GrpcBi<OperatorRequest, OperatorResponse> {
pub(super) fn bi_mut(&mut self) -> &mut GrpcBi<UserAgentRequest, UserAgentResponse> {
self.bi
}
pub(super) const fn tracker_mut(&mut self) -> &mut RequestTracker {
pub(super) fn tracker_mut(&mut self) -> &mut RequestTracker {
self.request_tracker
}
pub(super) async fn send_response_payload(
&mut self,
payload: OperatorResponsePayload,
payload: UserAgentResponsePayload,
) -> Result<(), TransportError> {
self.bi
.send(Ok(OperatorResponse {
.send(Ok(UserAgentResponse {
id: Some(self.request_tracker.current_request_id()),
payload: Some(payload),
}))
.await
}
async fn send_operator_response(
async fn send_user_agent_response(
&mut self,
payload: AuthResponsePayload,
) -> Result<(), TransportError> {
self.send_response_payload(OperatorResponsePayload::Auth(proto_auth::Response {
self.send_response_payload(UserAgentResponsePayload::Auth(proto_auth::Response {
payload: Some(payload),
}))
.await
@@ -107,7 +107,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
}
};
self.send_operator_response(payload).await
self.send_user_agent_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 operator auth request");
warn!(error = ?error, "Failed to receive user agent 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.operator.auth_adapter"
"grpc.useragent.auth_adapter"
);
return None;
};
let OperatorRequestPayload::Auth(auth_request) = payload else {
let UserAgentRequestPayload::Auth(auth_request) = payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported operator auth request",
"Unsupported user-agent 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.operator.auth_adapter"
"grpc.useragent.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.operator.auth_adapter"
"grpc.useragent.auth_adapter"
);
return None;
};

View File

@@ -3,8 +3,8 @@ use crate::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
},
peers::operator::{
OperatorSession,
peers::user_agent::{
UserAgentSession,
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,
},
operator::{
user_agent::{
evm::{
self as proto_evm, SignTransactionRequest as ProtoSignTransactionRequest,
request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload,
},
operator_response::Payload as OperatorResponsePayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
};
@@ -37,23 +37,23 @@ use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
const fn wrap_evm_response(payload: EvmResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::Evm(proto_evm::Response {
fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<OperatorSession>,
actor: &ActorRef<UserAgentSession>,
req: proto_evm::Request,
) -> Result<Option<OperatorResponsePayload>, Status> {
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing EVM request payload"));
};
match payload {
EvmRequestPayload::WalletCreate(()) => handle_wallet_create(actor).await,
EvmRequestPayload::WalletList(()) => handle_wallet_list(actor).await,
EvmRequestPayload::WalletCreate(_) => handle_wallet_create(actor).await,
EvmRequestPayload::WalletList(_) => handle_wallet_list(actor).await,
EvmRequestPayload::GrantCreate(req) => handle_grant_create(actor, req).await,
EvmRequestPayload::GrantDelete(req) => handle_grant_delete(actor, req).await,
EvmRequestPayload::GrantList(_) => handle_grant_list(actor).await,
@@ -62,8 +62,8 @@ pub(super) async fn dispatch(
}
async fn handle_wallet_create(
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, 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<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, 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<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, 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<OperatorSession>,
actor: &ActorRef<UserAgentSession>,
req: EvmGrantCreateRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
) -> Result<Option<UserAgentResponsePayload>, 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<OperatorSession>,
actor: &ActorRef<UserAgentSession>,
req: EvmGrantDeleteRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
) -> Result<Option<UserAgentResponsePayload>, 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<OperatorSession>,
actor: &ActorRef<UserAgentSession>,
req: ProtoSignTransactionRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
) -> Result<Option<UserAgentResponsePayload>, 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::operator::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
proto::user_agent::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
};
use alloy::primitives::{Address, U256};
@@ -22,11 +22,11 @@ use chrono::{DateTime, TimeZone, Utc};
use prost_types::Timestamp as ProtoTimestamp;
use tonic::Status;
fn address_from_bytes(bytes: &[u8]) -> Result<Address, Status> {
fn address_from_bytes(bytes: Vec<u8>) -> Result<Address, Status> {
if bytes.len() != 20 {
return Err(Status::invalid_argument("Invalid EVM address"));
}
Ok(Address::from_slice(bytes))
Ok(Address::from_slice(&bytes))
}
fn u256_from_proto_bytes(bytes: &[u8]) -> Result<U256, Status> {
@@ -41,7 +41,7 @@ impl TryConvert for ProtoTimestamp {
type Error = Status;
fn try_convert(self) -> Result<DateTime<Utc>, Status> {
Utc.timestamp_opt(self.seconds, self.nanos.try_into().unwrap_or_default())
Utc.timestamp_opt(self.seconds, self.nanos as u32)
.single()
.ok_or_else(|| Status::invalid_argument("Invalid timestamp"))
}
@@ -116,8 +116,7 @@ impl TryConvert for ProtoSpecificGrant {
limit,
})) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets
.iter()
.map(Vec::as_slice)
.into_iter()
.map(address_from_bytes)
.collect::<Result<_, _>>()?,
limit: limit
@@ -131,10 +130,8 @@ impl TryConvert for ProtoSpecificGrant {
target,
volume_limits,
})) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: address_from_bytes(&token_contract)?,
target: target
.map(|target| address_from_bytes(&target))
.transpose()?,
token_contract: address_from_bytes(token_contract)?,
target: target.map(address_from_bytes).transpose()?,
volume_limits: volume_limits
.into_iter()
.map(ProtoVolumeRateLimit::try_convert)

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
use crate::{
actors::vault::VaultState,
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState},
};
use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState,
proto::operator::{
operator_response::Payload as OperatorResponsePayload,
proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
self as proto_vault, request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
@@ -17,22 +17,22 @@ use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::Vault(proto_vault::Response {
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<OperatorSession>,
actor: &ActorRef<UserAgentSession>,
req: proto_vault::Request,
) -> Result<Option<OperatorResponsePayload>, Status> {
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing vault request payload"));
};
match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
@@ -42,8 +42,8 @@ pub(super) async fn dispatch(
}
async fn handle_query_vault_state(
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, 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::operator::vault_gate::{self as vault_gate},
peers::user_agent::vault_gate::{self as vault_gate},
};
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
@@ -15,35 +15,37 @@ mod outbound;
#[async_trait]
impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(
?error,
"Failed to receive operator request during vault gate"
);
loop {
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(
?error,
"Failed to receive user agent request during vault gate"
);
return None;
}
};
if let Err(err) = self.tracker_mut().request(request.id) {
let _ = self.bi_mut().send(Err(err)).await;
return None;
}
};
if let Err(err) = self.tracker_mut().request(request.id) {
let _ = self.bi_mut().send(Err(err)).await;
return None;
}
let Some(payload) = request.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing request payload")))
.await;
return None;
};
let Some(payload) = request.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing request payload")))
.await;
return None;
};
match payload.try_convert() {
Ok(inbound) => Some(inbound),
Err(status) => {
let _ = self.bi_mut().send(Err(status)).await;
None
match payload.try_convert() {
Ok(inbound) => return Some(inbound),
Err(status) => {
let _ = self.bi_mut().send(Err(status)).await;
return None;
}
}
}
}

View File

@@ -1,11 +1,11 @@
use crate::{
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{
peers::user_agent::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
},
};
use arbiter_proto::proto::operator::{
operator_request::Payload as OperatorRequestPayload,
use arbiter_proto::proto::user_agent::{
user_agent_request::Payload as UserAgentRequestPayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap},
@@ -16,13 +16,13 @@ use arbiter_proto::proto::operator::{
use tonic::Status;
impl TryConvert for OperatorRequestPayload {
impl TryConvert for UserAgentRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::Vault(req) => req.try_convert(),
UserAgentRequestPayload::Vault(req) => req.try_convert(),
_ => Err(Status::permission_denied(
"Only vault operations are permitted before unsealing",
)),
@@ -47,9 +47,9 @@ impl TryConvert for VaultRequestPayload {
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(),
VaultRequestPayload::QueryState(_) => Ok(vault_gate::Inbound::HandleVaultState),
VaultRequestPayload::Unseal(req) => req.try_convert(),
VaultRequestPayload::Bootstrap(req) => req.try_convert(),
}
}
}
@@ -71,8 +71,8 @@ impl TryConvert for UnsealRequestPayload {
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::Start(start) => start.try_convert(),
Self::EncryptedKey(key) => Ok(key.convert()),
UnsealRequestPayload::Start(start) => start.try_convert(),
UnsealRequestPayload::EncryptedKey(key) => Ok(key.convert()),
}
}
}

View File

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

View File

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

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 operators")]
#[error("Client connection denied by user agents")]
Denied,
#[error("Upstream error: {0}")]
Upstream(flow_coordinator::ApprovalError),
@@ -161,8 +161,7 @@ async fn insert_client(
pubkey: &authn::PublicKey,
metadata: &ClientMetadata,
) -> Result<i32, Error> {
use crate::db::schema::client_metadata;
use crate::db::schema::{client_metadata, program_client};
let pubkey = pubkey.clone();
let metadata = metadata.clone();
@@ -171,42 +170,46 @@ async fn insert_client(
Error::DatabasePoolUnavailable
})?;
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?;
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?;
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?;
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?;
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
})?;
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
})?;
Ok(client_id)
Ok(client_id)
})
})
.await
}
@@ -225,51 +228,55 @@ async fn sync_client_metadata(
Error::DatabasePoolUnavailable
})?;
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?;
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?;
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(&mut *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(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?;
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?;
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?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(conn)
.await?;
Ok::<(), diesel::result::Error>(())
Ok::<(), diesel::result::Error>(())
})
})
.await
.map_err(|e| {
@@ -322,19 +329,22 @@ where
return Err(Error::Transport);
};
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
id
} else {
approve_new_client(
&props.actors,
ClientProfile {
pubkey: pubkey.clone(),
metadata: metadata.clone(),
},
)
.await?;
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
let client_id = match get_client_id(&props.db, &pubkey).await? {
Some(id) => {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
id
}
None => {
approve_new_client(
&props.actors,
ClientProfile {
pubkey: pubkey.clone(),
metadata: metadata.clone(),
},
)
.await?;
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
}
};
sync_client_metadata(&props.db, client_id, &metadata).await?;

View File

@@ -29,7 +29,7 @@ pub struct ClientConnection {
}
impl ClientConnection {
pub const fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
@@ -42,7 +42,7 @@ where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{
let fut = auth::authenticate(&mut props, transport);
println!("authenticate future size: {}", size_of_val(&fut));
println!("authenticate future size: {}", std::mem::size_of_val(&fut));
match fut.await {
Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id));

View File

@@ -20,7 +20,7 @@ pub struct ClientSession {
}
impl ClientSession {
pub(crate) const fn new(props: ClientConnection, client_id: i32) -> Self {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props, client_id }
}
}
@@ -91,7 +91,7 @@ impl Actor for ClientSession {
}
impl ClientSession {
pub const fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self {
props,

View File

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

View File

@@ -1,11 +1,8 @@
use super::{Credentials, OperatorConnection};
use super::{Credentials, UserAgentConnection};
use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi;
use state::{
AuthContext, AuthError, AuthEvents, AuthStateMachine, AuthStates, ChallengeRequest,
ChallengeSolution,
};
use state::*;
use tracing::error;
mod state;
@@ -69,7 +66,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
}
pub async fn authenticate<T>(
props: &mut OperatorConnection,
props: &mut UserAgentConnection,
transport: &mut T,
) -> Result<Credentials, Error>
where

View File

@@ -1,13 +1,13 @@
use super::{
super::{Credentials, OperatorConnection},
super::{Credentials, UserAgentConnection},
Error,
};
use crate::{
actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::operator_client},
peers::operator::auth::Outbound,
db::{DatabasePool, schema::useragent_client},
peers::user_agent::auth::Outbound,
};
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_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 struct ChallengeContext {
pub(super) 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")
})?;
operator_client::table
.filter(operator_client::public_key.eq(pubkey.to_bytes()))
.select(operator_client::id)
useragent_client::table
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.select(useragent_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(operator_client::table)
.values((operator_client::public_key.eq(pubkey_bytes),))
.returning(operator_client::id)
let id: i32 = diesel::insert_into(useragent_client::table)
.values((useragent_client::public_key.eq(pubkey_bytes),))
.returning(useragent_client::id)
.get_result(&mut conn)
.await
.map_err(|e| {
@@ -77,19 +77,19 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
}
pub(super) struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut OperatorConnection,
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: &'a mut T,
}
impl<'a, T: ?Sized> AuthContext<'a, T> {
pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self {
pub(super) fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<Outbound, Error>> + Send + ?Sized,
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send + ?Sized,
{
type Error = Error;
@@ -138,12 +138,12 @@ where
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
})?;
let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature);
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
if !valid {
self.transport

View File

@@ -4,7 +4,7 @@ use crate::{
vault::{GetState, Vault},
},
crypto::integrity::{self, AttestationStatus, Integrable},
db::{DatabaseError, DatabasePool},
db::{self, DatabaseError, DatabasePool},
peers::client::ClientProfile,
};
use arbiter_crypto::authn;
@@ -17,7 +17,7 @@ use tokio::sync::oneshot;
use tracing::{error, warn};
pub use auth::authenticate;
pub use session::OperatorSession;
pub use session::UserAgentSession;
pub mod auth;
pub mod session;
@@ -30,10 +30,10 @@ pub struct Credentials {
}
impl Integrable for Credentials {
const KIND: &'static str = "operator_credentials";
const KIND: &'static str = "useragent_credentials";
}
// Messages, sent by operator to connection client without having a request
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile },
@@ -41,13 +41,13 @@ pub enum OutOfBand {
}
#[derive(Clone)]
pub struct OperatorConnection {
pub(crate) db: DatabasePool,
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl OperatorConnection {
pub const fn new(db: DatabasePool, actors: GlobalActors) -> Self {
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
@@ -81,7 +81,7 @@ async fn verify_integrity(
.get()
.await
.map_err(|_| Error::Internal("DB unavailable".into()))?;
match integrity::verify_entity(&mut conn, vault, credentials, credentials.id).await {
match integrity::verify_entity(&mut conn, &vault, credentials, credentials.id).await {
Ok(AttestationStatus::Attested) => Ok(()),
Ok(AttestationStatus::Unavailable) => {
Err(Error::Internal("Vault sealed during promotion".into()))
@@ -106,7 +106,7 @@ async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
}
async fn run_vault_gate<T>(
props: &OperatorConnection,
props: &UserAgentConnection,
transport: &mut T,
auth_creds: Credentials,
) -> Result<(), Error>
@@ -160,10 +160,10 @@ where
}
pub async fn start<T>(
props: &mut OperatorConnection,
props: &mut UserAgentConnection,
mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<OperatorSession>, Error>
) -> Result<ActorRef<UserAgentSession>, 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(OperatorSession::spawn(OperatorSession::new(
Ok(UserAgentSession::spawn(UserAgentSession::new(
props.clone(),
oob_sender,
)))

View File

@@ -1,8 +1,8 @@
use super::{Error, OperatorSession};
use super::{Error, UserAgentSession};
use crate::{
actors::evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
},
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
actors::vault::VaultState,
@@ -36,7 +36,7 @@ pub enum GrantMutationError {
}
#[messages]
impl OperatorSession {
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
@@ -44,7 +44,7 @@ impl OperatorSession {
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "operator", "vault.query.failed");
error!(?err, actor = "useragent", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
@@ -54,7 +54,7 @@ impl OperatorSession {
}
#[messages]
impl OperatorSession {
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
@@ -82,10 +82,10 @@ impl OperatorSession {
}
#[messages]
impl OperatorSession {
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(OperatorListGrants {}).await {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
@@ -98,13 +98,13 @@ impl OperatorSession {
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: SpecificGrant,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(OperatorCreateGrant { basic, grant })
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
@@ -116,12 +116,15 @@ impl OperatorSession {
}
#[message]
pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> {
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(OperatorDeleteGrant { grant_id })
// .ask(UseragentDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
@@ -157,7 +160,7 @@ impl OperatorSession {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in operator session");
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
@@ -169,18 +172,20 @@ impl OperatorSession {
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(async |conn| {
use crate::db::schema::evm_wallet_access;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(&mut *conn)
.await?;
}
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
@@ -192,16 +197,18 @@ impl OperatorSession {
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().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?;
}
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
@@ -222,7 +229,7 @@ impl OperatorSession {
}
#[messages]
impl OperatorSession {
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
@@ -230,10 +237,12 @@ impl OperatorSession {
pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let Some(pending_approval) = self.pending_client_approvals.remove(&pubkey.to_bytes())
else {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
let pending_approval = match self.pending_client_approvals.remove(&pubkey.to_bytes()) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval

View File

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

View File

@@ -8,7 +8,7 @@ use crate::{
db::DatabasePool,
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use state::State;
use state::*;
use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
use kameo::{Actor, error::SendError, messages, prelude::Message};
@@ -110,7 +110,7 @@ impl VaultGate {
});
match decryption_result {
Ok(()) => Ok(key_buffer),
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
@@ -122,9 +122,9 @@ impl VaultGate {
#[messages(messages = Inbound, replies = Outbound)]
impl VaultGate {
#[message]
pub fn handle_handshake(
pub async fn handle_handshake(
&mut self,
client_pubkey: PublicKey,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret);
@@ -132,7 +132,7 @@ impl VaultGate {
let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange {
server_key: public_key,
server_key: public_key.clone(),
secret,
};
@@ -152,9 +152,12 @@ impl VaultGate {
return Err(Error::State);
};
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else {
return Err(Error::InvalidKey);
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
{
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey);
}
};
match self
@@ -165,7 +168,7 @@ impl VaultGate {
})
.await
{
Ok(()) => {
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
Ok(())
}
@@ -176,7 +179,7 @@ impl VaultGate {
}
Err(err) => {
error!(?err, "Failed to send unseal request to vault");
Err(Error::internal("Vault actor error"))
Err(Error::internal("Vault actor error").into())
}
}
}
@@ -192,9 +195,12 @@ impl VaultGate {
return Err(Error::State);
};
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else {
return Err(Error::InvalidKey);
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
{
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey);
}
};
match self
@@ -205,7 +211,7 @@ impl VaultGate {
})
.await
{
Ok(()) => {
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
Ok(())
}

View File

@@ -16,7 +16,7 @@ use arbiter_server::{
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use ml_dsa::{KeyGen, MlDsa87, SigningKey, VerifyingKey, signature::Keypair};
use ml_dsa::{KeyGen, MlDsa87, SigningKey, VerifyingKey, signature::Keypair as _};
fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> ClientMetadata {
ClientMetadata {
@@ -26,10 +26,6 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
}
}
fn verifying_key(key: &SigningKey<MlDsa87>) -> VerifyingKey<MlDsa87> {
<SigningKey<MlDsa87> as Keypair>::verifying_key(key)
}
async fn insert_registered_client(
db: &db::DatabasePool,
actors: &GlobalActors,
@@ -51,7 +47,7 @@ async fn insert_registered_client(
.unwrap();
let client_id: i32 = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.encode().0.to_vec()),
program_client::public_key.eq(pubkey.encode().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.returning(program_client::id)
@@ -79,22 +75,22 @@ fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -
.into()
}
async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) {
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = verifying_key(&MlDsa87::key_gen(&mut rand::rng()))
let sentinel_key = MlDsa87::key_gen(&mut rand::rng())
.verifying_key()
.encode()
.0
.to_vec();
insert_into(schema::operator_client::table)
.values((schema::operator_client::public_key.eq(sentinel_key),))
insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(sentinel_key),))
.execute(&mut conn)
.await
.unwrap();
}
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_operator(db).await;
insert_bootstrap_sentinel_useragent(db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
@@ -109,7 +105,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
#[tokio::test]
#[test_log::test]
pub async fn unregistered_pubkey_rejected() {
async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -124,7 +120,7 @@ pub async fn unregistered_pubkey_rejected() {
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
pubkey: new_key.verifying_key().into(),
metadata: metadata("client", Some("desc"), Some("1.0.0")),
})
.await
@@ -136,18 +132,18 @@ pub async fn unregistered_pubkey_rejected() {
#[tokio::test]
#[test_log::test]
pub async fn challenge_auth() {
async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng());
Box::pin(insert_registered_client(
insert_registered_client(
&db,
&actors,
verifying_key(&new_key),
new_key.verifying_key(),
&metadata("client", Some("desc"), Some("1.0.0")),
))
)
.await;
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -160,7 +156,7 @@ pub async fn challenge_auth() {
// Send challenge request
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
pubkey: new_key.verifying_key().into(),
metadata: metadata("client", Some("desc"), Some("1.0.0")),
})
.await
@@ -174,7 +170,7 @@ pub async fn challenge_auth() {
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge,
other @ auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got {other:?}"),
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
@@ -203,19 +199,13 @@ pub async fn challenge_auth() {
#[tokio::test]
#[test_log::test]
pub async fn metadata_unchanged_does_not_append_history() {
async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
Box::pin(insert_registered_client(
&db,
&actors,
verifying_key(&new_key),
&requested,
))
.await;
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
let props = ClientConnection::new(db.clone(), actors);
@@ -227,7 +217,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
pubkey: new_key.verifying_key().into(),
metadata: requested,
})
.await
@@ -236,7 +226,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
let response = test_transport.recv().await.unwrap().unwrap();
let challenge = match response {
auth::Outbound::AuthChallenge { challenge } => challenge,
auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got AuthSuccess"),
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_client_challenge(&new_key, &challenge);
test_transport
@@ -266,17 +256,17 @@ pub async fn metadata_unchanged_does_not_append_history() {
#[tokio::test]
#[test_log::test]
pub async fn metadata_change_appends_history_and_repoints_binding() {
async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng());
Box::pin(insert_registered_client(
insert_registered_client(
&db,
&actors,
verifying_key(&new_key),
new_key.verifying_key(),
&metadata("client", Some("old"), Some("1.0.0")),
))
)
.await;
let props = ClientConnection::new(db.clone(), actors);
@@ -289,7 +279,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
pubkey: new_key.verifying_key().into(),
metadata: metadata("client", Some("new"), Some("2.0.0")),
})
.await
@@ -298,14 +288,14 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
let response = test_transport.recv().await.unwrap().unwrap();
let challenge = match response {
auth::Outbound::AuthChallenge { challenge } => challenge,
auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got AuthSuccess"),
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_client_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution { signature })
.await
.unwrap();
drop(test_transport.recv().await.unwrap());
let _ = test_transport.recv().await.unwrap();
task.await.unwrap();
{
@@ -353,7 +343,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
#[tokio::test]
#[test_log::test]
pub async fn challenge_auth_rejects_integrity_tag_mismatch() {
async fn test_challenge_auth_rejects_integrity_tag_mismatch() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
@@ -375,7 +365,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch() {
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(verifying_key(&new_key).encode().0.to_vec()),
program_client::public_key.eq(new_key.verifying_key().encode().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
@@ -392,7 +382,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch() {
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
pubkey: new_key.verifying_key().into(),
metadata: requested,
})
.await

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