Compare commits
200 Commits
key-altern
...
enforcing-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763058b014 | ||
|
|
1497884ce6 | ||
|
|
b3464cf8a6 | ||
|
|
46d1318b6f | ||
| 9c80d51d45 | |||
|
|
33456a644d | ||
|
|
5bc0c42cc7 | ||
|
|
f6b62ab884 | ||
|
|
2dd5a3f32f | ||
|
|
1aca9d4007 | ||
| 5ee1b49c43 | |||
|
|
00745bb381 | ||
|
|
b122aa464c | ||
|
|
9fab945a00 | ||
|
|
aeed664e9a | ||
|
|
4057c1fc12 | ||
|
|
f5eb51978d | ||
|
|
d997e0f843 | ||
|
|
7aca281a81 | ||
| 0daad1dd37 | |||
| 9ea474e1b2 | |||
|
|
c6f440fdad | ||
| e17c25a604 | |||
|
|
01b12515bd | ||
|
|
4a50daa7ea | ||
|
|
352ee3ee63 | ||
|
|
dd51d756da | ||
|
|
0bb6e596ac | ||
|
|
083ff66af2 | ||
|
|
881f16bb1a | ||
|
|
78895bca5b | ||
| 1495fbe754 | |||
| ab8cf877d7 | |||
|
|
146f7a419e | ||
|
|
0362044b83 | ||
| 72618c186f | |||
|
|
e47ccc3108 | ||
| 90d8ae3c6c | |||
| 4af172e49a | |||
|
|
bc45b9b9ce | ||
|
|
5bce9fd68e | ||
|
|
63a4875fdb | ||
|
|
d5ec303b9a | ||
|
|
82b5b85f52 | ||
|
|
e2d8b7841b | ||
|
|
8feda7990c | ||
|
|
16f0e67d02 | ||
|
|
b5507e7d0f | ||
|
|
0388fa2c8b | ||
|
|
cfe01ba1ad | ||
|
|
59c7091cba | ||
|
|
523bf783ac | ||
|
|
643f251419 | ||
|
|
bce6ecd409 | ||
|
|
f32728a277 | ||
|
|
32743741e1 | ||
|
|
54b2183be5 | ||
|
|
ca35b9fed7 | ||
|
|
27428f709a | ||
|
|
78006e90f2 | ||
|
|
29cc4d9e5b | ||
|
|
7f8b9cc63e | ||
|
|
a02ef68a70 | ||
|
|
e5be55e141 | ||
|
|
8f0eb7130b | ||
|
|
94fe04a6a4 | ||
|
|
976c11902c | ||
|
|
c8d2662a36 | ||
|
|
ac5fedddd1 | ||
|
|
0c2d4986a2 | ||
|
|
a3203936d2 | ||
|
|
fb1c0ec130 | ||
|
|
2a21758369 | ||
|
|
1abb5fa006 | ||
|
|
e1b1c857fa | ||
|
|
4216007af3 | ||
|
|
6987e5f70f | ||
|
|
bbf8a8019c | ||
|
|
ac04495480 | ||
|
|
eb25d31361 | ||
|
|
056ff3470b | ||
|
|
c0b08e84cc | ||
|
|
ddd6e7910f | ||
|
|
d9b3694cab | ||
|
|
4ebe7b6fc4 | ||
|
|
8043cdf8d8 | ||
| 2148faa376 | |||
|
|
eb37ee0a0c | ||
|
|
1f07fd6a98 | ||
|
|
e135519c06 | ||
|
|
f015d345f4 | ||
|
|
51674bb39c | ||
|
|
cd07ab7a78 | ||
|
|
cfa6e068eb | ||
|
|
784261f4d8 | ||
|
|
971db0e919 | ||
|
|
e1a8553142 | ||
|
|
ec70561c93 | ||
|
|
3993d3a8cc | ||
|
|
c87456ae2f | ||
|
|
e89983de3a | ||
|
|
f56668d9f6 | ||
|
|
434738bae5 | ||
|
|
915540de32 | ||
|
|
5a5008080a | ||
|
|
3bc423f9b2 | ||
|
|
f2c33a5bf4 | ||
|
|
3e8b26418a | ||
|
|
60ce1cc110 | ||
|
|
2ff4d0961c | ||
|
|
d61dab3285 | ||
|
|
c439c9645d | ||
|
|
c2883704e6 | ||
| 47caec38a6 | |||
|
|
77c3babec7 | ||
|
|
6f03ce4d1d | ||
|
|
712f114763 | ||
|
|
c56184d30b | ||
|
|
9017ea4017 | ||
|
|
088fa6fe72 | ||
|
|
c90af9c196 | ||
|
|
a5a9bc73b0 | ||
|
|
6ed8150e48 | ||
|
|
fac312d860 | ||
|
|
549a0f5f52 | ||
|
|
4db102b3d1 | ||
|
|
c61a9e30ac | ||
|
|
27836beb75 | ||
|
|
099f76166e | ||
|
|
66026e903a | ||
|
|
3360d3c8c7 | ||
|
|
02980468db | ||
|
|
ec0e8a980c | ||
|
|
16d5b9a233 | ||
|
|
62c4bc5ade | ||
|
|
ccd657c9ec | ||
|
|
013af7e65f | ||
| 84978afd58 | |||
|
|
4cb5b303dc | ||
| 8fde3cec41 | |||
| 17ac195c5d | |||
| c1c5d14133 | |||
| 47144bdf81 | |||
| 42760bbd79 | |||
| d29bca853b | |||
| f8d27a1454 | |||
| 6030f30901 | |||
| a3c401194f | |||
|
|
6386510f52 | ||
| ec36e5c2ea | |||
|
|
ba86d18250 | ||
|
|
606a1f3774 | ||
|
|
b3a67ffc00 | ||
|
|
168290040c | ||
|
|
2b27da224e | ||
|
|
9e92b168ba | ||
|
|
bd159c35e8 | ||
|
|
b3e378b5fc | ||
|
|
b7c4f2e735 | ||
|
|
4a5dd3eea7 | ||
|
|
5af6d8dd9c | ||
|
|
5dfe390ac3 | ||
|
|
43c7b211c3 | ||
|
|
c5f9cfcaa0 | ||
|
|
67fce6f06a | ||
|
|
191b126462 | ||
|
|
cb05407bb6 | ||
| 4beb34764d | |||
|
|
4b4a8f4489 | ||
|
|
54d0fe0505 | ||
|
|
06f4d628db | ||
|
|
657f47e32f | ||
|
|
86f8feb291 | ||
|
|
6deec731e2 | ||
|
|
f5a5c62181 | ||
|
|
b8afd94b21 | ||
|
|
7b57965952 | ||
|
|
9dca7aff27 | ||
|
|
4d1f047baf | ||
|
|
925c7a211f | ||
|
|
d81120f59c | ||
|
|
e118eceb85 | ||
|
|
4a84fe9339 | ||
|
|
c6e13dc476 | ||
|
|
8f5d4cc385 | ||
|
|
2ffd60973d | ||
|
|
08af101b2e | ||
|
|
bb58868333 | ||
|
|
b05cdeec66 | ||
|
|
9ec465706a | ||
|
|
46a3c1768c | ||
|
|
6c8a67c520 | ||
|
|
bbaed3fb97 | ||
|
|
4700bc407e | ||
|
|
281fbcb31d | ||
|
|
a55221573b | ||
|
|
45acb45a05 | ||
|
|
11f1caa6da | ||
|
|
f769c9119b | ||
|
|
1145642255 |
11
.claude/memory/feedback_widget_decomposition.md
Normal file
11
.claude/memory/feedback_widget_decomposition.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: Widget decomposition and provider subscriptions
|
||||
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
|
||||
|
||||
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
|
||||
|
||||
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,6 @@
|
||||
target/
|
||||
scripts/__pycache__/
|
||||
.DS_Store
|
||||
.cargo/config.toml
|
||||
.vscode/
|
||||
docs/
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"git.enabled": false
|
||||
}
|
||||
@@ -8,7 +8,7 @@ when:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
- name: audit
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
|
||||
25
.woodpecker/server-lint.yaml
Normal file
25
.woodpecker/server-lint.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
- mise exec rust -- cargo clippy --all -- -D warnings
|
||||
@@ -24,4 +24,4 @@ steps:
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
- mise install cargo:cargo-nextest
|
||||
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast
|
||||
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features
|
||||
@@ -8,7 +8,7 @@ when:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
- name: vet
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
|
||||
18
.woodpecker/useragent-analyze.yaml
Normal file
18
.woodpecker/useragent-analyze.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
|
||||
|
||||
steps:
|
||||
- name: analyze
|
||||
image: jdxcode/mise:latest
|
||||
commands:
|
||||
- mise install flutter
|
||||
- mise install protoc
|
||||
# Reruns codegen to catch protocol drift
|
||||
- mise codegen
|
||||
- cd useragent/ && flutter analyze
|
||||
128
AGENTS.md
Normal file
128
AGENTS.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
|
||||
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
|
||||
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
|
||||
|
||||
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
|
||||
|
||||
## Toolchain Setup
|
||||
|
||||
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
|
||||
```sh
|
||||
mise install
|
||||
```
|
||||
|
||||
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
|
||||
|
||||
## Server (Rust workspace at `server/`)
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
|
||||
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
|
||||
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
|
||||
| `arbiter-client` | Rust client library for SDK clients |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd server
|
||||
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run the server daemon
|
||||
cargo run -p arbiter-server
|
||||
|
||||
# Run all tests (preferred over cargo test)
|
||||
cargo nextest run
|
||||
|
||||
# Run a single test
|
||||
cargo nextest run <test_name>
|
||||
|
||||
# Lint
|
||||
cargo clippy
|
||||
|
||||
# Security audit
|
||||
cargo audit
|
||||
|
||||
# Check unused dependencies
|
||||
cargo shear
|
||||
|
||||
# Run snapshot tests and update snapshots
|
||||
cargo insta review
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
|
||||
|
||||
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
|
||||
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
|
||||
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
|
||||
|
||||
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
|
||||
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
|
||||
|
||||
**Cryptography:**
|
||||
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
|
||||
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
|
||||
- Password KDF: Argon2
|
||||
- Unseal transport: X25519 ephemeral key exchange
|
||||
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
|
||||
|
||||
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
|
||||
|
||||
### Proto Regeneration
|
||||
|
||||
When `.proto` files in `protobufs/` change, rebuild to regenerate:
|
||||
```sh
|
||||
cd server && cargo build -p arbiter-proto
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```sh
|
||||
# Create a new migration
|
||||
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
|
||||
|
||||
# Run migrations manually (server also runs them on startup)
|
||||
diesel migration run --migration-dir crates/arbiter-server/migrations
|
||||
```
|
||||
|
||||
## User Agent (Flutter + Rinf at `useragent/`)
|
||||
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
|
||||
|
||||
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
|
||||
```sh
|
||||
cd useragent && rinf gen
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd useragent
|
||||
|
||||
# Run the app (macOS or Windows)
|
||||
flutter run
|
||||
|
||||
# Regenerate Rust↔Dart signal bindings
|
||||
rinf gen
|
||||
|
||||
# Analyze Dart code
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
204
ARCHITECTURE.md
204
ARCHITECTURE.md
@@ -3,7 +3,6 @@
|
||||
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
|
||||
|
||||
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Peer Types
|
||||
@@ -12,6 +11,7 @@ Arbiter distinguishes two kinds of peers:
|
||||
|
||||
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
|
||||
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
|
||||
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +43,149 @@ There is no bootstrap mechanism for SDK clients. They must be explicitly approve
|
||||
|
||||
---
|
||||
|
||||
## 3. Server Identity
|
||||
## 3. Multi-Operator Governance
|
||||
|
||||
When more than one User Agent is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
|
||||
|
||||
### 3.1 Voting Rules
|
||||
|
||||
Voting is based on the total number of registered operators:
|
||||
|
||||
- **1 operator:** no vote is needed; the single operator decides directly.
|
||||
- **2 operators:** full consensus is required; both operators must approve.
|
||||
- **3 or more operators:** quorum is `floor(N / 2) + 1`.
|
||||
|
||||
For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored.
|
||||
|
||||
Examples:
|
||||
|
||||
- **3 operators:** 2 approvals required
|
||||
- **4 operators:** 3 approvals required
|
||||
|
||||
### 3.2 Actions Requiring a Vote
|
||||
|
||||
In multi-operator mode, a successful vote is required for:
|
||||
|
||||
- approving new SDK clients
|
||||
- granting an SDK client visibility to a wallet
|
||||
- approving a one-off transaction
|
||||
- approving creation of a persistent grant
|
||||
- approving operator replacement
|
||||
- approving server updates
|
||||
- updating Shamir secret-sharing parameters
|
||||
|
||||
### 3.3 Special Rule for Key Rotation
|
||||
|
||||
Key rotation always requires full quorum, regardless of the normal voting threshold.
|
||||
|
||||
This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently.
|
||||
|
||||
### 3.4 Root Key Custody
|
||||
|
||||
When the vault has multiple operators, the vault root key is protected using Shamir secret sharing.
|
||||
|
||||
The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password.
|
||||
|
||||
For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example:
|
||||
|
||||
- **2 operators:** `2-of-2`
|
||||
- **3 operators:** `2-of-3`
|
||||
- **4 operators:** `3-of-4`
|
||||
|
||||
In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example:
|
||||
|
||||
- **3 ordinary operators + 2 recovery shares:** `2-of-5`
|
||||
|
||||
This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows.
|
||||
|
||||
### 3.5 Recovery Operators
|
||||
|
||||
Recovery Operators are a separate peer type from ordinary vault operators.
|
||||
|
||||
Their role is intentionally narrow. They can only:
|
||||
|
||||
- participate in unsealing the vault
|
||||
- vote for operator replacement
|
||||
|
||||
Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters.
|
||||
|
||||
### 3.6 Sleeping and Waking Recovery Operators
|
||||
|
||||
By default, Recovery Operators are **sleeping** and do not participate in any active flow.
|
||||
|
||||
Any ordinary operator may request that Recovery Operators **wake up**.
|
||||
|
||||
Any ordinary operator may also cancel a pending wake-up request.
|
||||
|
||||
This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**.
|
||||
|
||||
Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum.
|
||||
|
||||
The high-level recovery flow is:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Op as Ordinary Operator
|
||||
participant Server
|
||||
actor Other as Other Operator
|
||||
actor Rec as Recovery Operator
|
||||
|
||||
Op->>Server: Request recovery wake-up
|
||||
Server-->>Op: Wake-up pending
|
||||
Note over Server: Default dispute window: 14 days
|
||||
|
||||
alt Wake-up cancelled during dispute window
|
||||
Other->>Server: Cancel wake-up
|
||||
Server-->>Op: Recovery cancelled
|
||||
Server-->>Rec: Stay sleeping
|
||||
else No cancellation for 14 days
|
||||
Server-->>Rec: Wake up
|
||||
Rec->>Server: Join recovery flow
|
||||
critical Recovery authority
|
||||
Rec->>Server: Participate in unseal
|
||||
Rec->>Server: Vote on operator replacement
|
||||
end
|
||||
Server-->>Op: Recovery mode active
|
||||
end
|
||||
```
|
||||
|
||||
### 3.7 Committee Formation
|
||||
|
||||
There are two ways to form a multi-operator committee:
|
||||
|
||||
- convert an existing single-operator vault by adding new operators
|
||||
- bootstrap an unbootstrapped vault directly into multi-operator mode
|
||||
|
||||
In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations.
|
||||
|
||||
### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode
|
||||
|
||||
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
|
||||
|
||||
1. An operator connects to the unbootstrapped vault using 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 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
|
||||
5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully.
|
||||
|
||||
This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered.
|
||||
|
||||
### 3.9 Special Bootstrap Constraint for Two-Operator Vaults
|
||||
|
||||
If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap.
|
||||
|
||||
This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator.
|
||||
|
||||
---
|
||||
|
||||
## 4. Server Identity
|
||||
|
||||
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
|
||||
|
||||
@@ -56,9 +198,9 @@ Peers verify the server by its **public key fingerprint**:
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Management
|
||||
## 5. Key Management
|
||||
|
||||
### 4.1 Key Hierarchy
|
||||
### 5.1 Key Hierarchy
|
||||
|
||||
There are three layers of keys:
|
||||
|
||||
@@ -73,19 +215,19 @@ This layered design enables:
|
||||
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
|
||||
- **Root key rotation** without requiring the user to change their password.
|
||||
|
||||
### 4.2 Encryption at Rest
|
||||
### 5.2 Encryption at Rest
|
||||
|
||||
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 5. Vault Lifecycle
|
||||
## 6. Vault Lifecycle
|
||||
|
||||
### 5.1 Sealed State
|
||||
### 6.1 Sealed State
|
||||
|
||||
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
|
||||
|
||||
### 5.2 Unseal Flow
|
||||
### 6.2 Unseal Flow
|
||||
|
||||
To transition to the **Unsealed** state, a User Agent must provide the password:
|
||||
|
||||
@@ -96,7 +238,7 @@ To transition to the **Unsealed** state, a User Agent must provide the password:
|
||||
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
|
||||
- **Failure:** The server returns an error indicating the password is incorrect.
|
||||
|
||||
### 5.3 Memory Protection
|
||||
### 6.3 Memory Protection
|
||||
|
||||
Once unsealed, the root key must be protected in memory against:
|
||||
|
||||
@@ -108,9 +250,9 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
|
||||
|
||||
---
|
||||
|
||||
## 6. Permission Engine
|
||||
## 7. Permission Engine
|
||||
|
||||
### 6.1 Fundamental Rules
|
||||
### 7.1 Fundamental Rules
|
||||
|
||||
- SDK clients have **no access by default**.
|
||||
- Access is granted **explicitly** by a User Agent.
|
||||
@@ -120,11 +262,45 @@ Each blockchain requires its own policy system due to differences in static tran
|
||||
|
||||
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
|
||||
|
||||
### 6.2 EVM Policies
|
||||
### 7.2 EVM Policies
|
||||
|
||||
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
|
||||
|
||||
#### 6.2.1 Transaction Sub-Grants
|
||||
#### 7.2.0 Transaction Signing Sequence
|
||||
|
||||
The high-level interaction order is:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor SDK as SDK Client
|
||||
participant Server
|
||||
participant UA as User Agent
|
||||
|
||||
SDK->>Server: SignTransactionRequest
|
||||
Server->>Server: Resolve wallet and wallet visibility
|
||||
alt Visibility approval required
|
||||
Server->>UA: Ask for wallet visibility approval
|
||||
UA-->>Server: Vote result
|
||||
end
|
||||
Server->>Server: Evaluate transaction
|
||||
Server->>Server: Load grant and limits context
|
||||
alt Grant approval required
|
||||
Server->>UA: Ask for execution / grant approval
|
||||
UA-->>Server: Vote result
|
||||
opt Create persistent grant
|
||||
Server->>Server: Create and store grant
|
||||
end
|
||||
Server->>Server: Retry evaluation
|
||||
end
|
||||
critical Final authorization path
|
||||
Server->>Server: Check limits and record execution
|
||||
Server-->>Server: Signature or evaluation error
|
||||
end
|
||||
Server-->>SDK: Signature or error
|
||||
```
|
||||
|
||||
#### 7.2.1 Transaction Sub-Grants
|
||||
|
||||
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
|
||||
|
||||
@@ -148,7 +324,7 @@ Available restrictions:
|
||||
|
||||
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
|
||||
|
||||
#### 6.2.2 Global Limits
|
||||
#### 7.2.2 Global Limits
|
||||
|
||||
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
|
||||
|
||||
|
||||
128
CLAUDE.md
Normal file
128
CLAUDE.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
|
||||
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
|
||||
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
|
||||
|
||||
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
|
||||
|
||||
## Toolchain Setup
|
||||
|
||||
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
|
||||
```sh
|
||||
mise install
|
||||
```
|
||||
|
||||
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
|
||||
|
||||
## Server (Rust workspace at `server/`)
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
|
||||
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
|
||||
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
|
||||
| `arbiter-client` | Rust client library for SDK clients |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd server
|
||||
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run the server daemon
|
||||
cargo run -p arbiter-server
|
||||
|
||||
# Run all tests (preferred over cargo test)
|
||||
cargo nextest run
|
||||
|
||||
# Run a single test
|
||||
cargo nextest run <test_name>
|
||||
|
||||
# Lint
|
||||
cargo clippy
|
||||
|
||||
# Security audit
|
||||
cargo audit
|
||||
|
||||
# Check unused dependencies
|
||||
cargo shear
|
||||
|
||||
# Run snapshot tests and update snapshots
|
||||
cargo insta review
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
|
||||
|
||||
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
|
||||
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
|
||||
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
|
||||
|
||||
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
|
||||
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
|
||||
|
||||
**Cryptography:**
|
||||
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
|
||||
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
|
||||
- Password KDF: Argon2
|
||||
- Unseal transport: X25519 ephemeral key exchange
|
||||
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
|
||||
|
||||
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
|
||||
|
||||
### Proto Regeneration
|
||||
|
||||
When `.proto` files in `protobufs/` change, rebuild to regenerate:
|
||||
```sh
|
||||
cd server && cargo build -p arbiter-proto
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```sh
|
||||
# Create a new migration
|
||||
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
|
||||
|
||||
# Run migrations manually (server also runs them on startup)
|
||||
diesel migration run --migration-dir crates/arbiter-server/migrations
|
||||
```
|
||||
|
||||
## User Agent (Flutter + Rinf at `useragent/`)
|
||||
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
|
||||
|
||||
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
|
||||
```sh
|
||||
cd useragent && rinf gen
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd useragent
|
||||
|
||||
# Run the app (macOS or Windows)
|
||||
flutter run
|
||||
|
||||
# Regenerate Rust↔Dart signal bindings
|
||||
rinf gen
|
||||
|
||||
# Analyze Dart code
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
@@ -4,10 +4,81 @@ This document covers concrete technology choices and dependencies. For the archi
|
||||
|
||||
---
|
||||
|
||||
## Client Connection Flow
|
||||
|
||||
### Authentication Result Semantics
|
||||
|
||||
Authentication no longer uses an implicit success-only response shape. Both `client` and `user-agent` return explicit auth status enums over the wire.
|
||||
|
||||
- **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:
|
||||
|
||||
- **Transport/protocol failures** are surfaced as stream/status errors
|
||||
- **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status
|
||||
|
||||
Clients are expected to handle these status codes directly and present the concrete failure reason to the user.
|
||||
|
||||
### New Client Approval
|
||||
|
||||
When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Client connects]) --> B[Receive AuthChallengeRequest]
|
||||
B --> C{pubkey in DB?}
|
||||
|
||||
C -- yes --> D[Read nonce\nIncrement nonce in DB]
|
||||
D --> G
|
||||
|
||||
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
|
||||
E --> F{First response}
|
||||
F -- denied --> Z([Reject connection])
|
||||
F -- approved --> F2[Cancel remaining\nUserAgent requests]
|
||||
F2 --> F3[INSERT client\nnonce = 1]
|
||||
F3 --> G[Send AuthChallenge\nwith nonce]
|
||||
|
||||
G --> H[Receive AuthChallengeSolution]
|
||||
H --> I{Signature valid?}
|
||||
I -- no --> Z
|
||||
I -- yes --> J([Session started])
|
||||
```
|
||||
|
||||
### Known Issue: Concurrent Registration Race (TOCTOU)
|
||||
|
||||
Two connections presenting the same previously-unknown public key can race through the approval flow simultaneously:
|
||||
|
||||
1. Both check the DB → neither is registered.
|
||||
2. Both request approval from user agents → both receive approval.
|
||||
3. Both `INSERT` the client record → the second insert silently overwrites the first, resetting the nonce.
|
||||
|
||||
This means the first connection's nonce is invalidated by the second, causing its challenge verification to fail. A fix requires either serialising new-client registration (e.g. an in-memory lock keyed on pubkey) or replacing the separate check + insert with an `INSERT OR IGNORE` / upsert guarded by a unique constraint on `public_key`.
|
||||
|
||||
### Nonce Semantics
|
||||
|
||||
The `program_client.nonce` column stores the **next usable nonce** — i.e. it is always one ahead of the nonce last issued in a challenge.
|
||||
|
||||
- **New client:** inserted with `nonce = 1`; the first challenge is issued with `nonce = 0`.
|
||||
- **Existing client:** the current DB value is read and used as the challenge nonce, then immediately incremented within the same exclusive transaction, preventing replay.
|
||||
|
||||
---
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Authentication
|
||||
- **Signature scheme:** ed25519
|
||||
- **Client protocol:** ed25519
|
||||
|
||||
### User-Agent Authentication
|
||||
|
||||
User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
|
||||
|
||||
- **Supported schemes:** RSA, Ed25519, ECDSA (secp256k1)
|
||||
- **Why:** the user agent authenticates with keys backed by platform facilities, and those facilities differ by platform
|
||||
- **Apple Silicon Secure Enclave / Secure Element:** ECDSA-only in practice
|
||||
- **Windows Hello / TPM 2.0:** currently RSA-backed in our integration
|
||||
|
||||
This is why the user-agent auth protocol carries an explicit `KeyType`, while the SDK client protocol remains fixed to ed25519.
|
||||
|
||||
### Encryption at Rest
|
||||
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
|
||||
@@ -22,14 +93,147 @@ This document covers concrete technology choices and dependencies. For the archi
|
||||
## Communication
|
||||
|
||||
- **Protocol:** gRPC with Protocol Buffers
|
||||
- **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs
|
||||
- **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
|
||||
- **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket
|
||||
|
||||
### Request Multiplexing
|
||||
|
||||
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
|
||||
- Out-of-band server messages omit the response ID entirely
|
||||
- The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message
|
||||
|
||||
This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs.
|
||||
|
||||
---
|
||||
|
||||
## EVM Policy Engine
|
||||
|
||||
### Overview
|
||||
|
||||
The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing.
|
||||
|
||||
The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging.
|
||||
|
||||
### Transaction Evaluation Flow
|
||||
|
||||
`Engine::evaluate_transaction` runs the following steps in order:
|
||||
|
||||
1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`.
|
||||
2. **Find grant** — `Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address.
|
||||
3. **Check shared constraints** — `check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below).
|
||||
4. **Evaluate** — `Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
|
||||
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
|
||||
|
||||
The detailed branch structure is shown below:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
|
||||
B --> C{Wallet exists?}
|
||||
|
||||
C -- No --> Z1[Return wallet not found error]
|
||||
C -- Yes --> D[Check SDK client wallet visibility]
|
||||
|
||||
D --> E{Wallet visible to SDK client?}
|
||||
E -- No --> F[Start wallet visibility voting flow]
|
||||
F --> G{Vote approved?}
|
||||
G -- No --> Z2[Return wallet access denied error]
|
||||
G -- Yes --> H[Persist wallet visibility]
|
||||
E -- Yes --> I[Classify transaction meaning]
|
||||
H --> I
|
||||
|
||||
I --> J{Meaning supported?}
|
||||
J -- No --> Z3[Return unsupported transaction error]
|
||||
J -- Yes --> K[Find matching grant]
|
||||
|
||||
K --> L{Grant exists?}
|
||||
L -- Yes --> M[Check grant limits]
|
||||
L -- No --> N[Start execution or grant voting flow]
|
||||
|
||||
N --> O{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]
|
||||
P --> Q[Persist grant]
|
||||
Q --> M
|
||||
|
||||
M --> R{Limits exceeded?}
|
||||
R -- Yes --> Z5[Return evaluation error]
|
||||
R -- No --> S[Record transaction in logs]
|
||||
S --> T[Produce signature]
|
||||
T --> U[Return signature to SDK client]
|
||||
|
||||
note1[Limit checks include volume, count, and gas constraints.]
|
||||
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
|
||||
|
||||
K -. uses .-> note2
|
||||
M -. checks .-> note1
|
||||
```
|
||||
|
||||
### Policy Trait
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply |
|
||||
| `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s |
|
||||
| `create_grant` | Inserts policy-specific rows; returns the specific grant ID |
|
||||
| `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` |
|
||||
| `find_all_grants` | Returns all non-revoked grants (used for listing) |
|
||||
| `record_transaction` | Persists policy-specific data after execution |
|
||||
|
||||
`analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume).
|
||||
|
||||
### Registered Policies
|
||||
|
||||
**EtherTransfer** — plain ETH transfers (empty calldata)
|
||||
|
||||
- Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window)
|
||||
- Violations: recipient not in allowlist, cumulative ETH volume exceeded
|
||||
|
||||
**TokenTransfer** — ERC-20 `transfer(address,uint256)` calls
|
||||
|
||||
- Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`)
|
||||
- Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
|
||||
- Violations: recipient mismatch, any volumetric limit exceeded
|
||||
|
||||
### Grant Model
|
||||
|
||||
Every grant has two layers:
|
||||
|
||||
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
|
||||
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration.
|
||||
|
||||
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
|
||||
|
||||
The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion.
|
||||
|
||||
### Shared Constraints (enforced by the engine)
|
||||
|
||||
These are checked centrally in `check_shared_constraints` before policy evaluation:
|
||||
|
||||
| Constraint | Fields | Behaviour |
|
||||
|---|---|---|
|
||||
| Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range |
|
||||
| Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached |
|
||||
| Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit |
|
||||
|
||||
---
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright.
|
||||
- **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected.
|
||||
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Memory Protection
|
||||
|
||||
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
|
||||
|
||||
- **Current:** Using the `memsafe` crate as an interim solution
|
||||
- **Planned:** Custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
|
||||
- **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
|
||||
- **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
|
||||
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 MarketTakers
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
13
README.md
Normal file
13
README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Arbiter
|
||||
> Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains
|
||||
|
||||
## Security warning
|
||||
Arbiter can't meaningfully protect against host compromise. Potential attack flow:
|
||||
- Attacker steals TLS keys from database
|
||||
- Pretends to be server; just accepts user agent challenge solutions
|
||||
- Pretend to be in sealed state and performing DH with client
|
||||
- Steals user password and derives seal key
|
||||
|
||||
While this attack is highly targetive, it's still possible.
|
||||
|
||||
> This software is experimental. Do not use with funds you cannot afford to lose.
|
||||
1
app/.dart_tool/extension_discovery/vs_code.json
Normal file
1
app/.dart_tool/extension_discovery/vs_code.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":2,"entries":[{"package":"app","rootUri":"../","packageUri":"lib/"}]}
|
||||
@@ -31,6 +31,12 @@
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "cupertino_icons",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
|
||||
@@ -158,7 +164,7 @@
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "arbiter",
|
||||
"name": "app",
|
||||
"rootUri": "../",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.10"
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"roots": [
|
||||
"arbiter"
|
||||
"app"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "arbiter",
|
||||
"version": "0.1.0",
|
||||
"name": "app",
|
||||
"version": "1.0.0+1",
|
||||
"dependencies": [
|
||||
"cupertino_icons",
|
||||
"flutter"
|
||||
],
|
||||
"devDependencies": [
|
||||
@@ -39,6 +40,11 @@
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cupertino_icons",
|
||||
"version": "1.0.8",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"version": "0.0.0",
|
||||
@@ -1,122 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -1 +0,0 @@
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -1,10 +0,0 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable
|
||||
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/useragent
|
||||
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=0.1.0
|
||||
FLUTTER_BUILD_NUMBER=0.1.0
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/useragent"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=0.1.0"
|
||||
export "FLUTTER_BUILD_NUMBER=0.1.0"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
213
app/pubspec.lock
213
app/pubspec.lock
@@ -1,213 +0,0 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
sdks:
|
||||
dart: ">=3.10.8 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
@@ -1,89 +0,0 @@
|
||||
name: app
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.8
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
@@ -1,11 +0,0 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
}
|
||||
1308
docs/superpowers/plans/2026-03-28-grant-creation-refactor.md
Normal file
1308
docs/superpowers/plans/2026-03-28-grant-creation-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
821
docs/superpowers/plans/2026-03-28-grant-grid-view.md
Normal file
821
docs/superpowers/plans/2026-03-28-grant-grid-view.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# Grant Grid View Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support.
|
||||
|
||||
**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
|
||||
| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
|
||||
| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
|
||||
| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
|
||||
| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
|
||||
| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
|
||||
| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `Palette.token`
|
||||
|
||||
**Files:**
|
||||
- Modify: `useragent/lib/theme/palette.dart`
|
||||
|
||||
- [ ] **Step 1: Add the color**
|
||||
|
||||
Replace the contents of `useragent/lib/theme/palette.dart` with:
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Palette {
|
||||
static const ink = Color(0xFF15263C);
|
||||
static const coral = Color(0xFFE26254);
|
||||
static const cream = Color(0xFFFFFAF4);
|
||||
static const line = Color(0x1A15263C);
|
||||
static const token = Color(0xFF5C6BC0);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/theme/palette.dart
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```sh
|
||||
jj describe -m "feat(theme): add Palette.token for token-transfer grant cards"
|
||||
jj new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `listAllWalletAccesses` feature function
|
||||
|
||||
**Files:**
|
||||
- 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 `useragent/lib/features/connection/evm/wallet_access.dart`:
|
||||
|
||||
```dart
|
||||
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
|
||||
Connection connection,
|
||||
) async {
|
||||
final response = await connection.ask(
|
||||
UserAgentRequest(listWalletAccess: Empty()),
|
||||
);
|
||||
if (!response.hasListWalletAccessResponse()) {
|
||||
throw Exception(
|
||||
'Expected list wallet access response, got ${response.whichPayload()}',
|
||||
);
|
||||
}
|
||||
return response.listWalletAccessResponse.accesses.toList(growable: false);
|
||||
}
|
||||
```
|
||||
|
||||
Each returned `SdkClientWalletAccess` has:
|
||||
- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`)
|
||||
- `.access.walletId` — the EVM wallet DB ID
|
||||
- `.access.sdkClientId` — the SDK client DB ID
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```sh
|
||||
jj describe -m "feat(evm): add listAllWalletAccesses feature function"
|
||||
jj new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create `WalletAccessListProvider`
|
||||
|
||||
**Files:**
|
||||
- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
|
||||
- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart`
|
||||
|
||||
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
|
||||
|
||||
- [ ] **Step 1: Write the provider**
|
||||
|
||||
Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:arbiter/features/connection/evm/wallet_access.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';
|
||||
|
||||
part 'wallet_access_list.g.dart';
|
||||
|
||||
@riverpod
|
||||
class WalletAccessList extends _$WalletAccessList {
|
||||
@override
|
||||
Future<List<SdkClientWalletAccess>?> build() async {
|
||||
final connection = await ref.watch(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await listAllWalletAccesses(connection);
|
||||
} catch (e, st) {
|
||||
talker.handle(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final connection = await ref.read(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
state = const AsyncData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => listAllWalletAccesses(connection));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run code generation**
|
||||
|
||||
```sh
|
||||
cd useragent && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/providers/sdk_clients/
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```sh
|
||||
jj describe -m "feat(providers): add WalletAccessListProvider"
|
||||
jj new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create `GrantCard` widget
|
||||
|
||||
**Files:**
|
||||
- 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/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
|
||||
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
|
||||
- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name`
|
||||
- `revokeEvmGrantMutation` — `Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
|
||||
- `executeRevokeEvmGrant(ref, grantId: int)` — `Future<void>`
|
||||
|
||||
- [ ] **Step 1: Write the widget**
|
||||
|
||||
Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:arbiter/proto/evm.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';
|
||||
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
||||
import 'package:arbiter/theme/palette.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
String _shortAddress(List<int> bytes) {
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
|
||||
}
|
||||
|
||||
String _formatError(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
return message.substring('Exception: '.length);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
class GrantCard extends ConsumerWidget {
|
||||
const GrantCard({super.key, required this.grant});
|
||||
|
||||
final GrantEntry grant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Enrichment lookups — each watch scopes rebuilds to this card only
|
||||
final walletAccesses =
|
||||
ref.watch(walletAccessListProvider).asData?.value ?? const [];
|
||||
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
|
||||
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
|
||||
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
|
||||
|
||||
final isEther =
|
||||
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
|
||||
final accent = isEther ? Palette.coral : Palette.token;
|
||||
final typeLabel = isEther ? 'Ether' : 'Token';
|
||||
final theme = Theme.of(context);
|
||||
final muted = Palette.ink.withValues(alpha: 0.62);
|
||||
|
||||
// Resolve wallet_access_id → wallet address + client name
|
||||
final accessById = <int, SdkClientWalletAccess>{
|
||||
for (final a in walletAccesses) a.id: a,
|
||||
};
|
||||
final walletById = <int, WalletEntry>{
|
||||
for (final w in wallets) w.id: w,
|
||||
};
|
||||
final clientNameById = <int, String>{
|
||||
for (final c in clients) c.id: c.info.name,
|
||||
};
|
||||
|
||||
final accessId = grant.shared.walletAccessId;
|
||||
final access = accessById[accessId];
|
||||
final wallet = access != null ? walletById[access.access.walletId] : null;
|
||||
|
||||
final walletLabel = wallet != null
|
||||
? _shortAddress(wallet.address)
|
||||
: 'Access #$accessId';
|
||||
|
||||
final clientLabel = () {
|
||||
if (access == null) return '';
|
||||
final name = clientNameById[access.access.sdkClientId] ?? '';
|
||||
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
|
||||
}();
|
||||
|
||||
void showError(String message) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> revoke() async {
|
||||
try {
|
||||
await executeRevokeEvmGrant(ref, grantId: grant.id);
|
||||
} catch (e) {
|
||||
showError(_formatError(e));
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Palette.cream.withValues(alpha: 0.92),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Accent strip
|
||||
Container(
|
||||
width: 0.8.w,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Card body
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 1.6.w,
|
||||
vertical: 1.4.h,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Row 1: type badge · chain · spacer · revoke button
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 1.w,
|
||||
vertical: 0.4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
typeLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 1.w),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 1.w,
|
||||
vertical: 0.4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.ink.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Chain ${grant.shared.chainId}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (revoking)
|
||||
SizedBox(
|
||||
width: 1.8.h,
|
||||
height: 1.8.h,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Palette.coral,
|
||||
),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: revoke,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Palette.coral,
|
||||
side: BorderSide(
|
||||
color: Palette.coral.withValues(alpha: 0.4),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 1.w,
|
||||
vertical: 0.6.h,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.block_rounded, size: 16),
|
||||
label: const Text('Revoke'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 0.8.h),
|
||||
// Row 2: wallet address · client name
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
walletLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Palette.ink,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
|
||||
child: Text(
|
||||
'·',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
clientLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```sh
|
||||
jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment"
|
||||
jj new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create `EvmGrantsScreen`
|
||||
|
||||
**Files:**
|
||||
- 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 `useragent/lib/screens/dashboard/evm/grants/grants.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/providers/evm/evm_grants.dart';
|
||||
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
||||
import 'package:arbiter/router.gr.dart';
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
|
||||
import 'package:arbiter/theme/palette.dart';
|
||||
import 'package:arbiter/widgets/page_header.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
String _formatError(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
return message.substring('Exception: '.length);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// ─── State panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatePanel extends StatelessWidget {
|
||||
const _StatePanel({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
this.busy = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
final String? actionLabel;
|
||||
final Future<void> Function()? onAction;
|
||||
final bool busy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Palette.cream.withValues(alpha: 0.92),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(2.8.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (busy)
|
||||
SizedBox(
|
||||
width: 2.8.h,
|
||||
height: 2.8.h,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 34, color: Palette.coral),
|
||||
SizedBox(height: 1.8.h),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: Palette.ink,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 1.h),
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: Palette.ink.withValues(alpha: 0.72),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
SizedBox(height: 2.h),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => onAction!(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Grant list ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _GrantList extends StatelessWidget {
|
||||
const _GrantList({required this.grants});
|
||||
|
||||
final List<GrantEntry> grants;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < grants.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == grants.length - 1 ? 0 : 1.8.h,
|
||||
),
|
||||
child: GrantCard(grant: grants[i]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@RoutePage()
|
||||
class EvmGrantsScreen extends ConsumerWidget {
|
||||
const EvmGrantsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Screen watches only the grant list for top-level state decisions
|
||||
final grantsAsync = ref.watch(evmGrantsProvider);
|
||||
|
||||
Future<void> refresh() async {
|
||||
await Future.wait([
|
||||
ref.read(evmGrantsProvider.notifier).refresh(),
|
||||
ref.read(walletAccessListProvider.notifier).refresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
void showMessage(String message) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> safeRefresh() async {
|
||||
try {
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
showMessage(_formatError(e));
|
||||
}
|
||||
}
|
||||
|
||||
final grantsState = grantsAsync.asData?.value;
|
||||
final grants = grantsState?.grants;
|
||||
|
||||
final content = switch (grantsAsync) {
|
||||
AsyncLoading() when grantsState == null => const _StatePanel(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Loading grants',
|
||||
body: 'Pulling grant registry from Arbiter.',
|
||||
busy: true,
|
||||
),
|
||||
AsyncError(:final error) => _StatePanel(
|
||||
icon: Icons.sync_problem,
|
||||
title: 'Grant registry unavailable',
|
||||
body: _formatError(error),
|
||||
actionLabel: 'Retry',
|
||||
onAction: safeRefresh,
|
||||
),
|
||||
AsyncData(:final value) when value == null => _StatePanel(
|
||||
icon: Icons.portable_wifi_off,
|
||||
title: 'No active server connection',
|
||||
body: 'Reconnect to Arbiter to list EVM grants.',
|
||||
actionLabel: 'Refresh',
|
||||
onAction: safeRefresh,
|
||||
),
|
||||
_ when grants != null && grants.isEmpty => _StatePanel(
|
||||
icon: Icons.policy_outlined,
|
||||
title: 'No grants yet',
|
||||
body: 'Create a grant to allow SDK clients to sign transactions.',
|
||||
actionLabel: 'Create grant',
|
||||
onAction: () => context.router.push(const CreateEvmGrantRoute()),
|
||||
),
|
||||
_ => _GrantList(grants: grants ?? const []),
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator.adaptive(
|
||||
color: Palette.ink,
|
||||
backgroundColor: Colors.white,
|
||||
onRefresh: safeRefresh,
|
||||
child: ListView(
|
||||
physics: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'EVM Grants',
|
||||
isBusy: grantsAsync.isLoading,
|
||||
actions: [
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
context.router.push(const CreateEvmGrantRoute()),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Create grant'),
|
||||
),
|
||||
SizedBox(width: 1.w),
|
||||
OutlinedButton.icon(
|
||||
onPressed: safeRefresh,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Palette.ink,
|
||||
side: BorderSide(color: Palette.line),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 1.4.w,
|
||||
vertical: 1.2.h,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Refresh'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 1.8.h),
|
||||
content,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```sh
|
||||
jj describe -m "feat(grants): add EvmGrantsScreen"
|
||||
jj new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire router and dashboard tab
|
||||
|
||||
**Files:**
|
||||
- Modify: `useragent/lib/router.dart`
|
||||
- Modify: `useragent/lib/screens/dashboard.dart`
|
||||
- Regenerated: `useragent/lib/router.gr.dart`
|
||||
|
||||
- [ ] **Step 1: Add route to `router.dart`**
|
||||
|
||||
Replace the contents of `useragent/lib/router.dart` with:
|
||||
|
||||
```dart
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
|
||||
import 'router.gr.dart';
|
||||
|
||||
@AutoRouterConfig(generateForDir: ['lib/screens'])
|
||||
class Router extends RootStackRouter {
|
||||
@override
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
|
||||
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
|
||||
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
|
||||
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
|
||||
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
|
||||
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
|
||||
|
||||
AutoRoute(
|
||||
page: DashboardRouter.page,
|
||||
path: '/dashboard',
|
||||
children: [
|
||||
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
||||
AutoRoute(page: ClientsRoute.page, path: 'clients'),
|
||||
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
|
||||
AutoRoute(page: AboutRoute.page, path: 'about'),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `dashboard.dart`**
|
||||
|
||||
In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant:
|
||||
|
||||
```dart
|
||||
final routes = [
|
||||
const EvmRoute(),
|
||||
const ClientsRoute(),
|
||||
const EvmGrantsRoute(),
|
||||
const AboutRoute(),
|
||||
];
|
||||
```
|
||||
|
||||
And replace the `destinations` list inside `AdaptiveScaffold`:
|
||||
|
||||
```dart
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.account_balance_wallet_outlined),
|
||||
selectedIcon: Icon(Icons.account_balance_wallet),
|
||||
label: 'Wallets',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.devices_other_outlined),
|
||||
selectedIcon: Icon(Icons.devices_other),
|
||||
label: 'Clients',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.policy_outlined),
|
||||
selectedIcon: Icon(Icons.policy),
|
||||
label: 'Grants',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.info_outline),
|
||||
selectedIcon: Icon(Icons.info),
|
||||
label: 'About',
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Regenerate router**
|
||||
|
||||
```sh
|
||||
cd useragent && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
|
||||
|
||||
- [ ] **Step 4: Full project verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```sh
|
||||
jj describe -m "feat(nav): add Grants dashboard tab"
|
||||
jj new
|
||||
```
|
||||
170
docs/superpowers/specs/2026-03-28-grant-grid-view-design.md
Normal file
170
docs/superpowers/specs/2026-03-28-grant-grid-view-design.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Grant Grid View — Design Spec
|
||||
|
||||
**Date:** 2026-03-28
|
||||
|
||||
## Overview
|
||||
|
||||
Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
|
||||
|
||||
## Scope
|
||||
|
||||
- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs
|
||||
- New `EvmGrantsScreen` as a dashboard tab
|
||||
- Grant card widget with enriched display (type, chain, wallet, client)
|
||||
- Revoke action wired to existing `executeRevokeEvmGrant` mutation
|
||||
- Dashboard tab bar and router updated
|
||||
- New token-transfer accent color added to `Palette`
|
||||
|
||||
**Out of scope:** Fixing grant creation (separate task).
|
||||
|
||||
---
|
||||
|
||||
## Data Layer
|
||||
|
||||
### `walletAccessListProvider`
|
||||
|
||||
**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
|
||||
|
||||
- `@riverpod` class, watches `connectionManagerProvider.future`
|
||||
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
|
||||
- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId`
|
||||
- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()`
|
||||
|
||||
### Enrichment at render time (Approach A)
|
||||
|
||||
The `EvmGrantsScreen` watches four providers:
|
||||
1. `evmGrantsProvider` — the grant list
|
||||
2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id)
|
||||
3. `evmProvider` — to resolve wallet_id → wallet address
|
||||
4. `sdkClientsProvider` — to resolve sdk_client_id → client name
|
||||
|
||||
All lookups are in-memory Maps built inside the build method; no extra model class needed.
|
||||
|
||||
Fallbacks:
|
||||
- Wallet address not found → `"Access #N"` where N is the wallet_access_id
|
||||
- Client name not found → `"Client #N"` where N is the sdk_client_id
|
||||
|
||||
---
|
||||
|
||||
## Route Structure
|
||||
|
||||
```
|
||||
/dashboard
|
||||
/evm ← existing (Wallets tab)
|
||||
/clients ← existing (Clients tab)
|
||||
/grants ← NEW (Grants tab)
|
||||
/about ← existing
|
||||
|
||||
/evm-grants/create ← existing push route (unchanged)
|
||||
```
|
||||
|
||||
### Changes to `router.dart`
|
||||
|
||||
Add inside dashboard children:
|
||||
```dart
|
||||
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
|
||||
```
|
||||
|
||||
### Changes to `dashboard.dart`
|
||||
|
||||
Add to `routes` list:
|
||||
```dart
|
||||
const EvmGrantsRoute()
|
||||
```
|
||||
|
||||
Add `NavigationDestination`:
|
||||
```dart
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.policy_outlined),
|
||||
selectedIcon: Icon(Icons.policy),
|
||||
label: 'Grants',
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen: `EvmGrantsScreen`
|
||||
|
||||
**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart`
|
||||
|
||||
```
|
||||
Scaffold
|
||||
└─ SafeArea
|
||||
└─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider)
|
||||
└─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics)
|
||||
├─ PageHeader
|
||||
│ title: 'EVM Grants'
|
||||
│ isBusy: evmGrantsProvider.isLoading
|
||||
│ actions: [CreateGrantButton, RefreshButton]
|
||||
├─ SizedBox(height: 1.8.h)
|
||||
└─ <content>
|
||||
```
|
||||
|
||||
### State handling
|
||||
|
||||
Matches the pattern from `EvmScreen` and `ClientsScreen`:
|
||||
|
||||
| State | Display |
|
||||
|---|---|
|
||||
| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" |
|
||||
| Error | `_StatePanel` with coral icon, error message, Retry button |
|
||||
| No connection | `_StatePanel`, "No active server connection" |
|
||||
| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut |
|
||||
| Data | Column of `_GrantCard` widgets |
|
||||
|
||||
### Header actions
|
||||
|
||||
**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`.
|
||||
|
||||
**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`.
|
||||
|
||||
---
|
||||
|
||||
## Grant Card: `_GrantCard`
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
Container (rounded 24, Palette.cream bg, Palette.line border)
|
||||
└─ IntrinsicHeight > Row
|
||||
├─ Accent strip (0.8.w wide, full height, rounded left)
|
||||
└─ Padding > Column
|
||||
├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton
|
||||
└─ Row 2: WalletText + "·" + ClientText
|
||||
```
|
||||
|
||||
**Accent color by grant type:**
|
||||
- Ether transfer → `Palette.coral`
|
||||
- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`)
|
||||
|
||||
**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`.
|
||||
|
||||
**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color.
|
||||
|
||||
**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family.
|
||||
|
||||
**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink.
|
||||
|
||||
**RevokeButton:**
|
||||
- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'`
|
||||
- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))`
|
||||
- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight
|
||||
- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error
|
||||
|
||||
---
|
||||
|
||||
## Adaptive Sizing
|
||||
|
||||
All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values.
|
||||
|
||||
---
|
||||
|
||||
## Files to Create / Modify
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `lib/theme/palette.dart` | Modify — add `Palette.token` color |
|
||||
| `lib/providers/sdk_clients/wallet_access_list.dart` | Create |
|
||||
| `lib/screens/dashboard/evm/grants/grants.dart` | Create |
|
||||
| `lib/router.dart` | Modify — add grants route to dashboard children |
|
||||
| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations |
|
||||
130
mise.lock
130
mise.lock
@@ -1,35 +1,69 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.ast-grep]]
|
||||
version = "0.42.0"
|
||||
backend = "aqua:ast-grep/ast-grep"
|
||||
|
||||
[tools.ast-grep."platforms.linux-arm64"]
|
||||
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.linux-x64"]
|
||||
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.macos-arm64"]
|
||||
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
|
||||
|
||||
[tools.ast-grep."platforms.macos-x64"]
|
||||
checksum = "sha256:979ffe611327056f4730a1ae71b0209b3b830f58b22c6ed194cda34f55400db2"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-apple-darwin.zip"
|
||||
|
||||
[tools.ast-grep."platforms.windows-x64"]
|
||||
checksum = "sha256:55836fa1b2c65dc7d61615a4d9368622a0d2371a76d28b9a165e5a3ab6ae32a4"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-pc-windows-msvc.zip"
|
||||
|
||||
[[tools."cargo:cargo-audit"]]
|
||||
version = "0.22.1"
|
||||
backend = "cargo:cargo-audit"
|
||||
|
||||
[[tools."cargo:cargo-features"]]
|
||||
version = "1.0.0"
|
||||
backend = "cargo:cargo-features"
|
||||
[[tools."cargo:cargo-edit"]]
|
||||
version = "0.13.9"
|
||||
backend = "cargo:cargo-edit"
|
||||
|
||||
[[tools."cargo:cargo-features-manager"]]
|
||||
version = "0.11.1"
|
||||
backend = "cargo:cargo-features-manager"
|
||||
|
||||
[[tools."cargo:cargo-insta"]]
|
||||
version = "1.46.3"
|
||||
backend = "cargo:cargo-insta"
|
||||
|
||||
[[tools."cargo:cargo-mutants"]]
|
||||
version = "27.0.0"
|
||||
backend = "cargo:cargo-mutants"
|
||||
|
||||
[[tools."cargo:cargo-nextest"]]
|
||||
version = "0.9.126"
|
||||
backend = "cargo:cargo-nextest"
|
||||
|
||||
[[tools."cargo:cargo-shear"]]
|
||||
version = "1.9.1"
|
||||
version = "1.11.2"
|
||||
backend = "cargo:cargo-shear"
|
||||
|
||||
[[tools."cargo:cargo-vet"]]
|
||||
version = "0.10.2"
|
||||
backend = "cargo:cargo-vet"
|
||||
|
||||
[[tools."cargo:diesel-cli"]]
|
||||
version = "2.3.6"
|
||||
backend = "cargo:diesel-cli"
|
||||
|
||||
[tools."cargo:diesel-cli".options]
|
||||
default-features = "false"
|
||||
features = "sqlite,sqlite-bundled"
|
||||
|
||||
[[tools."cargo:diesel_cli"]]
|
||||
version = "2.3.6"
|
||||
backend = "cargo:diesel_cli"
|
||||
@@ -45,11 +79,73 @@ backend = "asdf:flutter"
|
||||
[[tools.protoc]]
|
||||
version = "29.6"
|
||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||
"platforms.linux-arm64" = { checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79", url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"}
|
||||
"platforms.linux-x64" = { checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323", url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"}
|
||||
"platforms.macos-arm64" = { checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed", url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"}
|
||||
"platforms.macos-x64" = { checksum = "sha256:312f04713946921cc0187ef34df80241ddca1bab6f564c636885fd2cc90d3f88", url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-x86_64.zip"}
|
||||
"platforms.windows-x64" = { checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f6f7", url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip"}
|
||||
|
||||
[tools.protoc."platforms.linux-arm64"]
|
||||
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
|
||||
|
||||
[tools.protoc."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
|
||||
|
||||
[tools.protoc."platforms.linux-x64"]
|
||||
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
|
||||
|
||||
[tools.protoc."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
|
||||
|
||||
[tools.protoc."platforms.macos-arm64"]
|
||||
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
|
||||
|
||||
[tools.protoc."platforms.macos-x64"]
|
||||
checksum = "sha256:312f04713946921cc0187ef34df80241ddca1bab6f564c636885fd2cc90d3f88"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-x86_64.zip"
|
||||
|
||||
[tools.protoc."platforms.windows-x64"]
|
||||
checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f6f7"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip"
|
||||
|
||||
[[tools.python]]
|
||||
version = "3.14.3"
|
||||
backend = "core:python"
|
||||
|
||||
[tools.python."platforms.linux-arm64"]
|
||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64"]
|
||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-arm64"]
|
||||
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-x64"]
|
||||
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.windows-x64"]
|
||||
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.rust]]
|
||||
version = "1.93.0"
|
||||
|
||||
16
mise.toml
16
mise.toml
@@ -2,10 +2,22 @@
|
||||
"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.38.9-stable"
|
||||
protoc = "29.6"
|
||||
rust = "1.93.0"
|
||||
"rust" = {version = "1.93.0", components = "clippy"}
|
||||
"cargo:cargo-features-manager" = "0.11.1"
|
||||
"cargo:cargo-nextest" = "0.9.126"
|
||||
"cargo:cargo-shear" = "latest"
|
||||
"cargo:cargo-insta" = "1.46.3"
|
||||
python = "3.14.3"
|
||||
ast-grep = "0.42.0"
|
||||
"cargo:cargo-edit" = "0.13.9"
|
||||
"cargo:cargo-mutants" = "27.0.0"
|
||||
|
||||
[tasks.codegen]
|
||||
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']
|
||||
outputs = ['useragent/lib/proto/**']
|
||||
run = '''
|
||||
dart pub global activate protoc_plugin && \
|
||||
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
|
||||
'''
|
||||
|
||||
@@ -2,30 +2,8 @@ syntax = "proto3";
|
||||
|
||||
package arbiter;
|
||||
|
||||
import "auth.proto";
|
||||
|
||||
message ClientRequest {
|
||||
oneof payload {
|
||||
arbiter.auth.ClientMessage auth_message = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message ClientResponse {
|
||||
oneof payload {
|
||||
arbiter.auth.ServerMessage auth_message = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message UserAgentRequest {
|
||||
oneof payload {
|
||||
arbiter.auth.ClientMessage auth_message = 1;
|
||||
}
|
||||
}
|
||||
message UserAgentResponse {
|
||||
oneof payload {
|
||||
arbiter.auth.ServerMessage auth_message = 1;
|
||||
}
|
||||
}
|
||||
import "client.proto";
|
||||
import "user_agent.proto";
|
||||
|
||||
message ServerInfo {
|
||||
string version = 1;
|
||||
@@ -33,6 +11,6 @@ message ServerInfo {
|
||||
}
|
||||
|
||||
service ArbiterService {
|
||||
rpc Client(stream ClientRequest) returns (stream ClientResponse);
|
||||
rpc UserAgent(stream UserAgentRequest) returns (stream UserAgentResponse);
|
||||
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
|
||||
rpc UserAgent(stream arbiter.user_agent.UserAgentRequest) returns (stream arbiter.user_agent.UserAgentResponse);
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.auth;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
optional string bootstrap_token = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
bytes pubkey = 1;
|
||||
int32 nonce = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
message AuthOk {}
|
||||
|
||||
message ClientMessage {
|
||||
oneof payload {
|
||||
AuthChallengeRequest auth_challenge_request = 1;
|
||||
AuthChallengeSolution auth_challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ServerMessage {
|
||||
oneof payload {
|
||||
AuthChallenge auth_challenge = 1;
|
||||
AuthOk auth_ok = 2;
|
||||
}
|
||||
}
|
||||
25
protobufs/client.proto
Normal file
25
protobufs/client.proto
Normal file
@@ -0,0 +1,25 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client;
|
||||
|
||||
import "client/auth.proto";
|
||||
import "client/evm.proto";
|
||||
import "client/vault.proto";
|
||||
|
||||
message ClientRequest {
|
||||
int32 request_id = 4;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ClientResponse {
|
||||
optional int32 request_id = 7;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
}
|
||||
}
|
||||
43
protobufs/client/auth.proto
Normal file
43
protobufs/client/auth.proto
Normal file
@@ -0,0 +1,43 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.auth;
|
||||
|
||||
import "shared/client.proto";
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo client_info = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
bytes pubkey = 1;
|
||||
int32 nonce = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_APPROVAL_DENIED = 4;
|
||||
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
19
protobufs/client/evm.proto
Normal file
19
protobufs/client/evm.proto
Normal file
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.evm;
|
||||
|
||||
import "evm.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
18
protobufs/client/vault.proto
Normal file
18
protobufs/client/vault.proto
Normal file
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
}
|
||||
}
|
||||
153
protobufs/evm.proto
Normal file
153
protobufs/evm.proto
Normal file
@@ -0,0 +1,153 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "shared/evm.proto";
|
||||
|
||||
enum EvmError {
|
||||
EVM_ERROR_UNSPECIFIED = 0;
|
||||
EVM_ERROR_VAULT_SEALED = 1;
|
||||
EVM_ERROR_INTERNAL = 2;
|
||||
}
|
||||
|
||||
message WalletEntry {
|
||||
int32 id = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
}
|
||||
|
||||
message WalletList {
|
||||
repeated WalletEntry wallets = 1;
|
||||
}
|
||||
|
||||
message WalletCreateResponse {
|
||||
oneof result {
|
||||
WalletEntry wallet = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message WalletListResponse {
|
||||
oneof result {
|
||||
WalletList wallets = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grant types ---
|
||||
|
||||
message TransactionRateLimit {
|
||||
uint32 count = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message VolumeRateLimit {
|
||||
bytes max_volume = 1; // U256 as big-endian bytes
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message SharedSettings {
|
||||
int32 wallet_access_id = 1;
|
||||
uint64 chain_id = 2;
|
||||
optional google.protobuf.Timestamp valid_from = 3;
|
||||
optional google.protobuf.Timestamp valid_until = 4;
|
||||
optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes
|
||||
optional TransactionRateLimit rate_limit = 7;
|
||||
}
|
||||
|
||||
message EtherTransferSettings {
|
||||
repeated bytes targets = 1; // list of 20-byte Ethereum addresses
|
||||
VolumeRateLimit limit = 2;
|
||||
}
|
||||
|
||||
message TokenTransferSettings {
|
||||
bytes token_contract = 1; // 20-byte Ethereum address
|
||||
optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed
|
||||
repeated VolumeRateLimit volume_limits = 3;
|
||||
}
|
||||
|
||||
message SpecificGrant {
|
||||
oneof grant {
|
||||
EtherTransferSettings ether_transfer = 1;
|
||||
TokenTransferSettings token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- UserAgent grant management ---
|
||||
message EvmGrantCreateRequest {
|
||||
SharedSettings shared = 1;
|
||||
SpecificGrant specific = 2;
|
||||
}
|
||||
|
||||
message EvmGrantCreateResponse {
|
||||
oneof result {
|
||||
int32 grant_id = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmGrantDeleteRequest {
|
||||
int32 grant_id = 1;
|
||||
}
|
||||
|
||||
message EvmGrantDeleteResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic grant info returned in grant listings
|
||||
message GrantEntry {
|
||||
int32 id = 1;
|
||||
int32 wallet_access_id = 2;
|
||||
SharedSettings shared = 3;
|
||||
SpecificGrant specific = 4;
|
||||
}
|
||||
|
||||
message EvmGrantListRequest {
|
||||
optional int32 wallet_access_id = 1;
|
||||
}
|
||||
|
||||
message EvmGrantListResponse {
|
||||
oneof result {
|
||||
EvmGrantList grants = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmGrantList {
|
||||
repeated GrantEntry grants = 1;
|
||||
}
|
||||
|
||||
// --- Client transaction operations ---
|
||||
|
||||
message EvmSignTransactionRequest {
|
||||
bytes wallet_address = 1; // 20-byte Ethereum address
|
||||
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned)
|
||||
}
|
||||
|
||||
// oneof because signing and evaluation happen atomically — a signing failure
|
||||
// is always either an eval error or an internal error, never a partial success
|
||||
message EvmSignTransactionResponse {
|
||||
oneof result {
|
||||
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmAnalyzeTransactionRequest {
|
||||
bytes wallet_address = 1; // 20-byte Ethereum address
|
||||
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
|
||||
}
|
||||
|
||||
message EvmAnalyzeTransactionResponse {
|
||||
oneof result {
|
||||
arbiter.shared.evm.SpecificMeaning meaning = 1;
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
9
protobufs/shared/client.proto
Normal file
9
protobufs/shared/client.proto
Normal file
@@ -0,0 +1,9 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
message ClientInfo {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional string version = 3;
|
||||
}
|
||||
74
protobufs/shared/evm.proto
Normal file
74
protobufs/shared/evm.proto
Normal file
@@ -0,0 +1,74 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message EtherTransferMeaning {
|
||||
bytes to = 1; // 20-byte Ethereum address
|
||||
bytes value = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message TokenInfo {
|
||||
string symbol = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
uint64 chain_id = 3;
|
||||
}
|
||||
|
||||
// Mirror of token_transfers::Meaning
|
||||
message TokenTransferMeaning {
|
||||
TokenInfo token = 1;
|
||||
bytes to = 2; // 20-byte Ethereum address
|
||||
bytes value = 3; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
// Mirror of policies::SpecificMeaning
|
||||
message SpecificMeaning {
|
||||
oneof meaning {
|
||||
EtherTransferMeaning ether_transfer = 1;
|
||||
TokenTransferMeaning token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GasLimitExceededViolation {
|
||||
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message EvalViolation {
|
||||
message ChainIdMismatch {
|
||||
uint64 expected = 1;
|
||||
uint64 actual = 2;
|
||||
}
|
||||
oneof kind {
|
||||
bytes invalid_target = 1; // 20-byte Ethereum address
|
||||
GasLimitExceededViolation gas_limit_exceeded = 2;
|
||||
google.protobuf.Empty rate_limit_exceeded = 3;
|
||||
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
||||
google.protobuf.Empty invalid_time = 5;
|
||||
google.protobuf.Empty invalid_transaction_type = 6;
|
||||
|
||||
ChainIdMismatch chain_id_mismatch = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction was classified but no grant covers it
|
||||
message NoMatchingGrantError {
|
||||
SpecificMeaning meaning = 1;
|
||||
}
|
||||
|
||||
// Transaction was classified and a grant was found, but constraints were violated
|
||||
message PolicyViolationsError {
|
||||
SpecificMeaning meaning = 1;
|
||||
repeated EvalViolation violations = 2;
|
||||
}
|
||||
|
||||
// top-level error returned when transaction evaluation fails
|
||||
message TransactionEvalError {
|
||||
oneof kind {
|
||||
google.protobuf.Empty contract_creation_not_supported = 1;
|
||||
google.protobuf.Empty unsupported_transaction_type = 2;
|
||||
NoMatchingGrantError no_matching_grant = 3;
|
||||
PolicyViolationsError policy_violations = 4;
|
||||
}
|
||||
}
|
||||
11
protobufs/shared/vault.proto
Normal file
11
protobufs/shared/vault.proto
Normal file
@@ -0,0 +1,11 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_SEALED = 2;
|
||||
VAULT_STATE_UNSEALED = 3;
|
||||
VAULT_STATE_ERROR = 4;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.unseal;
|
||||
|
||||
message UserAgentKeyRequest {}
|
||||
|
||||
message ServerKeyResponse {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
message UserAgentSealedKey {
|
||||
bytes sealed_key = 1;
|
||||
bytes pubkey = 2;
|
||||
bytes nonce = 3;
|
||||
}
|
||||
28
protobufs/user_agent.proto
Normal file
28
protobufs/user_agent.proto
Normal file
@@ -0,0 +1,28 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent;
|
||||
|
||||
import "user_agent/auth.proto";
|
||||
import "user_agent/evm.proto";
|
||||
import "user_agent/sdk_client.proto";
|
||||
import "user_agent/vault/vault.proto";
|
||||
|
||||
message UserAgentRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message UserAgentResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
}
|
||||
}
|
||||
48
protobufs/user_agent/auth.proto
Normal file
48
protobufs/user_agent/auth.proto
Normal file
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.auth;
|
||||
|
||||
enum KeyType {
|
||||
KEY_TYPE_UNSPECIFIED = 0;
|
||||
KEY_TYPE_ED25519 = 1;
|
||||
KEY_TYPE_ECDSA_SECP256K1 = 2;
|
||||
KEY_TYPE_RSA = 3;
|
||||
}
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
optional string bootstrap_token = 2;
|
||||
KeyType key_type = 3;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
int32 nonce = 1;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
|
||||
AUTH_RESULT_TOKEN_INVALID = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
33
protobufs/user_agent/evm.proto
Normal file
33
protobufs/user_agent/evm.proto
Normal file
@@ -0,0 +1,33 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.evm;
|
||||
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message SignTransactionRequest {
|
||||
int32 client_id = 1;
|
||||
arbiter.evm.EvmSignTransactionRequest request = 2;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty wallet_create = 1;
|
||||
google.protobuf.Empty wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateRequest grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListRequest grant_list = 5;
|
||||
SignTransactionRequest sign_transaction = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.WalletCreateResponse wallet_create = 1;
|
||||
arbiter.evm.WalletListResponse wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateResponse grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListResponse grant_list = 5;
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 6;
|
||||
}
|
||||
}
|
||||
100
protobufs/user_agent/sdk_client.proto
Normal file
100
protobufs/user_agent/sdk_client.proto
Normal file
@@ -0,0 +1,100 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.sdk_client;
|
||||
|
||||
import "shared/client.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
enum Error {
|
||||
ERROR_UNSPECIFIED = 0;
|
||||
ERROR_ALREADY_EXISTS = 1;
|
||||
ERROR_NOT_FOUND = 2;
|
||||
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
|
||||
ERROR_INTERNAL = 4;
|
||||
}
|
||||
|
||||
message RevokeRequest {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message Entry {
|
||||
int32 id = 1;
|
||||
bytes pubkey = 2;
|
||||
arbiter.shared.ClientInfo info = 3;
|
||||
int32 created_at = 4;
|
||||
}
|
||||
|
||||
message List {
|
||||
repeated Entry clients = 1;
|
||||
}
|
||||
|
||||
message RevokeResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
oneof result {
|
||||
List clients = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ConnectionRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo info = 2;
|
||||
}
|
||||
|
||||
message ConnectionResponse {
|
||||
bool approved = 1;
|
||||
bytes pubkey = 2;
|
||||
}
|
||||
|
||||
message ConnectionCancel {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
|
||||
message WalletAccess {
|
||||
int32 wallet_id = 1;
|
||||
int32 sdk_client_id = 2;
|
||||
}
|
||||
|
||||
message WalletAccessEntry {
|
||||
int32 id = 1;
|
||||
WalletAccess access = 2;
|
||||
}
|
||||
|
||||
message GrantWalletAccess {
|
||||
repeated WalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message RevokeWalletAccess {
|
||||
repeated int32 accesses = 1;
|
||||
}
|
||||
|
||||
message ListWalletAccessResponse {
|
||||
repeated WalletAccessEntry accesses = 1;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
ConnectionResponse connection_response = 1;
|
||||
RevokeRequest revoke = 2;
|
||||
google.protobuf.Empty list = 3;
|
||||
GrantWalletAccess grant_wallet_access = 4;
|
||||
RevokeWalletAccess revoke_wallet_access = 5;
|
||||
google.protobuf.Empty list_wallet_access = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
ConnectionRequest connection_request = 1;
|
||||
ConnectionCancel connection_cancel = 2;
|
||||
RevokeResponse revoke = 3;
|
||||
ListResponse list = 4;
|
||||
ListWalletAccessResponse list_wallet_access = 5;
|
||||
}
|
||||
}
|
||||
24
protobufs/user_agent/vault/bootstrap.proto
Normal file
24
protobufs/user_agent/vault/bootstrap.proto
Normal file
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault.bootstrap;
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
BootstrapEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
|
||||
message Response {
|
||||
BootstrapResult result = 1;
|
||||
}
|
||||
37
protobufs/user_agent/vault/unseal.proto
Normal file
37
protobufs/user_agent/vault/unseal.proto
Normal file
@@ -0,0 +1,37 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault.unseal;
|
||||
|
||||
message UnsealStart {
|
||||
bytes client_pubkey = 1;
|
||||
}
|
||||
|
||||
message UnsealStartResponse {
|
||||
bytes server_pubkey = 1;
|
||||
}
|
||||
message UnsealEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||
UNSEAL_RESULT_SUCCESS = 1;
|
||||
UNSEAL_RESULT_INVALID_KEY = 2;
|
||||
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
UnsealStart start = 1;
|
||||
UnsealEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
UnsealStartResponse start = 1;
|
||||
UnsealResult result = 2;
|
||||
}
|
||||
}
|
||||
24
protobufs/user_agent/vault/vault.proto
Normal file
24
protobufs/user_agent/vault/vault.proto
Normal file
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
import "user_agent/vault/bootstrap.proto";
|
||||
import "user_agent/vault/unseal.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
unseal.Request unseal = 2;
|
||||
bootstrap.Request bootstrap = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
unseal.Response unseal = 2;
|
||||
bootstrap.Response bootstrap = 3;
|
||||
}
|
||||
}
|
||||
150
scripts/gen_erc20_registry.py
Normal file
150
scripts/gen_erc20_registry.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch the Uniswap default token list and emit Rust `TokenInfo` statics.
|
||||
|
||||
Usage:
|
||||
python3 gen_erc20_registry.py # fetch from IPFS
|
||||
python3 gen_erc20_registry.py tokens.json # local file
|
||||
python3 gen_erc20_registry.py tokens.json out.rs # custom output file
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
|
||||
UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org"
|
||||
|
||||
SOLANA_CHAIN_ID = 501000101
|
||||
IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+")
|
||||
|
||||
|
||||
def load_tokens(source=None):
|
||||
if source:
|
||||
with open(source) as f:
|
||||
return json.load(f)
|
||||
req = urllib.request.Request(
|
||||
UNISWAP_URL,
|
||||
headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def to_screaming_case(name: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", name or "")
|
||||
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper()
|
||||
if not snake:
|
||||
snake = "TOKEN"
|
||||
if snake[0].isdigit():
|
||||
snake = f"TOKEN_{snake}"
|
||||
return snake
|
||||
|
||||
|
||||
def static_name_for_token(token: dict, used_names: set[str]) -> str:
|
||||
base = to_screaming_case(token.get("name", ""))
|
||||
if base not in used_names:
|
||||
used_names.add(base)
|
||||
return base
|
||||
|
||||
address = token["address"]
|
||||
suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}"
|
||||
candidate = f"{base}_{suffix}"
|
||||
|
||||
i = 2
|
||||
while candidate in used_names:
|
||||
candidate = f"{base}_{suffix}_{i}"
|
||||
i += 1
|
||||
|
||||
used_names.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def main():
|
||||
source = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs"
|
||||
data = load_tokens(source)
|
||||
tokens = data["tokens"]
|
||||
|
||||
# Deduplicate by (chainId, address)
|
||||
seen = set()
|
||||
unique = []
|
||||
for t in tokens:
|
||||
key = (t["chainId"], t["address"].lower())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(t)
|
||||
|
||||
unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper()))
|
||||
evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID]
|
||||
|
||||
ver = data["version"]
|
||||
lines = []
|
||||
w = lines.append
|
||||
|
||||
w(
|
||||
f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}"
|
||||
)
|
||||
w(f"// {len(evm_tokens)} tokens")
|
||||
w("// DO NOT EDIT - regenerate with gen_erc20_registry.py")
|
||||
w("")
|
||||
|
||||
used_static_names = set()
|
||||
token_statics = []
|
||||
for t in evm_tokens:
|
||||
static_name = static_name_for_token(t, used_static_names)
|
||||
token_statics.append((static_name, t))
|
||||
|
||||
for static_name, t in token_statics:
|
||||
addr = t["address"]
|
||||
name = escape(t.get("name", ""))
|
||||
symbol = escape(t.get("symbol", ""))
|
||||
decimals = t.get("decimals", 18)
|
||||
logo = t.get("logoURI")
|
||||
chain = t["chainId"]
|
||||
|
||||
logo_val = f'Some("{escape(logo)}")' if logo else "None"
|
||||
|
||||
w(f"pub static {static_name}: TokenInfo = TokenInfo {{")
|
||||
w(f' name: "{name}",')
|
||||
w(f' symbol: "{symbol}",')
|
||||
w(f" decimals: {decimals},")
|
||||
w(f' contract: address!("{addr}"),')
|
||||
w(f" chain: {chain},")
|
||||
w(f" logo_uri: {logo_val},")
|
||||
w("};")
|
||||
w("")
|
||||
|
||||
w("pub static TOKENS: &[&TokenInfo] = &[")
|
||||
for static_name, _ in token_statics:
|
||||
w(f" &{static_name},")
|
||||
w("];")
|
||||
w("")
|
||||
w("pub fn get_token(")
|
||||
w(" chain_id: alloy::primitives::ChainId,")
|
||||
w(" address: alloy::primitives::Address,")
|
||||
w(") -> Option<&'static TokenInfo> {")
|
||||
w(" match (chain_id, address) {")
|
||||
for static_name, t in token_statics:
|
||||
w(
|
||||
f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),'
|
||||
)
|
||||
w(" _ => None,")
|
||||
w(" }")
|
||||
w("}")
|
||||
w("")
|
||||
|
||||
with open(output, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Wrote {len(token_statics)} tokens to {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
server/.cargo/audit.toml
Normal file
13
server/.cargo/audit.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[advisories]
|
||||
# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate.
|
||||
# No fixed version is available upstream.
|
||||
# RSA support is required for Windows Hello / KeyCredentialManager
|
||||
# (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync),
|
||||
# which only issues RSA-2048 keys.
|
||||
# Mitigations in place:
|
||||
# - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to
|
||||
# protect the private key from timing recovery during signing.
|
||||
# - RSA decryption is never performed; we only verify public-key signatures.
|
||||
# - The attack requires local, high-resolution timing access against the
|
||||
# signing process, which is not exposed in our threat model.
|
||||
ignore = ["RUSTSEC-2023-0071"]
|
||||
1
server/.cargo/mutants.toml
Normal file
1
server/.cargo/mutants.toml
Normal file
@@ -0,0 +1 @@
|
||||
test_tool = "nextest"
|
||||
2
server/.gitignore
vendored
Normal file
2
server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
mutants.out/
|
||||
mutants.out.old/
|
||||
3710
server/Cargo.lock
generated
3710
server/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,47 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/arbiter-client",
|
||||
"crates/arbiter-proto",
|
||||
"crates/arbiter-server",
|
||||
"crates/arbiter-useragent",
|
||||
"crates/*",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
disallowed-methods = "deny"
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
tonic = { version = "0.14.3", features = ["deflate", "gzip", "tls-connect-info", "zstd"] }
|
||||
tonic = { version = "0.14.5", features = [
|
||||
"deflate",
|
||||
"gzip",
|
||||
"tls-connect-info",
|
||||
"zstd",
|
||||
] }
|
||||
tracing = "0.1.44"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
rand = "0.10.0"
|
||||
rustls = "0.23.36"
|
||||
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
|
||||
smlang = "0.8.0"
|
||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||
thiserror = "2.0.18"
|
||||
async-trait = "0.1.89"
|
||||
futures = "0.3.31"
|
||||
futures = "0.3.32"
|
||||
tokio-stream = { version = "0.1.18", features = ["full"] }
|
||||
kameo = "0.19.2"
|
||||
prost-types = { version = "0.14.3", features = ["chrono"] }
|
||||
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
|
||||
rstest = "0.26.1"
|
||||
rustls-pki-types = "1.14.0"
|
||||
alloy = "1.7.3"
|
||||
rcgen = { version = "0.14.7", features = [
|
||||
"aws_lc_rs",
|
||||
"pem",
|
||||
"x509-parser",
|
||||
"zeroize",
|
||||
], default-features = false }
|
||||
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
||||
rsa = { version = "0.9", features = ["sha2"] }
|
||||
sha2 = "0.10"
|
||||
spki = "0.7"
|
||||
prost = "0.14.3"
|
||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||
mutants = "0.0.4"
|
||||
|
||||
11
server/clippy.toml
Normal file
11
server/clippy.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
disallowed-methods = [
|
||||
# RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack).
|
||||
# We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption
|
||||
# is never required and must not be introduced.
|
||||
{ path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||
|
||||
{ path = "arbiter_server::crypto::integrity::v1::lookup_verified_allow_unavailable", reason = "This function allows integrity checks to be bypassed when vault key material is unavailable, which can lead to silent security failures if used incorrectly. It should only be used in specific contexts where this behavior is acceptable, and its use should be carefully audited." },
|
||||
]
|
||||
Binary file not shown.
@@ -3,5 +3,24 @@ name = "arbiter-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
evm = ["dep:alloy"]
|
||||
|
||||
[dependencies]
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
alloy = { workspace = true, optional = true }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
thiserror.workspace = true
|
||||
http = "1.4.0"
|
||||
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }
|
||||
async-trait.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
149
server/crates/arbiter-client/src/auth.rs
Normal file
149
server/crates/arbiter-client/src/auth.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, format_challenge,
|
||||
proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
auth::{
|
||||
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
|
||||
AuthResult, request::Payload as AuthRequestPayload,
|
||||
response::Payload as AuthResponsePayload,
|
||||
},
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
},
|
||||
shared::ClientInfo as ProtoClientInfo,
|
||||
},
|
||||
};
|
||||
use ed25519_dalek::Signer as _;
|
||||
|
||||
use crate::{
|
||||
storage::StorageError,
|
||||
transport::{ClientTransport, next_request_id},
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Auth challenge was not returned by server")]
|
||||
MissingAuthChallenge,
|
||||
|
||||
#[error("Client approval denied by User Agent")]
|
||||
ApprovalDenied,
|
||||
|
||||
#[error("No User Agents online to approve client")]
|
||||
NoUserAgentsOnline,
|
||||
|
||||
#[error("Unexpected auth response payload")]
|
||||
UnexpectedAuthResponse,
|
||||
|
||||
#[error("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::NoUserAgentsOnline => AuthError::NoUserAgentsOnline,
|
||||
AuthResult::Unspecified
|
||||
| AuthResult::Success
|
||||
| AuthResult::InvalidKey
|
||||
| AuthResult::InvalidSignature
|
||||
| AuthResult::Internal => AuthError::UnexpectedAuthResponse,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_request(
|
||||
transport: &mut ClientTransport,
|
||||
metadata: ClientMetadata,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
) -> std::result::Result<(), AuthError> {
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
|
||||
pubkey: key.verifying_key().to_bytes().to_vec(),
|
||||
client_info: Some(ProtoClientInfo {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
version: metadata.version,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_challenge(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<AuthChallenge, AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| AuthError::MissingAuthChallenge)?;
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
|
||||
match payload {
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
None => Err(AuthError::MissingAuthChallenge),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_solution(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
challenge: AuthChallenge,
|
||||
) -> std::result::Result<(), AuthError> {
|
||||
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
|
||||
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
|
||||
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeSolution(
|
||||
AuthChallengeSolution { signature },
|
||||
)),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_confirmation(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<(), AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)?;
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
|
||||
match payload {
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Result(result))
|
||||
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn authenticate(
|
||||
transport: &mut ClientTransport,
|
||||
metadata: ClientMetadata,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
) -> 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?;
|
||||
receive_auth_confirmation(transport).await
|
||||
}
|
||||
44
server/crates/arbiter-client/src/bin/test_connect.rs
Normal file
44
server/crates/arbiter-client/src/bin/test_connect.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::io::{self, Write};
|
||||
|
||||
use arbiter_client::ArbiterClient;
|
||||
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("Testing connection to Arbiter server...");
|
||||
print!("Enter ArbiterUrl: ");
|
||||
let _ = io::stdout().flush();
|
||||
|
||||
let mut input = String::new();
|
||||
if let Err(err) = io::stdin().read_line(&mut input) {
|
||||
eprintln!("Failed to read input: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
eprintln!("ArbiterUrl cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
let url = match ArbiterUrl::try_from(input) {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
eprintln!("Invalid ArbiterUrl: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("{:#?}", url);
|
||||
|
||||
let metadata = ClientMetadata {
|
||||
name: "arbiter-client test_connect".to_string(),
|
||||
description: Some("Manual connection smoke test".to_string()),
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
};
|
||||
|
||||
match ArbiterClient::connect(url, metadata).await {
|
||||
Ok(_) => println!("Connected and authenticated successfully."),
|
||||
Err(err) => eprintln!("Failed to connect: {:#?}", err),
|
||||
}
|
||||
}
|
||||
94
server/crates/arbiter-client/src/client.rs
Normal file
94
server/crates/arbiter-client/src/client.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::ClientTlsConfig;
|
||||
|
||||
use crate::{
|
||||
StorageError,
|
||||
auth::{AuthError, authenticate},
|
||||
storage::{FileSigningKeyStorage, SigningKeyStorage},
|
||||
transport::{BUFFER_LENGTH, ClientTransport},
|
||||
};
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
use crate::wallets::evm::ArbiterEvmWallet;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("gRPC error")]
|
||||
Grpc(#[from] tonic::Status),
|
||||
|
||||
#[error("Could not establish connection")]
|
||||
Connection(#[from] tonic::transport::Error),
|
||||
|
||||
#[error("Invalid server URI")]
|
||||
InvalidUri(#[from] http::uri::InvalidUri),
|
||||
|
||||
#[error("Invalid CA certificate")]
|
||||
InvalidCaCert(#[from] webpki::Error),
|
||||
|
||||
#[error("Authentication error")]
|
||||
Authentication(#[from] AuthError),
|
||||
|
||||
#[error("Storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
}
|
||||
|
||||
pub struct ArbiterClient {
|
||||
#[allow(dead_code)]
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
}
|
||||
|
||||
impl ArbiterClient {
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn connect_with_storage<S: SigningKeyStorage>(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
storage: &S,
|
||||
) -> Result<Self, Error> {
|
||||
let key = storage.load_or_create()?;
|
||||
Self::connect_with_key(url, metadata, key).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_key(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
key: ed25519_dalek::SigningKey,
|
||||
) -> Result<Self, Error> {
|
||||
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
|
||||
let tls = ClientTlsConfig::new().trust_anchor(anchor);
|
||||
|
||||
let channel =
|
||||
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
|
||||
.tls_config(tls)?
|
||||
.connect()
|
||||
.await?;
|
||||
|
||||
let mut client = ArbiterServiceClient::new(channel);
|
||||
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
|
||||
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
|
||||
|
||||
let mut transport = ClientTransport {
|
||||
sender: tx,
|
||||
receiver: response_stream,
|
||||
};
|
||||
|
||||
authenticate(&mut transport, metadata, &key).await?;
|
||||
|
||||
Ok(Self {
|
||||
transport: Arc::new(Mutex::new(transport)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, Error> {
|
||||
todo!("fetch EVM wallet list from server")
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
}
|
||||
mod auth;
|
||||
mod client;
|
||||
mod storage;
|
||||
mod transport;
|
||||
pub mod wallets;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
pub use auth::AuthError;
|
||||
pub use client::{ArbiterClient, Error};
|
||||
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "evm")]
|
||||
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};
|
||||
|
||||
132
server/crates/arbiter-client/src/storage.rs
Normal file
132
server/crates/arbiter-client/src/storage.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use arbiter_proto::home_path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("I/O error")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
|
||||
InvalidKeyLength { expected: usize, actual: usize },
|
||||
}
|
||||
|
||||
pub trait SigningKeyStorage {
|
||||
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSigningKeyStorage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSigningKeyStorage {
|
||||
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
|
||||
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
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) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let raw: [u8; 32] =
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
|
||||
expected: 32,
|
||||
actual: v.len(),
|
||||
})?;
|
||||
Ok(ed25519_dalek::SigningKey::from_bytes(&raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKeyStorage for FileSigningKeyStorage {
|
||||
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if self.path.exists() {
|
||||
return Self::read_key(&self.path);
|
||||
}
|
||||
|
||||
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||
let raw_key = key.to_bytes();
|
||||
|
||||
// Use create_new to prevent accidental overwrite if another process creates the key first.
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
use std::io::Write as _;
|
||||
file.write_all(&raw_key)?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Self::read_key(&self.path)
|
||||
}
|
||||
Err(err) => Err(StorageError::Io(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
fn unique_temp_key_path() -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"arbiter-client-key-{}-{}.bin",
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_creates_and_reuses_key() {
|
||||
let path = unique_temp_key_path();
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let key_a = storage
|
||||
.load_or_create()
|
||||
.expect("first load_or_create should create key");
|
||||
let key_b = storage
|
||||
.load_or_create()
|
||||
.expect("second load_or_create should read same key");
|
||||
|
||||
assert_eq!(key_a.to_bytes(), key_b.to_bytes());
|
||||
assert!(path.exists());
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_rejects_invalid_key_length() {
|
||||
let path = unique_temp_key_path();
|
||||
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let err = storage
|
||||
.load_or_create()
|
||||
.expect_err("storage should reject non-32-byte key file");
|
||||
|
||||
match err {
|
||||
StorageError::InvalidKeyLength { expected, actual } => {
|
||||
assert_eq!(expected, 32);
|
||||
assert_eq!(actual, 31);
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
}
|
||||
44
server/crates/arbiter-client/src/transport.rs
Normal file
44
server/crates/arbiter-client/src/transport.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub(crate) const BUFFER_LENGTH: usize = 16;
|
||||
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
|
||||
|
||||
pub(crate) fn next_request_id() -> i32 {
|
||||
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ClientSignError {
|
||||
#[error("Transport channel closed")]
|
||||
ChannelClosed,
|
||||
|
||||
#[error("Connection closed by server")]
|
||||
ConnectionClosed,
|
||||
}
|
||||
|
||||
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,
|
||||
) -> std::result::Result<(), ClientSignError> {
|
||||
self.sender
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|_| ClientSignError::ChannelClosed)
|
||||
}
|
||||
|
||||
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(ClientSignError::ConnectionClosed),
|
||||
Err(_) => Err(ClientSignError::ConnectionClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
196
server/crates/arbiter-client/src/wallets/evm.rs
Normal file
196
server/crates/arbiter-client/src/wallets/evm.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use alloy::{
|
||||
consensus::SignableTransaction,
|
||||
network::TxSigner,
|
||||
primitives::{Address, B256, ChainId, Signature},
|
||||
signers::{Error, Result, Signer},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use arbiter_proto::proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
evm::{
|
||||
self as proto_evm, request::Payload as EvmRequestPayload,
|
||||
response::Payload as EvmResponsePayload,
|
||||
},
|
||||
},
|
||||
evm::{
|
||||
EvmSignTransactionRequest,
|
||||
evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
||||
},
|
||||
shared::evm::TransactionEvalError,
|
||||
};
|
||||
|
||||
use crate::transport::{ClientTransport, next_request_id};
|
||||
|
||||
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
|
||||
///
|
||||
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
|
||||
/// interpret the concrete policy evaluation failure instead of parsing strings.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ArbiterEvmSignTransactionError {
|
||||
#[error("transaction rejected by policy: {0:?}")]
|
||||
PolicyEval(TransactionEvalError),
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
|
||||
if let Error::Other(inner) = value
|
||||
&& let Some(eval_error) = inner.downcast_ref()
|
||||
{
|
||||
Ok(eval_error)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArbiterEvmWallet {
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
address: Address,
|
||||
chain_id: Option<ChainId>,
|
||||
}
|
||||
|
||||
impl ArbiterEvmWallet {
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "constructor may be used in future extensions, e.g. to support wallet listing"
|
||||
)]
|
||||
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
address,
|
||||
chain_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
|
||||
self.chain_id = Some(chain_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
|
||||
if let Some(chain_id) = self.chain_id
|
||||
&& !tx.set_chain_id_checked(chain_id)
|
||||
{
|
||||
return Err(Error::TransactionChainIdMismatch {
|
||||
signer: chain_id,
|
||||
tx: tx.chain_id().unwrap(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Signer for ArbiterEvmWallet {
|
||||
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
|
||||
Err(Error::other(
|
||||
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
|
||||
))
|
||||
}
|
||||
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
fn chain_id(&self) -> Option<ChainId> {
|
||||
self.chain_id
|
||||
}
|
||||
|
||||
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
|
||||
self.chain_id = chain_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TxSigner<Signature> for ArbiterEvmWallet {
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
async fn sign_transaction(
|
||||
&self,
|
||||
tx: &mut dyn SignableTransaction<Signature>,
|
||||
) -> Result<Signature> {
|
||||
self.validate_chain_id(tx)?;
|
||||
|
||||
let mut transport = self.transport.lock().await;
|
||||
let request_id = next_request_id();
|
||||
let rlp_transaction = tx.encoded_for_signing();
|
||||
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id,
|
||||
payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
|
||||
payload: Some(EvmRequestPayload::SignTransaction(
|
||||
EvmSignTransactionRequest {
|
||||
wallet_address: self.address.to_vec(),
|
||||
rlp_transaction,
|
||||
},
|
||||
)),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
|
||||
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
|
||||
|
||||
if response.request_id != Some(request_id) {
|
||||
return Err(Error::other(
|
||||
"received mismatched response id for evm sign transaction",
|
||||
));
|
||||
}
|
||||
|
||||
let payload = response
|
||||
.payload
|
||||
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
|
||||
|
||||
let ClientResponsePayload::Evm(proto_evm::Response {
|
||||
payload: Some(payload),
|
||||
}) = payload
|
||||
else {
|
||||
return Err(Error::other(
|
||||
"unexpected response payload for evm sign transaction request",
|
||||
));
|
||||
};
|
||||
|
||||
let EvmResponsePayload::SignTransaction(response) = payload else {
|
||||
return Err(Error::other(
|
||||
"unexpected evm response payload for sign transaction request",
|
||||
));
|
||||
};
|
||||
|
||||
let result = response
|
||||
.result
|
||||
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
|
||||
|
||||
match result {
|
||||
EvmSignTransactionResult::Signature(signature) => {
|
||||
Signature::try_from(signature.as_slice())
|
||||
.map_err(|_| Error::other("invalid signature returned by server"))
|
||||
}
|
||||
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
|
||||
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
|
||||
)),
|
||||
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
|
||||
"server failed to sign transaction with error code {code}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
2
server/crates/arbiter-client/src/wallets/mod.rs
Normal file
2
server/crates/arbiter-client/src/wallets/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[cfg(feature = "evm")]
|
||||
pub mod evm;
|
||||
@@ -3,18 +3,34 @@ name = "arbiter-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
tonic.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
hex = "0.4.3"
|
||||
tonic-prost = "0.14.3"
|
||||
prost = "0.14.3"
|
||||
tonic-prost = "0.14.5"
|
||||
prost.workspace = true
|
||||
kameo.workspace = true
|
||||
url = "2.5.8"
|
||||
miette.workspace = true
|
||||
thiserror.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
base64 = "0.22.1"
|
||||
prost-types.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14.3"
|
||||
tonic-prost-build = "0.14.5"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
rand.workspace = true
|
||||
rcgen.workspace = true
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
ignored = ["tonic-prost", "prost", "kameo"]
|
||||
|
||||
@@ -3,16 +3,19 @@ use tonic_prost_build::configure;
|
||||
static PROTOBUF_DIR: &str = "../../../protobufs";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
|
||||
|
||||
configure()
|
||||
.message_attribute(".", "#[derive(::kameo::Reply)]")
|
||||
.compile_protos(
|
||||
&[
|
||||
format!("{}/arbiter.proto", PROTOBUF_DIR),
|
||||
format!("{}/auth.proto", PROTOBUF_DIR),
|
||||
format!("{}/user_agent.proto", PROTOBUF_DIR),
|
||||
format!("{}/client.proto", PROTOBUF_DIR),
|
||||
format!("{}/evm.proto", PROTOBUF_DIR),
|
||||
],
|
||||
&[PROTOBUF_DIR.to_string()],
|
||||
)
|
||||
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,19 +1,79 @@
|
||||
use crate::proto::auth::AuthChallenge;
|
||||
pub mod transport;
|
||||
pub mod url;
|
||||
|
||||
use base64::{Engine, prelude::BASE64_STANDARD};
|
||||
|
||||
pub mod proto {
|
||||
tonic::include_proto!("arbiter");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.auth");
|
||||
pub mod shared {
|
||||
tonic::include_proto!("arbiter.shared");
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.shared.evm");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod user_agent {
|
||||
tonic::include_proto!("arbiter.user_agent");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.user_agent.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.user_agent.evm");
|
||||
}
|
||||
|
||||
pub mod sdk_client {
|
||||
tonic::include_proto!("arbiter.user_agent.sdk_client");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.user_agent.vault");
|
||||
|
||||
pub mod bootstrap {
|
||||
tonic::include_proto!("arbiter.user_agent.vault.bootstrap");
|
||||
}
|
||||
|
||||
pub mod unseal {
|
||||
tonic::include_proto!("arbiter.user_agent.vault.unseal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod client {
|
||||
tonic::include_proto!("arbiter.client");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.client.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.client.evm");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.client.vault");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.evm");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod transport;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientMetadata {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
pub static BOOTSTRAP_TOKEN_PATH: &'static str = "bootstrap_token";
|
||||
pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
|
||||
|
||||
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
|
||||
static ARBITER_HOME: &'static str = ".arbiter";
|
||||
static ARBITER_HOME: &str = ".arbiter";
|
||||
let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"can not get home directory",
|
||||
@@ -25,7 +85,7 @@ pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
|
||||
Ok(arbiter_home)
|
||||
}
|
||||
|
||||
pub fn format_challenge(challenge: &AuthChallenge) -> Vec<u8> {
|
||||
let concat_form = format!("{}:{}", challenge.nonce, hex::encode(&challenge.pubkey));
|
||||
concat_form.into_bytes().to_vec()
|
||||
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
|
||||
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
|
||||
concat_form.into_bytes()
|
||||
}
|
||||
|
||||
@@ -1,46 +1,163 @@
|
||||
use futures::{Stream, StreamExt};
|
||||
use tokio::sync::mpsc::{self, error::SendError};
|
||||
use tonic::{Status, Streaming};
|
||||
//! Transport-facing abstractions shared by protocol/session code.
|
||||
//!
|
||||
//! This module defines a small set of transport traits that actors and other
|
||||
//! protocol code can depend on without knowing anything about the concrete
|
||||
//! transport underneath.
|
||||
//!
|
||||
//! The abstraction is split into:
|
||||
//! - [`Sender`] for outbound delivery
|
||||
//! - [`Receiver`] for inbound delivery
|
||||
//! - [`Bi`] as the combined duplex form (`Sender + Receiver`)
|
||||
//!
|
||||
//! This split lets code depend only on the half it actually needs. For
|
||||
//! example, some actor/session code only sends out-of-band messages, while
|
||||
//! auth/state-machine code may need full duplex access.
|
||||
//!
|
||||
//! [`Bi`] remains intentionally minimal and transport-agnostic:
|
||||
//! - [`Receiver::recv`] yields inbound messages
|
||||
//! - [`Sender::send`] accepts outbound messages
|
||||
//!
|
||||
//! Transport-specific adapters, including protobuf or gRPC bridges, live in the
|
||||
//! crates that own those boundaries rather than in `arbiter-proto`.
|
||||
//!
|
||||
//! [`Bi`] deliberately does not model request/response correlation. Some
|
||||
//! transports may carry multiplexed request/response traffic, some may emit
|
||||
//! out-of-band messages, and some may be one-message-at-a-time state machines.
|
||||
//! Correlation concerns such as request IDs, pending response maps, and
|
||||
//! out-of-band routing belong in the adapter or connection layer built on top
|
||||
//! of [`Bi`], not in this abstraction itself.
|
||||
//!
|
||||
//! # Generic Ordering Rule
|
||||
//!
|
||||
//! This module consistently uses `Inbound` first and `Outbound` second in
|
||||
//! generic parameter lists.
|
||||
//!
|
||||
//! For [`Receiver`], [`Sender`], and [`Bi`], this means:
|
||||
//! - `Receiver<Inbound>`
|
||||
//! - `Sender<Outbound>`
|
||||
//! - `Bi<Inbound, Outbound>`
|
||||
//!
|
||||
//! Concretely, for [`Bi`]:
|
||||
//! - `recv() -> Option<Inbound>`
|
||||
//! - `send(Outbound)`
|
||||
//!
|
||||
//! [`expect_message`] is a small helper for linear protocol steps: it reads one
|
||||
//! inbound message from a transport and extracts a typed value from it, failing
|
||||
//! if the channel closes or the message shape is not what the caller expected.
|
||||
//!
|
||||
//! [`DummyTransport`] is a no-op implementation useful for tests and local
|
||||
//! actor execution where no real stream exists.
|
||||
//!
|
||||
//! # Design Notes
|
||||
//!
|
||||
//! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such
|
||||
//! as a closed outbound channel.
|
||||
//! - [`Bi::recv`] returns `None` when the underlying transport closes.
|
||||
//! - Message translation is intentionally out of scope for this module.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
// Abstraction for stream for sans-io capabilities
|
||||
pub trait Bi<T, U>: Stream<Item = Result<T, Status>> + Send + Sync + 'static {
|
||||
type Error;
|
||||
fn send(
|
||||
&mut self,
|
||||
item: Result<U, Status>,
|
||||
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Errors returned by transport adapters implementing [`Bi`].
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Transport channel is closed")]
|
||||
ChannelClosed,
|
||||
#[error("Unexpected message received")]
|
||||
UnexpectedMessage,
|
||||
}
|
||||
|
||||
// Bi-directional stream abstraction for handling gRPC streaming requests and responses
|
||||
pub struct BiStream<T, U> {
|
||||
pub request_stream: Streaming<T>,
|
||||
pub response_sender: mpsc::Sender<Result<U, Status>>,
|
||||
}
|
||||
|
||||
impl<T, U> Stream for BiStream<T, U>
|
||||
/// Receives one message from `transport` and extracts a value from it using
|
||||
/// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and
|
||||
/// [`Error::UnexpectedMessage`] if `extractor` returns `None`.
|
||||
pub async fn expect_message<T, Inbound, Outbound, Target, F>(
|
||||
transport: &mut T,
|
||||
extractor: F,
|
||||
) -> Result<Target, Error>
|
||||
where
|
||||
T: Send + 'static,
|
||||
U: Send + 'static,
|
||||
T: Bi<Inbound, Outbound> + ?Sized,
|
||||
F: FnOnce(Inbound) -> Option<Target>,
|
||||
{
|
||||
type Item = Result<T, Status>;
|
||||
let msg = transport.recv().await.ok_or(Error::ChannelClosed)?;
|
||||
extractor(msg).ok_or(Error::UnexpectedMessage)
|
||||
}
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
self.request_stream.poll_next_unpin(cx)
|
||||
#[async_trait]
|
||||
pub trait Sender<Outbound>: Send + Sync {
|
||||
async fn send(&mut self, item: Outbound) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Receiver<Inbound>: Send + Sync {
|
||||
async fn recv(&mut self) -> Option<Inbound>;
|
||||
}
|
||||
|
||||
/// Minimal bidirectional transport abstraction used by protocol code.
|
||||
///
|
||||
/// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and
|
||||
/// [`Receiver`].
|
||||
///
|
||||
/// It models a channel with:
|
||||
/// - inbound items of type `Inbound` read via [`Bi::recv`]
|
||||
/// - outbound items of type `Outbound` written via [`Bi::send`]
|
||||
///
|
||||
/// It does not imply request/response sequencing, one-at-a-time exchange, or
|
||||
/// any built-in correlation mechanism between inbound and outbound items.
|
||||
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
|
||||
|
||||
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
|
||||
type Sender: Sender<Outbound>;
|
||||
type Receiver: Receiver<Inbound>;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver);
|
||||
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self;
|
||||
}
|
||||
|
||||
/// No-op [`Bi`] transport for tests and manual actor usage.
|
||||
///
|
||||
/// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore
|
||||
/// does not busy-wait or spuriously close the stream.
|
||||
pub struct DummyTransport<Inbound, Outbound> {
|
||||
_marker: PhantomData<(Inbound, Outbound)>,
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, U> Bi<T, U> for BiStream<T, U>
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
T: Send + 'static,
|
||||
U: Send + 'static,
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
type Error = SendError<Result<U, Status>>;
|
||||
|
||||
async fn send(&mut self, item: Result<U, Status>) -> Result<(), Self::Error> {
|
||||
self.response_sender.send(item).await
|
||||
async fn send(&mut self, _item: Outbound) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Inbound> {
|
||||
std::future::pending::<()>().await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
pub mod grpc;
|
||||
|
||||
106
server/crates/arbiter-proto/src/transport/grpc.rs
Normal file
106
server/crates/arbiter-proto/src/transport/grpc.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use super::{Bi, Receiver, Sender};
|
||||
|
||||
pub struct GrpcSender<Outbound> {
|
||||
tx: mpsc::Sender<Result<Outbound, tonic::Status>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound>
|
||||
where
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
|
||||
self.tx
|
||||
.send(item)
|
||||
.await
|
||||
.map_err(|_| super::Error::ChannelClosed)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrpcReceiver<Inbound> {
|
||||
rx: tonic::Streaming<Inbound>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
|
||||
self.rx.next().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrpcBi<Inbound, Outbound> {
|
||||
sender: GrpcSender<Outbound>,
|
||||
receiver: GrpcReceiver<Inbound>,
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
pub fn from_bi_stream(
|
||||
receiver: tonic::Streaming<Inbound>,
|
||||
) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
let sender = GrpcSender { tx };
|
||||
let receiver = GrpcReceiver { rx: receiver };
|
||||
let bi = GrpcBi { sender, receiver };
|
||||
(bi, ReceiverStream::new(rx))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
|
||||
self.sender.send(item).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
|
||||
for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound>
|
||||
super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
|
||||
for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
type Sender = GrpcSender<Outbound>;
|
||||
type Receiver = GrpcReceiver<Inbound>;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||
(self.sender, self.receiver)
|
||||
}
|
||||
|
||||
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self {
|
||||
GrpcBi { sender, receiver }
|
||||
}
|
||||
}
|
||||
129
server/crates/arbiter-proto/src/url.rs
Normal file
129
server/crates/arbiter-proto/src/url.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use base64::{Engine as _, prelude::BASE64_URL_SAFE};
|
||||
use rustls_pki_types::CertificateDer;
|
||||
|
||||
const ARBITER_URL_SCHEME: &str = "arbiter";
|
||||
const CERT_QUERY_KEY: &str = "cert";
|
||||
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArbiterUrl {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub ca_cert: CertificateDer<'static>,
|
||||
pub bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Display for ArbiterUrl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut base = format!(
|
||||
"{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}",
|
||||
self.host,
|
||||
self.port,
|
||||
BASE64_URL_SAFE.encode(&self.ca_cert)
|
||||
);
|
||||
if let Some(token) = &self.bootstrap_token {
|
||||
base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token));
|
||||
}
|
||||
f.write_str(&base)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||
pub enum Error {
|
||||
#[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::invalid_scheme),
|
||||
help("The URL must start with '{ARBITER_URL_SCHEME}://'")
|
||||
)]
|
||||
InvalidScheme,
|
||||
#[error("Missing host in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_host),
|
||||
help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'")
|
||||
)]
|
||||
MissingHost,
|
||||
#[error("Missing port in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_port),
|
||||
help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'")
|
||||
)]
|
||||
MissingPort,
|
||||
#[error("Missing 'cert' query parameter in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_cert),
|
||||
help("The URL must include a 'cert' query parameter")
|
||||
)]
|
||||
MissingCert,
|
||||
#[error("Invalid base64 in 'cert' query parameter: {0}")]
|
||||
#[diagnostic(code(arbiter::url::invalid_cert_base64))]
|
||||
InvalidCertBase64(#[from] base64::DecodeError),
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a str> for ArbiterUrl {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
|
||||
let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?;
|
||||
|
||||
if url.scheme() != ARBITER_URL_SCHEME {
|
||||
return Err(Error::InvalidScheme);
|
||||
}
|
||||
|
||||
let host = url.host_str().ok_or(Error::MissingHost)?.to_string();
|
||||
let port = url.port().ok_or(Error::MissingPort)?;
|
||||
let cert_str = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == CERT_QUERY_KEY)
|
||||
.ok_or(Error::MissingCert)?
|
||||
.1;
|
||||
|
||||
let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?;
|
||||
let cert = CertificateDer::from_slice(&cert).into_owned();
|
||||
|
||||
let bootstrap_token = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY)
|
||||
.map(|(_, v)| v.to_string());
|
||||
|
||||
Ok(ArbiterUrl {
|
||||
host,
|
||||
port,
|
||||
ca_cert: cert,
|
||||
bootstrap_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
|
||||
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>,
|
||||
) {
|
||||
let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap();
|
||||
let cert = cert.cert.der();
|
||||
|
||||
let url = ArbiterUrl {
|
||||
host: host.to_string(),
|
||||
port: 1234,
|
||||
ca_cert: cert.clone().into_owned(),
|
||||
bootstrap_token,
|
||||
};
|
||||
let url_str = url.to_string();
|
||||
let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap();
|
||||
assert_eq!(url.host, parsed_url.host);
|
||||
assert_eq!(url.port, parsed_url.port);
|
||||
assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec());
|
||||
assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token);
|
||||
}
|
||||
}
|
||||
BIN
server/crates/arbiter-server/.DS_Store
vendored
Normal file
BIN
server/crates/arbiter-server/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -3,30 +3,31 @@ name = "arbiter-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
diesel = { version = "2.3.6", features = [
|
||||
"sqlite",
|
||||
"uuid",
|
||||
"time",
|
||||
"chrono",
|
||||
"serde_json",
|
||||
] }
|
||||
diesel-async = { version = "0.7.4", 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",
|
||||
"tokio",
|
||||
] }
|
||||
ed25519-dalek.workspace = true
|
||||
ed25519-dalek.features = ["serde"]
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
rustls.workspace = true
|
||||
smlang.workspace = true
|
||||
miette.workspace = true
|
||||
thiserror.workspace = true
|
||||
fatality = "0.1.1"
|
||||
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
|
||||
async-trait.workspace = true
|
||||
secrecy = "0.10.3"
|
||||
@@ -34,16 +35,35 @@ futures.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
dashmap = "6.1.0"
|
||||
rand.workspace = true
|
||||
rcgen = { version = "0.14.7", features = [
|
||||
"aws_lc_rs",
|
||||
"pem",
|
||||
"x509-parser",
|
||||
"zeroize",
|
||||
], default-features = false }
|
||||
rcgen.workspace = true
|
||||
chrono.workspace = true
|
||||
memsafe = "0.4.0"
|
||||
zeroize = { version = "1.8.2", features = ["std", "simd"] }
|
||||
kameo.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["zeroize"] }
|
||||
restructed = "0.2.2"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
pem = "3.0.6"
|
||||
k256.workspace = true
|
||||
k256.features = ["serde"]
|
||||
rsa.workspace = true
|
||||
rsa.features = ["serde"]
|
||||
sha2.workspace = true
|
||||
hmac = "0.12"
|
||||
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"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.46.3"
|
||||
proptest = "1.11.0"
|
||||
rstest.workspace = true
|
||||
test-log = { version = "0.2", default-features = false, features = ["trace"] }
|
||||
|
||||
@@ -1,31 +1,209 @@
|
||||
create table if not exists aead_encrypted (
|
||||
create table if not exists root_key_history (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
current_nonce integer not null default(1), -- if re-encrypted, this should be incremented
|
||||
-- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password)
|
||||
root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key
|
||||
data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself
|
||||
ciphertext blob not null,
|
||||
tag blob not null,
|
||||
schema_version integer not null default(1) -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
salt blob not null -- for key deriviation
|
||||
) STRICT;
|
||||
|
||||
create table if not exists aead_encrypted (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
current_nonce blob not null default(1), -- if re-encrypted, this should be incremented
|
||||
ciphertext blob not null,
|
||||
tag blob not null,
|
||||
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_nonce_per_root_key on aead_encrypted (
|
||||
current_nonce,
|
||||
associated_root_key_id
|
||||
);
|
||||
|
||||
create table if not exists tls_history (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
cert text not null,
|
||||
cert_key text not null, -- PEM Encoded private key
|
||||
ca_cert text not null,
|
||||
ca_key text not null, -- PEM Encoded private key
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- This is a singleton
|
||||
create table if not exists arbiter_settings (
|
||||
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
|
||||
root_key_id integer references aead_encrypted (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
|
||||
cert_key blob not null,
|
||||
cert blob not null
|
||||
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
|
||||
tls_id integer references tls_history (id) on delete RESTRICT
|
||||
) STRICT;
|
||||
|
||||
insert into arbiter_settings (id) values (1) on conflict do nothing;
|
||||
-- ensure singleton row exists
|
||||
|
||||
create table if not exists useragent_client (
|
||||
id integer not null primary key,
|
||||
nonce integer not null default (1), -- used for auth challenge
|
||||
nonce integer not null default(1), -- used for auth challenge
|
||||
public_key blob not null,
|
||||
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key, key_type);
|
||||
|
||||
create table if not exists client_metadata (
|
||||
id integer not null primary key,
|
||||
name text not null, -- human-readable name for the client
|
||||
description text, -- optional description for the client
|
||||
version text, -- client version for tracking and debugging
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- created to track history of changes
|
||||
create table if not exists client_metadata_history (
|
||||
id integer not null primary key,
|
||||
metadata_id integer not null references client_metadata (id) on delete cascade,
|
||||
client_id integer not null references program_client (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id);
|
||||
|
||||
create table if not exists program_client (
|
||||
id integer not null primary key,
|
||||
nonce integer not null default(1), -- used for auth challenge
|
||||
public_key blob not null,
|
||||
metadata_id integer not null references client_metadata (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create table if not exists program_client (
|
||||
create unique index if not exists program_client_public_key_unique
|
||||
on program_client (public_key);
|
||||
|
||||
create unique index if not exists uniq_program_client_public_key on program_client (public_key);
|
||||
|
||||
create table if not exists evm_wallet (
|
||||
id integer not null primary key,
|
||||
nonce integer not null default (1), -- used for auth challenge
|
||||
public_key blob not null,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
address blob not null, -- 20-byte Ethereum address
|
||||
aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
|
||||
|
||||
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
|
||||
|
||||
create table if not exists evm_wallet_access (
|
||||
id integer not null primary key,
|
||||
wallet_id integer not null references evm_wallet (id) on delete cascade,
|
||||
client_id integer not null references program_client (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id);
|
||||
|
||||
create table if not exists evm_ether_transfer_limit (
|
||||
id integer not null primary key,
|
||||
window_secs integer not null, -- window duration in seconds
|
||||
max_volume blob not null -- big-endian 32-byte U256
|
||||
) STRICT;
|
||||
|
||||
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit
|
||||
create table if not exists evm_basic_grant (
|
||||
id integer not null primary key,
|
||||
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
|
||||
chain_id integer not null, -- EIP-155 chain ID
|
||||
valid_from integer, -- unix timestamp (seconds), null = no lower bound
|
||||
valid_until integer, -- unix timestamp (seconds), null = no upper bound
|
||||
max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
|
||||
max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
|
||||
rate_limit_count integer, -- max transactions in window, null = unlimited
|
||||
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
|
||||
revoked_at integer, -- unix timestamp when revoked, null = still active
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- Shared transaction log for all EVM grants, used for rate limit tracking and auditing
|
||||
create table if not exists evm_transaction_log (
|
||||
id integer not null primary key,
|
||||
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
|
||||
grant_id integer not null references evm_basic_grant (id) on delete restrict,
|
||||
chain_id integer not null,
|
||||
eth_value blob not null, -- always present on any EVM tx
|
||||
signed_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id);
|
||||
|
||||
-- ===============================
|
||||
-- ERC20 token transfer grant
|
||||
-- ===============================
|
||||
create table if not exists evm_token_transfer_grant (
|
||||
id integer not null primary key,
|
||||
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
|
||||
token_contract blob not null, -- 20-byte ERC20 contract address
|
||||
receiver blob -- 20-byte recipient address or null if every recipient allowed
|
||||
) STRICT;
|
||||
|
||||
-- Per-window volume limits for token transfer grants
|
||||
create table if not exists evm_token_transfer_volume_limit (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_token_transfer_grant (id) on delete cascade,
|
||||
window_secs integer not null, -- window duration in seconds
|
||||
max_volume blob not null -- big-endian 32-byte U256
|
||||
) STRICT;
|
||||
|
||||
-- Log table for token transfer grant usage
|
||||
create table if not exists evm_token_transfer_log (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_token_transfer_grant (id) on delete restrict,
|
||||
log_id integer not null references evm_transaction_log (id) on delete restrict,
|
||||
chain_id integer not null, -- EIP-155 chain ID
|
||||
token_contract blob not null, -- 20-byte ERC20 contract address
|
||||
recipient_address blob not null, -- 20-byte recipient address
|
||||
value blob not null, -- big-endian 32-byte U256
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id);
|
||||
|
||||
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id);
|
||||
|
||||
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id);
|
||||
|
||||
-- ===============================
|
||||
-- Ether transfer grant (uses base log)
|
||||
-- ===============================
|
||||
create table if not exists evm_ether_transfer_grant (
|
||||
id integer not null primary key,
|
||||
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
|
||||
limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict
|
||||
) STRICT;
|
||||
|
||||
-- Specific recipient addresses for an ether transfer grant
|
||||
create table if not exists evm_ether_transfer_grant_target (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade,
|
||||
address blob not null -- 20-byte recipient address
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
|
||||
|
||||
-- ===============================
|
||||
-- Integrity Envelopes
|
||||
-- ===============================
|
||||
create table if not exists integrity_envelope (
|
||||
id integer not null primary key,
|
||||
entity_kind text not null,
|
||||
entity_id blob not null,
|
||||
payload_version integer not null,
|
||||
key_version integer not null,
|
||||
mac blob not null, -- 20-byte recipient address
|
||||
signed_at integer not null default(unixepoch ('now')),
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod user_agent;
|
||||
pub mod client;
|
||||
101
server/crates/arbiter-server/src/actors/bootstrap.rs
Normal file
101
server/crates/arbiter-server/src/actors/bootstrap.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
|
||||
use diesel::QueryDsl;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, messages};
|
||||
|
||||
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::db::{self, DatabasePool, schema};
|
||||
const TOKEN_LENGTH: usize = 64;
|
||||
|
||||
pub async fn generate_token() -> Result<String, std::io::Error> {
|
||||
let rng: StdRng = make_rng();
|
||||
|
||||
let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
|
||||
Default::default(),
|
||||
|mut accum, char| {
|
||||
accum += char.to_string().as_str();
|
||||
accum
|
||||
},
|
||||
);
|
||||
|
||||
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] db::PoolError),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct Bootstrapper {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl Bootstrapper {
|
||||
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
|
||||
let row_count: i64 = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
schema::useragent_client::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let token = if row_count == 0 {
|
||||
let token = generate_token().await?;
|
||||
Some(token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self { token })
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn is_correct_token(&self, token: String) -> bool {
|
||||
match &self.token {
|
||||
Some(expected) => {
|
||||
let expected_bytes = expected.as_bytes();
|
||||
let token_bytes = token.as_bytes();
|
||||
|
||||
let choice = expected_bytes.ct_eq(token_bytes);
|
||||
bool::from(choice)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn consume_token(&mut self, token: String) -> bool {
|
||||
if self.is_correct_token(token) {
|
||||
self.token = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn get_token(&self) -> Option<String> {
|
||||
self.token.clone()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use arbiter_proto::{
|
||||
proto::{ClientRequest, ClientResponse},
|
||||
transport::Bi,
|
||||
};
|
||||
|
||||
use crate::ServerContext;
|
||||
|
||||
pub(crate) async fn handle_client(
|
||||
_context: ServerContext,
|
||||
_bistream: impl Bi<ClientRequest, ClientResponse>,
|
||||
) {
|
||||
}
|
||||
410
server/crates/arbiter-server/src/actors/client/auth.rs
Normal file
410
server/crates/arbiter-server/src/actors/client/auth.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, format_challenge,
|
||||
transport::{Bi, expect_message},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use diesel::{
|
||||
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
|
||||
dsl::insert_into, update,
|
||||
};
|
||||
use diesel_async::RunQueryDsl as _;
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use kameo::{actor::ActorRef, error::SendError};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
client::{ClientConnection, ClientCredentials, ClientProfile},
|
||||
flow_coordinator::{self, RequestClientApproval},
|
||||
keyholder::KeyHolder,
|
||||
},
|
||||
crypto::integrity::{self},
|
||||
db::{
|
||||
self,
|
||||
models::{ProgramClientMetadata, SqliteTimestamp},
|
||||
schema::program_client,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Database pool unavailable")]
|
||||
DatabasePoolUnavailable,
|
||||
#[error("Database operation failed")]
|
||||
DatabaseOperationFailed,
|
||||
#[error("Integrity check failed")]
|
||||
IntegrityCheckFailed,
|
||||
#[error("Invalid challenge solution")]
|
||||
InvalidChallengeSolution,
|
||||
#[error("Client approval request failed")]
|
||||
ApproveError(#[from] ApproveError),
|
||||
#[error("Transport error")]
|
||||
Transport,
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for Error {
|
||||
fn from(e: diesel::result::Error) -> Self {
|
||||
error!(?e, "Database error");
|
||||
Self::DatabaseOperationFailed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ApproveError {
|
||||
#[error("Internal error")]
|
||||
Internal,
|
||||
#[error("Client connection denied by user agents")]
|
||||
Denied,
|
||||
#[error("Upstream error: {0}")]
|
||||
Upstream(flow_coordinator::ApprovalError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Inbound {
|
||||
AuthChallengeRequest {
|
||||
pubkey: VerifyingKey,
|
||||
metadata: ClientMetadata,
|
||||
},
|
||||
AuthChallengeSolution {
|
||||
signature: Signature,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outbound {
|
||||
AuthChallenge { pubkey: VerifyingKey, nonce: i32 },
|
||||
AuthSuccess,
|
||||
}
|
||||
|
||||
/// Returns the current nonce and client ID for a registered client.
|
||||
/// Returns `None` if the pubkey is not registered.
|
||||
async fn get_current_nonce_and_id(
|
||||
db: &db::DatabasePool,
|
||||
pubkey: &VerifyingKey,
|
||||
) -> Result<Option<(i32, i32)>, Error> {
|
||||
let pubkey_bytes = pubkey.as_bytes().to_vec();
|
||||
let mut conn = db.get().await.map_err(|e| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
program_client::table
|
||||
.filter(program_client::public_key.eq(&pubkey_bytes))
|
||||
.select((program_client::id, program_client::nonce))
|
||||
.first::<(i32, i32)>(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Database error");
|
||||
Error::DatabaseOperationFailed
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_integrity(
|
||||
db: &db::DatabasePool,
|
||||
keyholder: &ActorRef<KeyHolder>,
|
||||
pubkey: &VerifyingKey,
|
||||
) -> Result<(), Error> {
|
||||
let mut db_conn = db.get().await.map_err(|e| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
|
||||
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?.ok_or_else(|| {
|
||||
error!("Client not found during integrity verification");
|
||||
Error::DatabaseOperationFailed
|
||||
})?;
|
||||
|
||||
integrity::verify_entity(
|
||||
&mut db_conn,
|
||||
keyholder,
|
||||
&ClientCredentials {
|
||||
pubkey: *pubkey,
|
||||
nonce,
|
||||
},
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Integrity verification failed");
|
||||
Error::IntegrityCheckFailed
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically increments the nonce and re-signs the integrity envelope.
|
||||
/// Returns the new nonce, which is used as the challenge nonce.
|
||||
async fn create_nonce(
|
||||
db: &db::DatabasePool,
|
||||
keyholder: &ActorRef<KeyHolder>,
|
||||
pubkey: &VerifyingKey,
|
||||
) -> Result<i32, Error> {
|
||||
let pubkey_bytes = pubkey.as_bytes().to_vec();
|
||||
|
||||
let mut conn = db.get().await.map_err(|e| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
|
||||
conn.exclusive_transaction(|conn| {
|
||||
let keyholder = keyholder.clone();
|
||||
Box::pin(async move {
|
||||
let (id, new_nonce): (i32, i32) = update(program_client::table)
|
||||
.filter(program_client::public_key.eq(&pubkey_bytes))
|
||||
.set(program_client::nonce.eq(program_client::nonce + 1))
|
||||
.returning((program_client::id, program_client::nonce))
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
|
||||
integrity::sign_entity(
|
||||
conn,
|
||||
&keyholder,
|
||||
&ClientCredentials {
|
||||
pubkey: *pubkey,
|
||||
nonce: new_nonce,
|
||||
},
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Integrity sign failed after nonce update");
|
||||
Error::DatabaseOperationFailed
|
||||
})?;
|
||||
|
||||
Ok(new_nonce)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn approve_new_client(
|
||||
actors: &crate::actors::GlobalActors,
|
||||
profile: ClientProfile,
|
||||
) -> Result<(), Error> {
|
||||
let result = actors
|
||||
.flow_coordinator
|
||||
.ask(RequestClientApproval { client: profile })
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(Error::ApproveError(ApproveError::Denied)),
|
||||
Err(SendError::HandlerError(e)) => {
|
||||
error!(error = ?e, "Approval upstream error");
|
||||
Err(Error::ApproveError(ApproveError::Upstream(e)))
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Approval request to flow coordinator failed");
|
||||
Err(Error::ApproveError(ApproveError::Internal))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_client(
|
||||
db: &db::DatabasePool,
|
||||
keyholder: &ActorRef<KeyHolder>,
|
||||
pubkey: &VerifyingKey,
|
||||
metadata: &ClientMetadata,
|
||||
) -> Result<i32, Error> {
|
||||
use crate::db::schema::{client_metadata, program_client};
|
||||
let metadata = metadata.clone();
|
||||
|
||||
let mut conn = db.get().await.map_err(|e| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
|
||||
conn.exclusive_transaction(|conn| {
|
||||
let keyholder = keyholder.clone();
|
||||
Box::pin(async move {
|
||||
const NONCE_START: i32 = 1;
|
||||
|
||||
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.as_bytes().to_vec()),
|
||||
program_client::metadata_id.eq(metadata_id),
|
||||
program_client::nonce.eq(NONCE_START),
|
||||
))
|
||||
.on_conflict_do_nothing()
|
||||
.returning(program_client::id)
|
||||
.get_result::<i32>(conn)
|
||||
.await?;
|
||||
|
||||
integrity::sign_entity(
|
||||
conn,
|
||||
&keyholder,
|
||||
&ClientCredentials {
|
||||
pubkey: *pubkey,
|
||||
nonce: NONCE_START,
|
||||
},
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Failed to sign integrity tag for new client key");
|
||||
Error::DatabaseOperationFailed
|
||||
})?;
|
||||
|
||||
Ok(client_id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn sync_client_metadata(
|
||||
db: &db::DatabasePool,
|
||||
client_id: i32,
|
||||
metadata: &ClientMetadata,
|
||||
) -> Result<(), Error> {
|
||||
use crate::db::schema::{client_metadata, client_metadata_history};
|
||||
|
||||
let now = SqliteTimestamp(Utc::now());
|
||||
|
||||
let mut conn = db.get().await.map_err(|e| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
|
||||
conn.exclusive_transaction(|conn| {
|
||||
let metadata = metadata.clone();
|
||||
Box::pin(async move {
|
||||
let (current_metadata_id, current): (i32, ProgramClientMetadata) =
|
||||
program_client::table
|
||||
.find(client_id)
|
||||
.inner_join(client_metadata::table)
|
||||
.select((
|
||||
program_client::metadata_id,
|
||||
ProgramClientMetadata::as_select(),
|
||||
))
|
||||
.first(conn)
|
||||
.await?;
|
||||
|
||||
let unchanged = current.name == metadata.name
|
||||
&& current.description == metadata.description
|
||||
&& current.version == metadata.version;
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
insert_into(client_metadata_history::table)
|
||||
.values((
|
||||
client_metadata_history::metadata_id.eq(current_metadata_id),
|
||||
client_metadata_history::client_id.eq(client_id),
|
||||
))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
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(conn)
|
||||
.await?;
|
||||
|
||||
Ok::<(), diesel::result::Error>(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Database error");
|
||||
Error::DatabaseOperationFailed
|
||||
})
|
||||
}
|
||||
|
||||
async fn challenge_client<T>(
|
||||
transport: &mut T,
|
||||
pubkey: VerifyingKey,
|
||||
nonce: i32,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
|
||||
{
|
||||
transport
|
||||
.send(Ok(Outbound::AuthChallenge { pubkey, nonce }))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Failed to send auth challenge");
|
||||
Error::Transport
|
||||
})?;
|
||||
|
||||
let signature = expect_message(transport, |req: Inbound| match req {
|
||||
Inbound::AuthChallengeSolution { signature } => Some(signature),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Failed to receive challenge solution");
|
||||
Error::Transport
|
||||
})?;
|
||||
|
||||
let formatted = format_challenge(nonce, pubkey.as_bytes());
|
||||
|
||||
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
|
||||
error!("Challenge solution verification failed");
|
||||
Error::InvalidChallengeSolution
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error>
|
||||
where
|
||||
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
|
||||
{
|
||||
let Some(Inbound::AuthChallengeRequest { pubkey, metadata }) = transport.recv().await else {
|
||||
return Err(Error::Transport);
|
||||
};
|
||||
|
||||
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
|
||||
Some((id, _)) => {
|
||||
verify_integrity(&props.db, &props.actors.key_holder, &pubkey).await?;
|
||||
id
|
||||
}
|
||||
None => {
|
||||
approve_new_client(
|
||||
&props.actors,
|
||||
ClientProfile {
|
||||
pubkey,
|
||||
metadata: metadata.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
|
||||
}
|
||||
};
|
||||
|
||||
sync_client_metadata(&props.db, client_id, &metadata).await?;
|
||||
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
|
||||
challenge_client(transport, pubkey, challenge_nonce).await?;
|
||||
|
||||
transport
|
||||
.send(Ok(Outbound::AuthSuccess))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Failed to send auth success");
|
||||
Error::Transport
|
||||
})?;
|
||||
|
||||
Ok(client_id)
|
||||
}
|
||||
61
server/crates/arbiter-server/src/actors/client/mod.rs
Normal file
61
server/crates/arbiter-server/src/actors/client/mod.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use arbiter_proto::{ClientMetadata, transport::Bi};
|
||||
use kameo::actor::Spawn;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
actors::{GlobalActors, client::session::ClientSession},
|
||||
crypto::integrity::{Integrable, hashing::Hashable},
|
||||
db,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientProfile {
|
||||
pub pubkey: ed25519_dalek::VerifyingKey,
|
||||
pub metadata: ClientMetadata,
|
||||
}
|
||||
|
||||
pub struct ClientCredentials {
|
||||
pub pubkey: ed25519_dalek::VerifyingKey,
|
||||
pub nonce: i32,
|
||||
}
|
||||
|
||||
impl Integrable for ClientCredentials {
|
||||
const KIND: &'static str = "client_credentials";
|
||||
}
|
||||
|
||||
impl Hashable for ClientCredentials {
|
||||
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.pubkey.as_bytes());
|
||||
self.nonce.hash(hasher);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClientConnection {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) actors: GlobalActors,
|
||||
}
|
||||
|
||||
impl ClientConnection {
|
||||
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||
Self { db, actors }
|
||||
}
|
||||
}
|
||||
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
|
||||
pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
|
||||
where
|
||||
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
|
||||
{
|
||||
match auth::authenticate(&mut props, transport).await {
|
||||
Ok(client_id) => {
|
||||
ClientSession::spawn(ClientSession::new(props, client_id));
|
||||
info!("Client authenticated, session started");
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = transport.send(Err(err.clone())).await;
|
||||
error!(?err, "Authentication failed, closing connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
119
server/crates/arbiter-server/src/actors/client/session.rs
Normal file
119
server/crates/arbiter-server/src/actors/client/session.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use kameo::{Actor, messages};
|
||||
use tracing::error;
|
||||
|
||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
client::ClientConnection,
|
||||
evm::{ClientSignTransaction, SignTransactionError},
|
||||
flow_coordinator::RegisterClient,
|
||||
keyholder::KeyHolderState,
|
||||
},
|
||||
db,
|
||||
evm::VetError,
|
||||
};
|
||||
|
||||
pub struct ClientSession {
|
||||
props: ClientConnection,
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
impl ClientSession {
|
||||
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
|
||||
Self { props, client_id }
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ClientSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
|
||||
use crate::actors::keyholder::GetState;
|
||||
|
||||
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
error!(?err, actor = "client", "keyholder.query.failed");
|
||||
return Err(Error::Internal);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(vault_state)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_sign_transaction(
|
||||
&mut self,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionRpcError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(ClientSignTransaction {
|
||||
client_id: self.client_id,
|
||||
wallet_address,
|
||||
transaction,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(signature) => Ok(signature),
|
||||
Err(kameo::error::SendError::HandlerError(SignTransactionError::Vet(vet_error))) => {
|
||||
Err(SignTransactionRpcError::Vet(vet_error))
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to sign EVM transaction in client session");
|
||||
Err(SignTransactionRpcError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for ClientSession {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Error;
|
||||
|
||||
async fn on_start(
|
||||
args: Self::Args,
|
||||
this: kameo::prelude::ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
args.props
|
||||
.actors
|
||||
.flow_coordinator
|
||||
.ask(RegisterClient { actor: this })
|
||||
.await
|
||||
.map_err(|_| Error::ConnectionRegistrationFailed)?;
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientSession {
|
||||
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||
let props = ClientConnection::new(db, actors);
|
||||
Self {
|
||||
props,
|
||||
client_id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Connection registration failed")]
|
||||
ConnectionRegistrationFailed,
|
||||
#[error("Internal error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignTransactionRpcError {
|
||||
#[error("Policy evaluation failed")]
|
||||
Vet(#[from] VetError),
|
||||
|
||||
#[error("Internal error")]
|
||||
Internal,
|
||||
}
|
||||
273
server/crates/arbiter-server/src/actors/evm/mod.rs
Normal file
273
server/crates/arbiter-server/src/actors/evm/mod.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
||||
use diesel::{
|
||||
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
|
||||
};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use rand::{SeedableRng, rng, rngs::StdRng};
|
||||
|
||||
use crate::{
|
||||
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
||||
crypto::integrity,
|
||||
db::{
|
||||
DatabaseError, DatabasePool,
|
||||
models::{self},
|
||||
schema,
|
||||
},
|
||||
evm::{
|
||||
self, ListError, RunKind,
|
||||
policies::{
|
||||
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||
},
|
||||
},
|
||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
|
||||
pub use crate::evm::safe_signer;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignTransactionError {
|
||||
#[error("Wallet not found")]
|
||||
WalletNotFound,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Keyholder error: {0}")]
|
||||
Keyholder(#[from] crate::actors::keyholder::Error),
|
||||
|
||||
#[error("Keyholder mailbox error")]
|
||||
KeyholderSend,
|
||||
|
||||
#[error("Signing error: {0}")]
|
||||
Signing(#[from] alloy::signers::Error),
|
||||
|
||||
#[error("Policy error: {0}")]
|
||||
Vet(#[from] evm::VetError),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Keyholder error: {0}")]
|
||||
Keyholder(#[from] crate::actors::keyholder::Error),
|
||||
|
||||
#[error("Keyholder mailbox error")]
|
||||
KeyholderSend,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Integrity violation: {0}")]
|
||||
Integrity(#[from] integrity::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct EvmActor {
|
||||
pub keyholder: ActorRef<KeyHolder>,
|
||||
pub db: DatabasePool,
|
||||
pub rng: StdRng,
|
||||
pub engine: evm::Engine,
|
||||
}
|
||||
|
||||
impl EvmActor {
|
||||
pub fn new(keyholder: ActorRef<KeyHolder>, db: DatabasePool) -> Self {
|
||||
// is it safe to seed rng from system once?
|
||||
// todo: audit
|
||||
let rng = StdRng::from_rng(&mut rng());
|
||||
let engine = evm::Engine::new(db.clone(), keyholder.clone());
|
||||
Self {
|
||||
keyholder,
|
||||
db,
|
||||
rng,
|
||||
engine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
|
||||
let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
|
||||
|
||||
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
|
||||
|
||||
let aead_id: i32 = self
|
||||
.keyholder
|
||||
.ask(CreateNew { plaintext })
|
||||
.await
|
||||
.map_err(|_| Error::KeyholderSend)?;
|
||||
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet_id = insert_into(schema::evm_wallet::table)
|
||||
.values(&models::NewEvmWallet {
|
||||
address: address.as_slice().to_vec(),
|
||||
aead_encrypted_id: aead_id,
|
||||
})
|
||||
.returning(schema::evm_wallet::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok((wallet_id, address))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|w| (w.id, Address::from_slice(&w.address)))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn useragent_create_grant(
|
||||
&mut self,
|
||||
basic: SharedGrantSettings,
|
||||
grant: SpecificGrant,
|
||||
) -> Result<integrity::Verified<i32>, Error> {
|
||||
match grant {
|
||||
SpecificGrant::EtherTransfer(settings) => self
|
||||
.engine
|
||||
.create_grant::<EtherTransfer>(CombinedSettings {
|
||||
shared: basic,
|
||||
specific: settings,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
SpecificGrant::TokenTransfer(settings) => self
|
||||
.engine
|
||||
.create_grant::<TokenTransfer>(CombinedSettings {
|
||||
shared: basic,
|
||||
specific: settings,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
|
||||
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
// let keyholder = self.keyholder.clone();
|
||||
|
||||
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
|
||||
// Box::pin(async move {
|
||||
// diesel::update(schema::evm_basic_grant::table)
|
||||
// .filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
||||
// .execute(conn)
|
||||
// .await?;
|
||||
|
||||
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
|
||||
|
||||
// diesel::result::QueryResult::Ok(())
|
||||
// })
|
||||
// })
|
||||
// .await
|
||||
// .map_err(DatabaseError::from)?;
|
||||
|
||||
// Ok(())
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[message]
|
||||
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)),
|
||||
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn shared_analyze_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<SpecificMeaning, SignTransactionError> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
let meaning = self
|
||||
.engine
|
||||
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
|
||||
.await?;
|
||||
|
||||
Ok(meaning)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn client_sign_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
mut transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionError> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
let raw_key: SafeCell<Vec<u8>> = self
|
||||
.keyholder
|
||||
.ask(Decrypt {
|
||||
aead_id: wallet.aead_encrypted_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| SignTransactionError::KeyholderSend)?;
|
||||
|
||||
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
|
||||
|
||||
self.engine
|
||||
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
|
||||
.await?;
|
||||
|
||||
use alloy::network::TxSignerSync as _;
|
||||
Ok(signer.sign_transaction_sync(&mut transaction)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
use kameo::{
|
||||
Actor, messages,
|
||||
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
|
||||
reply::ReplySender,
|
||||
};
|
||||
|
||||
use crate::actors::{
|
||||
client::ClientProfile,
|
||||
flow_coordinator::ApprovalError,
|
||||
user_agent::{UserAgentSession, session::BeginNewClientApproval},
|
||||
};
|
||||
|
||||
pub struct Args {
|
||||
pub client: ClientProfile,
|
||||
pub user_agents: Vec<ActorRef<UserAgentSession>>,
|
||||
pub reply: ReplySender<Result<bool, ApprovalError>>,
|
||||
}
|
||||
|
||||
pub struct ClientApprovalController {
|
||||
/// Number of UAs that have not yet responded (approval or denial) or died.
|
||||
pending: usize,
|
||||
/// Number of approvals received so far.
|
||||
approved: usize,
|
||||
reply: Option<ReplySender<Result<bool, ApprovalError>>>,
|
||||
}
|
||||
|
||||
impl ClientApprovalController {
|
||||
fn send_reply(&mut self, result: Result<bool, ApprovalError>) {
|
||||
if let Some(reply) = self.reply.take() {
|
||||
reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for ClientApprovalController {
|
||||
type Args = Args;
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(
|
||||
Args {
|
||||
client,
|
||||
mut user_agents,
|
||||
reply,
|
||||
}: Self::Args,
|
||||
actor_ref: ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let this = Self {
|
||||
pending: user_agents.len(),
|
||||
approved: 0,
|
||||
reply: Some(reply),
|
||||
};
|
||||
|
||||
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(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
_: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
// 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 UA didn't approve: deny.
|
||||
self.send_reply(Ok(false));
|
||||
return Ok(ControlFlow::Break(ActorStopReason::Normal));
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ClientApprovalController {
|
||||
#[message(ctx)]
|
||||
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));
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
self.approved += 1;
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
|
||||
if self.pending == 0 {
|
||||
// Every connected UA approved.
|
||||
self.send_reply(Ok(true));
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
118
server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs
Normal file
118
server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::{collections::HashMap, ops::ControlFlow};
|
||||
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::{ActorId, ActorRef, Spawn},
|
||||
messages,
|
||||
prelude::{ActorStopReason, Context, WeakActorRef},
|
||||
reply::DelegatedReply,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::actors::{
|
||||
client::{ClientProfile, session::ClientSession},
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
user_agent::session::UserAgentSession,
|
||||
};
|
||||
|
||||
pub mod client_connect_approval;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FlowCoordinator {
|
||||
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
|
||||
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
|
||||
}
|
||||
|
||||
impl Actor for FlowCoordinator {
|
||||
type Args = Self;
|
||||
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
id: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
if self.user_agents.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "useragent.disconnected"
|
||||
);
|
||||
} else if self.clients.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "client.disconnected"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "unknown.actor.disconnected"
|
||||
);
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ApprovalError {
|
||||
#[error("No user agents connected")]
|
||||
NoUserAgentsConnected,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl FlowCoordinator {
|
||||
#[message(ctx)]
|
||||
pub async fn register_user_agent(
|
||||
&mut self,
|
||||
actor: ActorRef<UserAgentSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "FlowCoordinator", event = "useragent.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.user_agents.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message(ctx)]
|
||||
pub async fn register_client(
|
||||
&mut self,
|
||||
actor: ActorRef<ClientSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.clients.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message(ctx)]
|
||||
pub async fn request_client_approval(
|
||||
&mut self,
|
||||
client: ClientProfile,
|
||||
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
|
||||
) -> DelegatedReply<Result<bool, ApprovalError>> {
|
||||
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
|
||||
unreachable!("Expected `request_client_approval` to have callback channel");
|
||||
};
|
||||
|
||||
let refs: Vec<_> = self.user_agents.values().cloned().collect();
|
||||
if refs.is_empty() {
|
||||
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
|
||||
return reply;
|
||||
}
|
||||
|
||||
ClientApprovalController::spawn(client_connect_approval::Args {
|
||||
client,
|
||||
user_agents: refs,
|
||||
reply: reply_sender,
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
||||
}
|
||||
461
server/crates/arbiter-server/src/actors/keyholder/mod.rs
Normal file
461
server/crates/arbiter-server/src/actors/keyholder/mod.rs
Normal file
@@ -0,0 +1,461 @@
|
||||
use chrono::Utc;
|
||||
use diesel::{
|
||||
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
|
||||
dsl::{insert_into, update},
|
||||
};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use hmac::Mac as _;
|
||||
use kameo::{Actor, Reply, messages};
|
||||
use strum::{EnumDiscriminants, IntoDiscriminant};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
crypto::{
|
||||
KeyCell, derive_key,
|
||||
encryption::v1::{self, Nonce},
|
||||
integrity::v1::HmacSha256,
|
||||
},
|
||||
safe_cell::SafeCell,
|
||||
};
|
||||
use crate::{
|
||||
db::{
|
||||
self,
|
||||
models::{self, RootKeyHistory},
|
||||
schema::{self},
|
||||
},
|
||||
safe_cell::SafeCellHandle as _,
|
||||
};
|
||||
|
||||
#[derive(Default, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
|
||||
enum State {
|
||||
#[default]
|
||||
Unbootstrapped,
|
||||
Sealed {
|
||||
root_key_history_id: i32,
|
||||
},
|
||||
Unsealed {
|
||||
root_key_history_id: i32,
|
||||
root_key: KeyCell,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Keyholder is already bootstrapped")]
|
||||
AlreadyBootstrapped,
|
||||
#[error("Keyholder is not bootstrapped")]
|
||||
NotBootstrapped,
|
||||
#[error("Invalid key provided")]
|
||||
InvalidKey,
|
||||
|
||||
#[error("Requested aead entry not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(#[from] chacha20poly1305::aead::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
|
||||
#[error("Database transaction error: {0}")]
|
||||
DatabaseTransaction(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct KeyHolder {
|
||||
db: db::DatabasePool,
|
||||
state: State,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl KeyHolder {
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, Error> {
|
||||
let state = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
let (root_key_history,) = schema::arbiter_settings::table
|
||||
.left_join(schema::root_key_history::table)
|
||||
.select((Option::<RootKeyHistory>::as_select(),))
|
||||
.get_result::<(Option<RootKeyHistory>,)>(&mut conn)
|
||||
.await?;
|
||||
|
||||
match root_key_history {
|
||||
Some(root_key_history) => State::Sealed {
|
||||
root_key_history_id: root_key_history.id,
|
||||
},
|
||||
None => State::Unbootstrapped,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { db, state })
|
||||
}
|
||||
|
||||
// Exclusive transaction to avoid race condtions if multiple keyholders write
|
||||
// additional layer of protection against nonce-reuse
|
||||
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
|
||||
let mut conn = pool.get().await?;
|
||||
|
||||
let nonce = conn
|
||||
.exclusive_transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let current_nonce: Vec<u8> = schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.select(schema::root_key_history::data_encryption_nonce)
|
||||
.first(conn)
|
||||
.await?;
|
||||
|
||||
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for root key history id={}",
|
||||
root_key_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
nonce.increment();
|
||||
|
||||
update(schema::root_key_history::table)
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, Error>::Ok(nonce)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(nonce)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
if !matches!(self.state, State::Unbootstrapped) {
|
||||
return Err(Error::AlreadyBootstrapped);
|
||||
}
|
||||
let salt = v1::generate_salt();
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
let mut root_key = KeyCell::new_secure_random();
|
||||
|
||||
// Zero nonces are fine because they are one-time
|
||||
let root_key_nonce = Nonce::default();
|
||||
let data_encryption_nonce = Nonce::default();
|
||||
|
||||
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
|
||||
let root_key_reader = reader.as_slice();
|
||||
seal_key
|
||||
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
|
||||
.map_err(|err| {
|
||||
error!(?err, "Fatal bootstrap error");
|
||||
Error::Encryption(err)
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
|
||||
let root_key_history_id = conn
|
||||
.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
|
||||
.values(&models::NewRootKeyHistory {
|
||||
ciphertext: root_key_ciphertext,
|
||||
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||
root_key_encryption_nonce: root_key_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce_bytes,
|
||||
schema_version: 1,
|
||||
salt: salt.to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.state = State::Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
};
|
||||
|
||||
info!("Keyholder bootstrapped successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
let State::Sealed {
|
||||
root_key_history_id,
|
||||
} = &self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
// We don't want to hold connection while doing expensive KDF work
|
||||
let current_key = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(*root_key_history_id))
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let salt = ¤t_key.salt;
|
||||
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
|
||||
error!("Broken database: invalid salt for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
|
||||
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
|
||||
|
||||
let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
|
||||
|_| {
|
||||
error!("Broken database: invalid nonce for root key");
|
||||
Error::BrokenDatabase
|
||||
},
|
||||
)?;
|
||||
|
||||
seal_key
|
||||
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
|
||||
.map_err(|err| {
|
||||
error!(?err, "Failed to unseal root key: invalid seal key");
|
||||
Error::InvalidKey
|
||||
})?;
|
||||
|
||||
self.state = State::Unsealed {
|
||||
root_key_history_id: current_key.id,
|
||||
root_key: KeyCell::try_from(root_key).map_err(|err| {
|
||||
error!(?err, "Broken database: invalid encryption key size");
|
||||
Error::BrokenDatabase
|
||||
})?,
|
||||
};
|
||||
|
||||
info!("Keyholder unsealed successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||
let State::Unsealed { root_key, .. } = &mut self.state else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
let row: models::AeadEncrypted = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::aead_encrypted::table
|
||||
.select(models::AeadEncrypted::as_select())
|
||||
.filter(schema::aead_encrypted::id.eq(aead_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.ok_or(Error::NotFound)?
|
||||
};
|
||||
|
||||
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for aead_encrypted id={}",
|
||||
aead_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut output = SafeCell::new(row.ciphertext);
|
||||
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
// Creates new `aead_encrypted` entry in the database and returns it's ID
|
||||
#[message]
|
||||
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
|
||||
let State::Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
..
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
|
||||
// Borrow checker note: &mut borrow a few lines above is disjoint from this field
|
||||
let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?;
|
||||
|
||||
let mut ciphertext_buffer = plaintext.write();
|
||||
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
|
||||
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
|
||||
|
||||
let ciphertext = std::mem::take(ciphertext_buffer);
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
|
||||
.values(&models::NewAeadEncrypted {
|
||||
ciphertext,
|
||||
tag: v1::TAG.to_vec(),
|
||||
current_nonce: nonce.to_vec(),
|
||||
schema_version: 1,
|
||||
associated_root_key_id: *root_key_history_id,
|
||||
created_at: Utc::now().into(),
|
||||
})
|
||||
.returning(schema::aead_encrypted::id)
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(aead_id)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn get_state(&self) -> KeyHolderState {
|
||||
self.state.discriminant()
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
|
||||
let State::Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
let mut hmac = root_key
|
||||
.0
|
||||
.read_inline(|k| match HmacSha256::new_from_slice(k) {
|
||||
Ok(v) => v,
|
||||
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||
});
|
||||
hmac.update(&root_key_history_id.to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
let mac = hmac.finalize().into_bytes().to_vec();
|
||||
Ok((*root_key_history_id, mac))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn verify_integrity(
|
||||
&mut self,
|
||||
mac_input: Vec<u8>,
|
||||
expected_mac: Vec<u8>,
|
||||
key_version: i32,
|
||||
) -> Result<bool, Error> {
|
||||
let State::Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
if *root_key_history_id != key_version {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut hmac = root_key
|
||||
.0
|
||||
.read_inline(|k| match HmacSha256::new_from_slice(k) {
|
||||
Ok(v) => v,
|
||||
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||
});
|
||||
hmac.update(&key_version.to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
Ok(hmac.verify_slice(&expected_mac).is_ok())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn seal(&mut self) -> Result<(), Error> {
|
||||
let State::Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
} = &self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
self.state = State::Sealed {
|
||||
root_key_history_id: *root_key_history_id,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use diesel::SelectableHelper;
|
||||
|
||||
use diesel_async::RunQueryDsl;
|
||||
|
||||
use crate::{
|
||||
db::{self},
|
||||
safe_cell::SafeCell,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn bootstrapped_actor(db: &db::DatabasePool) -> KeyHolder {
|
||||
let mut actor = KeyHolder::new(db.clone()).await.unwrap();
|
||||
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
actor.bootstrap(seal_key).await.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
|
||||
let db = db::create_test_pool().await;
|
||||
let mut actor = bootstrapped_actor(&db).await;
|
||||
let root_key_history_id = match actor.state {
|
||||
State::Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
} => root_key_history_id,
|
||||
_ => panic!("expected unsealed state"),
|
||||
};
|
||||
|
||||
let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let n2 = KeyHolder::get_new_nonce(&db, root_key_history_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let root_row: models::RootKeyHistory = schema::root_key_history::table
|
||||
.select(models::RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
|
||||
|
||||
let id = actor
|
||||
.create_new(SafeCell::new(b"post-interleave".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
let row: models::AeadEncrypted = schema::aead_encrypted::table
|
||||
.filter(schema::aead_encrypted::id.eq(id))
|
||||
.select(models::AeadEncrypted::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
row.current_nonce > n2.to_vec(),
|
||||
"next write must advance nonce"
|
||||
);
|
||||
}
|
||||
}
|
||||
47
server/crates/arbiter-server/src/actors/mod.rs
Normal file
47
server/crates/arbiter-server/src/actors/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use kameo::actor::{ActorRef, Spawn};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||
keyholder::KeyHolder,
|
||||
},
|
||||
db,
|
||||
};
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod client;
|
||||
mod evm;
|
||||
pub mod flow_coordinator;
|
||||
pub mod keyholder;
|
||||
pub mod user_agent;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SpawnError {
|
||||
#[error("Failed to spawn Bootstrapper actor")]
|
||||
Bootstrapper(#[from] bootstrap::Error),
|
||||
|
||||
#[error("Failed to spawn KeyHolder actor")]
|
||||
KeyHolder(#[from] keyholder::Error),
|
||||
}
|
||||
|
||||
/// Long-lived actors that are shared across all connections and handle global state and operations
|
||||
#[derive(Clone)]
|
||||
pub struct GlobalActors {
|
||||
pub key_holder: ActorRef<KeyHolder>,
|
||||
pub bootstrapper: ActorRef<Bootstrapper>,
|
||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||
pub evm: ActorRef<EvmActor>,
|
||||
}
|
||||
|
||||
impl GlobalActors {
|
||||
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
|
||||
let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?);
|
||||
Ok(Self {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
|
||||
key_holder,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
use arbiter_proto::proto::{
|
||||
UserAgentRequest, UserAgentResponse,
|
||||
auth::{
|
||||
self, AuthChallenge, AuthChallengeRequest, AuthOk, ClientMessage,
|
||||
ServerMessage as AuthServerMessage, client_message::Payload as ClientAuthPayload,
|
||||
server_message::Payload as ServerAuthPayload,
|
||||
},
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
};
|
||||
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, dsl::update};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use futures::StreamExt;
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::{ActorRef, Spawn},
|
||||
error::SendError,
|
||||
messages,
|
||||
prelude::Context,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tonic::Status;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
ServerContext,
|
||||
context::bootstrap::{BootstrapActor, ConsumeToken},
|
||||
db::{self, schema},
|
||||
errors::GrpcStatusExt,
|
||||
};
|
||||
|
||||
/// Context for state machine with validated key and sent challenge
|
||||
/// Challenge is then transformed to bytes using shared function and verified
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChallengeContext {
|
||||
challenge: AuthChallenge,
|
||||
key: VerifyingKey,
|
||||
}
|
||||
|
||||
// Request context with deserialized public key for state machine.
|
||||
// This intermediate struct is needed because the state machine branches depending on presence of bootstrap token,
|
||||
// but we want to have the deserialized key in both branches.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthRequestContext {
|
||||
pubkey: VerifyingKey,
|
||||
bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
smlang::statemachine!(
|
||||
name: UserAgent,
|
||||
derive_states: [Debug],
|
||||
custom_error: false,
|
||||
transitions: {
|
||||
*Init + AuthRequest(AuthRequestContext) / auth_request_context = ReceivedAuthRequest(AuthRequestContext),
|
||||
ReceivedAuthRequest(AuthRequestContext) + ReceivedBootstrapToken = Authenticated,
|
||||
|
||||
ReceivedAuthRequest(AuthRequestContext) + SentChallenge(ChallengeContext) / move_challenge = WaitingForChallengeSolution(ChallengeContext),
|
||||
|
||||
WaitingForChallengeSolution(ChallengeContext) + ReceivedGoodSolution = Authenticated,
|
||||
WaitingForChallengeSolution(ChallengeContext) + ReceivedBadSolution = AuthError, // block further transitions, but connection should close anyway
|
||||
}
|
||||
);
|
||||
|
||||
pub struct DummyContext;
|
||||
impl UserAgentStateMachineContext for DummyContext {
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
fn move_challenge(
|
||||
&mut self,
|
||||
state_data: &AuthRequestContext,
|
||||
event_data: ChallengeContext,
|
||||
) -> Result<ChallengeContext, ()> {
|
||||
Ok(event_data)
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
fn auth_request_context(
|
||||
&mut self,
|
||||
event_data: AuthRequestContext,
|
||||
) -> Result<AuthRequestContext, ()> {
|
||||
Ok(event_data)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct UserAgentActor {
|
||||
db: db::DatabasePool,
|
||||
bootstapper: ActorRef<BootstrapActor>,
|
||||
state: UserAgentStateMachine<DummyContext>,
|
||||
tx: Sender<Result<UserAgentResponse, Status>>,
|
||||
}
|
||||
|
||||
impl UserAgentActor {
|
||||
pub(crate) fn new(
|
||||
context: ServerContext,
|
||||
tx: Sender<Result<UserAgentResponse, Status>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db: context.db.clone(),
|
||||
bootstapper: context.bootstrapper.clone(),
|
||||
state: UserAgentStateMachine::new(DummyContext),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_manual(
|
||||
db: db::DatabasePool,
|
||||
bootstapper: ActorRef<BootstrapActor>,
|
||||
tx: Sender<Result<UserAgentResponse, Status>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
bootstapper,
|
||||
state: UserAgentStateMachine::new(DummyContext),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Status> {
|
||||
self.state.process_event(event).map_err(|e| {
|
||||
error!(?e, "State transition failed");
|
||||
Status::internal("State machine error")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn auth_with_bootstrap_token(
|
||||
&mut self,
|
||||
pubkey: ed25519_dalek::VerifyingKey,
|
||||
token: String,
|
||||
) -> Result<UserAgentResponse, Status> {
|
||||
let token_ok: bool = self
|
||||
.bootstapper
|
||||
.ask(ConsumeToken { token })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?pubkey, "Failed to consume bootstrap token: {e}");
|
||||
Status::internal("Bootstrap token consumption failed")
|
||||
})?;
|
||||
|
||||
if !token_ok {
|
||||
error!(?pubkey, "Invalid bootstrap token provided");
|
||||
return Err(Status::invalid_argument("Invalid bootstrap token"));
|
||||
}
|
||||
|
||||
{
|
||||
let mut conn = self.db.get().await.to_status()?;
|
||||
|
||||
diesel::insert_into(schema::useragent_client::table)
|
||||
.values((
|
||||
schema::useragent_client::public_key.eq(pubkey.as_bytes().to_vec()),
|
||||
schema::useragent_client::nonce.eq(1),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.to_status()?;
|
||||
}
|
||||
|
||||
self.transition(UserAgentEvents::ReceivedBootstrapToken)?;
|
||||
|
||||
Ok(auth_response(ServerAuthPayload::AuthOk(AuthOk {})))
|
||||
}
|
||||
|
||||
async fn auth_with_challenge(&mut self, pubkey: VerifyingKey, pubkey_bytes: Vec<u8>) -> Output {
|
||||
let nonce: Option<i32> = {
|
||||
let mut db_conn = self.db.get().await.to_status()?;
|
||||
db_conn
|
||||
.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let current_nonce = schema::useragent_client::table
|
||||
.filter(
|
||||
schema::useragent_client::public_key.eq(pubkey.as_bytes().to_vec()),
|
||||
)
|
||||
.select(schema::useragent_client::nonce)
|
||||
.first::<i32>(conn)
|
||||
.await?;
|
||||
|
||||
update(schema::useragent_client::table)
|
||||
.filter(
|
||||
schema::useragent_client::public_key.eq(pubkey.as_bytes().to_vec()),
|
||||
)
|
||||
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(current_nonce)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.optional()
|
||||
.to_status()?
|
||||
};
|
||||
|
||||
let Some(nonce) = nonce else {
|
||||
error!(?pubkey, "Public key not found in database");
|
||||
return Err(Status::unauthenticated("Public key not registered"));
|
||||
};
|
||||
|
||||
let challenge = auth::AuthChallenge {
|
||||
pubkey: pubkey_bytes,
|
||||
nonce: nonce,
|
||||
};
|
||||
|
||||
self.transition(UserAgentEvents::SentChallenge(ChallengeContext {
|
||||
challenge: challenge.clone(),
|
||||
key: pubkey,
|
||||
}))?;
|
||||
|
||||
info!(
|
||||
?pubkey,
|
||||
?challenge,
|
||||
"Sent authentication challenge to client"
|
||||
);
|
||||
|
||||
Ok(auth_response(ServerAuthPayload::AuthChallenge(challenge)))
|
||||
}
|
||||
|
||||
fn verify_challenge_solution(
|
||||
&self,
|
||||
solution: &auth::AuthChallengeSolution,
|
||||
) -> Result<(bool, &ChallengeContext), Status> {
|
||||
let UserAgentStates::WaitingForChallengeSolution(challenge_context) = self.state.state()
|
||||
else {
|
||||
error!("Received challenge solution in invalid state");
|
||||
return Err(Status::invalid_argument(
|
||||
"Invalid state for challenge solution",
|
||||
));
|
||||
};
|
||||
let formatted_challenge = arbiter_proto::format_challenge(&challenge_context.challenge);
|
||||
|
||||
let signature = solution.signature.as_slice().try_into().map_err(|_| {
|
||||
error!(?solution, "Invalid signature length");
|
||||
Status::invalid_argument("Invalid signature length")
|
||||
})?;
|
||||
|
||||
let valid = challenge_context
|
||||
.key
|
||||
.verify_strict(&formatted_challenge, &signature)
|
||||
.is_ok();
|
||||
|
||||
Ok((valid, challenge_context))
|
||||
}
|
||||
}
|
||||
|
||||
type Output = Result<UserAgentResponse, Status>;
|
||||
|
||||
fn auth_response(payload: ServerAuthPayload) -> UserAgentResponse {
|
||||
UserAgentResponse {
|
||||
payload: Some(UserAgentResponsePayload::AuthMessage(AuthServerMessage {
|
||||
payload: Some(payload),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentActor {
|
||||
#[message(ctx)]
|
||||
pub async fn handle_auth_challenge_request(
|
||||
&mut self,
|
||||
req: AuthChallengeRequest,
|
||||
ctx: &mut Context<Self, Output>,
|
||||
) -> Output {
|
||||
let pubkey = req.pubkey.as_array().ok_or(Status::invalid_argument(
|
||||
"Expected pubkey to have specific length",
|
||||
))?;
|
||||
let pubkey = VerifyingKey::from_bytes(pubkey).map_err(|err| {
|
||||
error!(?pubkey, "Failed to convert to VerifyingKey");
|
||||
Status::invalid_argument("Failed to convert pubkey to VerifyingKey")
|
||||
})?;
|
||||
|
||||
self.transition(UserAgentEvents::AuthRequest(AuthRequestContext {
|
||||
pubkey,
|
||||
bootstrap_token: req.bootstrap_token.clone(),
|
||||
}))?;
|
||||
|
||||
match req.bootstrap_token {
|
||||
Some(token) => self.auth_with_bootstrap_token(pubkey, token).await,
|
||||
None => self.auth_with_challenge(pubkey, req.pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[message(ctx)]
|
||||
pub async fn handle_auth_challenge_solution(
|
||||
&mut self,
|
||||
solution: auth::AuthChallengeSolution,
|
||||
ctx: &mut Context<Self, Output>,
|
||||
) -> Output {
|
||||
let (valid, challenge_context) = self.verify_challenge_solution(&solution)?;
|
||||
|
||||
if valid {
|
||||
info!(
|
||||
?challenge_context,
|
||||
"Client provided valid solution to authentication challenge"
|
||||
);
|
||||
self.transition(UserAgentEvents::ReceivedGoodSolution)?;
|
||||
Ok(auth_response(ServerAuthPayload::AuthOk(AuthOk {})))
|
||||
} else {
|
||||
error!("Client provided invalid solution to authentication challenge");
|
||||
self.transition(UserAgentEvents::ReceivedBadSolution)?;
|
||||
Err(Status::unauthenticated("Invalid challenge solution"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use arbiter_proto::proto::{
|
||||
UserAgentResponse, auth::{AuthChallengeRequest, AuthOk},
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
};
|
||||
use kameo::actor::Spawn;
|
||||
|
||||
use crate::{
|
||||
actors::user_agent::HandleAuthChallengeRequest, context::bootstrap::BootstrapActor, db,
|
||||
};
|
||||
|
||||
use super::UserAgentActor;
|
||||
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn test_bootstrap_token_auth() {
|
||||
let db = db::create_test_pool().await;
|
||||
// explicitly not installing any user_agent pubkeys
|
||||
let bootstrapper = BootstrapActor::new(&db).await.unwrap(); // this will create bootstrap token
|
||||
let token = bootstrapper.get_token().unwrap();
|
||||
|
||||
let bootstrapper_ref = BootstrapActor::spawn(bootstrapper);
|
||||
let user_agent = UserAgentActor::new_manual(
|
||||
db.clone(),
|
||||
bootstrapper_ref,
|
||||
tokio::sync::mpsc::channel(1).0, // dummy channel, we won't actually send responses in this test
|
||||
);
|
||||
let user_agent_ref = UserAgentActor::spawn(user_agent);
|
||||
|
||||
// simulate client sending auth request with bootstrap token
|
||||
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
|
||||
|
||||
let result = user_agent_ref
|
||||
.ask(HandleAuthChallengeRequest {
|
||||
req: AuthChallengeRequest {
|
||||
pubkey: pubkey_bytes,
|
||||
bootstrap_token: Some(token),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("Shouldn't fail to send message");
|
||||
|
||||
// auth succeeded
|
||||
assert_eq!(
|
||||
result,
|
||||
UserAgentResponse {
|
||||
payload: Some(UserAgentResponsePayload::AuthMessage(
|
||||
arbiter_proto::proto::auth::ServerMessage {
|
||||
payload: Some(arbiter_proto::proto::auth::server_message::Payload::AuthOk(
|
||||
AuthOk {},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod transport;
|
||||
pub(crate) use transport::handle_user_agent;
|
||||
117
server/crates/arbiter-server/src/actors/user_agent/auth.rs
Normal file
117
server/crates/arbiter-server/src/actors/user_agent/auth.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use arbiter_proto::transport::Bi;
|
||||
use tracing::error;
|
||||
|
||||
use crate::actors::user_agent::{
|
||||
AuthPublicKey, UserAgentConnection,
|
||||
auth::state::{AuthContext, AuthStateMachine},
|
||||
};
|
||||
|
||||
mod state;
|
||||
use state::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Inbound {
|
||||
AuthChallengeRequest {
|
||||
pubkey: AuthPublicKey,
|
||||
bootstrap_token: Option<String>,
|
||||
},
|
||||
AuthChallengeSolution {
|
||||
signature: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
UnregisteredPublicKey,
|
||||
InvalidChallengeSolution,
|
||||
InvalidBootstrapToken,
|
||||
Internal { details: String },
|
||||
Transport,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
#[track_caller]
|
||||
pub(super) fn internal(details: impl Into<String>, err: &impl std::fmt::Debug) -> Self {
|
||||
let details = details.into();
|
||||
let caller = std::panic::Location::caller();
|
||||
error!(
|
||||
caller_file = %caller.file(),
|
||||
caller_line = caller.line(),
|
||||
caller_column = caller.column(),
|
||||
details = %details,
|
||||
error = ?err,
|
||||
"Internal error"
|
||||
);
|
||||
|
||||
Self::Internal { details }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for Error {
|
||||
fn from(e: diesel::result::Error) -> Self {
|
||||
Self::internal("Database error", &e)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outbound {
|
||||
AuthChallenge { nonce: i32 },
|
||||
AuthSuccess,
|
||||
}
|
||||
|
||||
fn parse_auth_event(payload: Inbound) -> AuthEvents {
|
||||
match payload {
|
||||
Inbound::AuthChallengeRequest {
|
||||
pubkey,
|
||||
bootstrap_token: None,
|
||||
} => AuthEvents::AuthRequest(ChallengeRequest { pubkey }),
|
||||
Inbound::AuthChallengeRequest {
|
||||
pubkey,
|
||||
bootstrap_token: Some(token),
|
||||
} => AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { pubkey, token }),
|
||||
Inbound::AuthChallengeSolution { signature } => {
|
||||
AuthEvents::ReceivedSolution(ChallengeSolution {
|
||||
solution: signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate<T>(
|
||||
props: &mut UserAgentConnection,
|
||||
transport: T,
|
||||
) -> Result<AuthPublicKey, Error>
|
||||
where
|
||||
T: Bi<Inbound, Result<Outbound, Error>> + Send,
|
||||
{
|
||||
let mut state = AuthStateMachine::new(AuthContext::new(props, transport));
|
||||
|
||||
loop {
|
||||
// `state` holds a mutable reference to `props` so we can't access it directly here
|
||||
let Some(payload) = state.context_mut().transport.recv().await else {
|
||||
return Err(Error::Transport);
|
||||
};
|
||||
|
||||
match state.process_event(parse_auth_event(payload)).await {
|
||||
Ok(AuthStates::AuthOk(key)) => return Ok(key.clone()),
|
||||
Err(AuthError::ActionFailed(err)) => {
|
||||
error!(?err, "State machine action failed");
|
||||
return Err(err);
|
||||
}
|
||||
Err(AuthError::GuardFailed(err)) => {
|
||||
error!(?err, "State machine guard failed");
|
||||
return Err(err);
|
||||
}
|
||||
Err(AuthError::InvalidEvent) => {
|
||||
error!("Invalid event for current state");
|
||||
return Err(Error::InvalidChallengeSolution);
|
||||
}
|
||||
Err(AuthError::TransitionsFailed) => {
|
||||
error!("Invalid state transition");
|
||||
return Err(Error::InvalidChallengeSolution);
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
346
server/crates/arbiter-server/src/actors/user_agent/auth/state.rs
Normal file
346
server/crates/arbiter-server/src/actors/user_agent/auth/state.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use arbiter_proto::transport::Bi;
|
||||
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use kameo::actor::ActorRef;
|
||||
use tracing::error;
|
||||
|
||||
use super::Error;
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::ConsumeToken,
|
||||
keyholder::KeyHolder,
|
||||
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
|
||||
},
|
||||
crypto::integrity,
|
||||
db::{DatabasePool, schema::useragent_client},
|
||||
};
|
||||
|
||||
pub struct ChallengeRequest {
|
||||
pub pubkey: AuthPublicKey,
|
||||
}
|
||||
|
||||
pub struct BootstrapAuthRequest {
|
||||
pub pubkey: AuthPublicKey,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub struct ChallengeContext {
|
||||
pub challenge_nonce: i32,
|
||||
pub key: AuthPublicKey,
|
||||
}
|
||||
|
||||
pub struct ChallengeSolution {
|
||||
pub solution: Vec<u8>,
|
||||
}
|
||||
|
||||
smlang::statemachine!(
|
||||
name: Auth,
|
||||
custom_error: true,
|
||||
transitions: {
|
||||
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
|
||||
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey),
|
||||
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey),
|
||||
}
|
||||
);
|
||||
|
||||
/// Returns the current nonce, ready to use for the challenge nonce.
|
||||
async fn get_current_nonce_and_id(
|
||||
db: &DatabasePool,
|
||||
key: &AuthPublicKey,
|
||||
) -> Result<(i32, i32), Error> {
|
||||
let mut db_conn = db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||
db_conn
|
||||
.exclusive_transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
useragent_client::table
|
||||
.filter(useragent_client::public_key.eq(key.to_stored_bytes()))
|
||||
.filter(useragent_client::key_type.eq(key.key_type()))
|
||||
.select((useragent_client::id, useragent_client::nonce))
|
||||
.first::<(i32, i32)>(conn)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.optional()
|
||||
.map_err(|e| Error::internal("Database operation failed", &e))?
|
||||
.ok_or_else(|| {
|
||||
error!(?key, "Public key not found in database");
|
||||
Error::UnregisteredPublicKey
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_integrity(
|
||||
db: &DatabasePool,
|
||||
keyholder: &ActorRef<KeyHolder>,
|
||||
pubkey: &AuthPublicKey,
|
||||
) -> Result<(), Error> {
|
||||
let mut db_conn = db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||
|
||||
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
|
||||
|
||||
let attestation_status = integrity::check_entity_attestation(
|
||||
&mut db_conn,
|
||||
keyholder,
|
||||
&UserAgentCredentials {
|
||||
pubkey: pubkey.clone(),
|
||||
nonce,
|
||||
},
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal("Integrity verification failed", &e))?;
|
||||
|
||||
use integrity::AttestationStatus as AS;
|
||||
// SAFETY (policy): challenge auth must work in both vault states.
|
||||
// While sealed, integrity checks can only report `Unavailable` because key material is not
|
||||
// accessible. While unsealed, the same check can report `Attested`.
|
||||
// This path intentionally accepts both outcomes to keep challenge auth available across state
|
||||
// transitions; stricter verification is enforced in sensitive post-auth flows.
|
||||
match attestation_status {
|
||||
AS::Attested | AS::Unavailable => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_nonce(
|
||||
db: &DatabasePool,
|
||||
keyholder: &ActorRef<KeyHolder>,
|
||||
pubkey: &AuthPublicKey,
|
||||
) -> Result<i32, Error> {
|
||||
let mut db_conn = db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||
let new_nonce = db_conn
|
||||
.exclusive_transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let (id, new_nonce): (i32, i32) = update(useragent_client::table)
|
||||
.filter(useragent_client::public_key.eq(pubkey.to_stored_bytes()))
|
||||
.filter(useragent_client::key_type.eq(pubkey.key_type()))
|
||||
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
|
||||
.returning((useragent_client::id, useragent_client::nonce))
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database operation failed", &e))?;
|
||||
|
||||
integrity::sign_entity(
|
||||
conn,
|
||||
keyholder,
|
||||
&UserAgentCredentials {
|
||||
pubkey: pubkey.clone(),
|
||||
nonce: new_nonce,
|
||||
},
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database error", &e))?;
|
||||
|
||||
Result::<_, Error>::Ok(new_nonce)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(new_nonce)
|
||||
}
|
||||
|
||||
async fn register_key(
|
||||
db: &DatabasePool,
|
||||
keyholder: &ActorRef<KeyHolder>,
|
||||
pubkey: &AuthPublicKey,
|
||||
) -> Result<(), Error> {
|
||||
let pubkey_bytes = pubkey.to_stored_bytes();
|
||||
let key_type = pubkey.key_type();
|
||||
let mut conn = db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
const NONCE_START: i32 = 1;
|
||||
|
||||
let id: i32 = diesel::insert_into(useragent_client::table)
|
||||
.values((
|
||||
useragent_client::public_key.eq(pubkey_bytes),
|
||||
useragent_client::nonce.eq(NONCE_START),
|
||||
useragent_client::key_type.eq(key_type),
|
||||
))
|
||||
.returning(useragent_client::id)
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal("Database operation failed", &e))?;
|
||||
|
||||
if let Err(e) = integrity::sign_entity(
|
||||
conn,
|
||||
keyholder,
|
||||
&UserAgentCredentials {
|
||||
pubkey: pubkey.clone(),
|
||||
nonce: NONCE_START,
|
||||
},
|
||||
id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match e {
|
||||
integrity::Error::Keyholder(
|
||||
crate::actors::keyholder::Error::NotBootstrapped,
|
||||
) => {
|
||||
// IMPORTANT: bootstrap-token auth must work before the vault has a root key.
|
||||
// We intentionally allow creating the DB row first and backfill envelopes
|
||||
// after bootstrap/unseal to keep the bootstrap flow possible.
|
||||
}
|
||||
other => {
|
||||
return Err(Error::internal("Failed to register public key", &other));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct AuthContext<'a, T> {
|
||||
pub(super) conn: &'a mut UserAgentConnection,
|
||||
pub(super) transport: T,
|
||||
}
|
||||
|
||||
impl<'a, T> AuthContext<'a, T> {
|
||||
pub fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self {
|
||||
Self { conn, transport }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AuthStateMachineContext for AuthContext<'_, T>
|
||||
where
|
||||
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
|
||||
{
|
||||
type Error = Error;
|
||||
|
||||
async fn prepare_challenge(
|
||||
&mut self,
|
||||
ChallengeRequest { pubkey }: ChallengeRequest,
|
||||
) -> Result<ChallengeContext, Self::Error> {
|
||||
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
|
||||
|
||||
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
|
||||
|
||||
self.transport
|
||||
.send(Ok(Outbound::AuthChallenge { nonce }))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Failed to send auth challenge");
|
||||
Error::Transport
|
||||
})?;
|
||||
|
||||
Ok(ChallengeContext {
|
||||
challenge_nonce: nonce,
|
||||
key: pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::result_unit_err)]
|
||||
async fn verify_bootstrap_token(
|
||||
&mut self,
|
||||
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
|
||||
) -> Result<AuthPublicKey, Self::Error> {
|
||||
let token_ok: bool = self
|
||||
.conn
|
||||
.actors
|
||||
.bootstrapper
|
||||
.ask(ConsumeToken {
|
||||
token: token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::internal("Failed to consume bootstrap token", &e))?;
|
||||
|
||||
if !token_ok {
|
||||
error!("Invalid bootstrap token provided");
|
||||
return Err(Error::InvalidBootstrapToken);
|
||||
}
|
||||
|
||||
match token_ok {
|
||||
true => {
|
||||
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
|
||||
self.transport
|
||||
.send(Ok(Outbound::AuthSuccess))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
Ok(pubkey)
|
||||
}
|
||||
false => {
|
||||
error!("Invalid bootstrap token provided");
|
||||
self.transport
|
||||
.send(Err(Error::InvalidBootstrapToken))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
Err(Error::InvalidBootstrapToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
async fn verify_solution(
|
||||
&mut self,
|
||||
ChallengeContext {
|
||||
challenge_nonce,
|
||||
key,
|
||||
}: &ChallengeContext,
|
||||
ChallengeSolution { solution }: ChallengeSolution,
|
||||
) -> Result<AuthPublicKey, Self::Error> {
|
||||
let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes());
|
||||
|
||||
let valid = match key {
|
||||
AuthPublicKey::Ed25519(vk) => {
|
||||
let sig = solution.as_slice().try_into().map_err(|_| {
|
||||
error!(?solution, "Invalid Ed25519 signature length");
|
||||
Error::InvalidChallengeSolution
|
||||
})?;
|
||||
vk.verify_strict(&formatted, &sig).is_ok()
|
||||
}
|
||||
AuthPublicKey::EcdsaSecp256k1(vk) => {
|
||||
use k256::ecdsa::signature::Verifier as _;
|
||||
let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| {
|
||||
error!(?solution, "Invalid ECDSA signature bytes");
|
||||
Error::InvalidChallengeSolution
|
||||
})?;
|
||||
vk.verify(&formatted, &sig).is_ok()
|
||||
}
|
||||
AuthPublicKey::Rsa(pk) => {
|
||||
use rsa::signature::Verifier as _;
|
||||
let verifying_key = rsa::pss::VerifyingKey::<sha2::Sha256>::new(pk.clone());
|
||||
let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| {
|
||||
error!(?solution, "Invalid RSA signature bytes");
|
||||
Error::InvalidChallengeSolution
|
||||
})?;
|
||||
verifying_key.verify(&formatted, &sig).is_ok()
|
||||
}
|
||||
};
|
||||
|
||||
match valid {
|
||||
true => {
|
||||
self.transport
|
||||
.send(Ok(Outbound::AuthSuccess))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
Ok(key.clone())
|
||||
}
|
||||
false => {
|
||||
self.transport
|
||||
.send(Err(Error::InvalidChallengeSolution))
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
Err(Error::InvalidChallengeSolution)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
server/crates/arbiter-server/src/actors/user_agent/mod.rs
Normal file
120
server/crates/arbiter-server/src/actors/user_agent/mod.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::{
|
||||
actors::{GlobalActors, client::ClientProfile},
|
||||
crypto::integrity::Integrable,
|
||||
db::{self, models::KeyType},
|
||||
};
|
||||
|
||||
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AuthPublicKey {
|
||||
Ed25519(ed25519_dalek::VerifyingKey),
|
||||
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
|
||||
EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
|
||||
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
|
||||
Rsa(rsa::RsaPublicKey),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserAgentCredentials {
|
||||
pub pubkey: AuthPublicKey,
|
||||
pub nonce: i32,
|
||||
}
|
||||
|
||||
impl Integrable for UserAgentCredentials {
|
||||
const KIND: &'static str = "useragent_credentials";
|
||||
}
|
||||
|
||||
impl AuthPublicKey {
|
||||
/// Canonical bytes stored in DB and echoed back in the challenge.
|
||||
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
|
||||
pub fn to_stored_bytes(&self) -> Vec<u8> {
|
||||
match self {
|
||||
AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(),
|
||||
// SEC1 compressed (33 bytes) is the natural compact format for secp256k1
|
||||
AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(),
|
||||
AuthPublicKey::Rsa(k) => {
|
||||
use rsa::pkcs8::EncodePublicKey as _;
|
||||
#[allow(clippy::expect_used)]
|
||||
k.to_public_key_der()
|
||||
.expect("rsa SPKI encoding is infallible")
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key_type(&self) -> KeyType {
|
||||
match self {
|
||||
AuthPublicKey::Ed25519(_) => KeyType::Ed25519,
|
||||
AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1,
|
||||
AuthPublicKey::Rsa(_) => KeyType::Rsa,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<(KeyType, Vec<u8>)> for AuthPublicKey {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: (KeyType, Vec<u8>)) -> Result<Self, Self::Error> {
|
||||
let (key_type, bytes) = value;
|
||||
match key_type {
|
||||
KeyType::Ed25519 => {
|
||||
let bytes: [u8; 32] = bytes.try_into().map_err(|_| "invalid Ed25519 key length")?;
|
||||
let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
|
||||
.map_err(|_e| "invalid Ed25519 key")?;
|
||||
Ok(AuthPublicKey::Ed25519(key))
|
||||
}
|
||||
KeyType::EcdsaSecp256k1 => {
|
||||
let point =
|
||||
k256::EncodedPoint::from_bytes(&bytes).map_err(|_e| "invalid ECDSA key")?;
|
||||
let key = k256::ecdsa::VerifyingKey::from_encoded_point(&point)
|
||||
.map_err(|_e| "invalid ECDSA key")?;
|
||||
Ok(AuthPublicKey::EcdsaSecp256k1(key))
|
||||
}
|
||||
KeyType::Rsa => {
|
||||
use rsa::pkcs8::DecodePublicKey as _;
|
||||
let key = rsa::RsaPublicKey::from_public_key_der(&bytes)
|
||||
.map_err(|_e| "invalid RSA key")?;
|
||||
Ok(AuthPublicKey::Rsa(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages, sent by user agent to connection client without having a request
|
||||
#[derive(Debug)]
|
||||
pub enum OutOfBand {
|
||||
ClientConnectionRequest { profile: ClientProfile },
|
||||
ClientConnectionCancel { pubkey: ed25519_dalek::VerifyingKey },
|
||||
}
|
||||
|
||||
pub struct UserAgentConnection {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) actors: GlobalActors,
|
||||
}
|
||||
|
||||
impl UserAgentConnection {
|
||||
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||
Self { db, actors }
|
||||
}
|
||||
}
|
||||
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
|
||||
pub use auth::authenticate;
|
||||
pub use session::UserAgentSession;
|
||||
|
||||
use crate::crypto::integrity::hashing::Hashable;
|
||||
|
||||
impl Hashable for AuthPublicKey {
|
||||
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.to_stored_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for UserAgentCredentials {
|
||||
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||
self.pubkey.hash(hasher);
|
||||
self.nonce.hash(hasher);
|
||||
}
|
||||
}
|
||||
181
server/crates/arbiter-server/src/actors/user_agent/session.rs
Normal file
181
server/crates/arbiter-server/src/actors/user_agent/session.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use std::{borrow::Cow, collections::HashMap};
|
||||
|
||||
use arbiter_proto::transport::Sender;
|
||||
use async_trait::async_trait;
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use thiserror::Error;
|
||||
use tracing::error;
|
||||
|
||||
use crate::actors::{
|
||||
client::ClientProfile,
|
||||
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController},
|
||||
user_agent::{OutOfBand, UserAgentConnection},
|
||||
};
|
||||
|
||||
mod state;
|
||||
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("State transition failed")]
|
||||
State,
|
||||
|
||||
#[error("Internal error: {message}")]
|
||||
Internal { message: Cow<'static, str> },
|
||||
}
|
||||
|
||||
impl From<crate::db::PoolError> for Error {
|
||||
fn from(err: crate::db::PoolError) -> Self {
|
||||
error!(?err, "Database pool error");
|
||||
Self::internal("Database pool error")
|
||||
}
|
||||
}
|
||||
impl From<diesel::result::Error> for Error {
|
||||
fn from(err: diesel::result::Error) -> Self {
|
||||
error!(?err, "Database error");
|
||||
Self::internal("Database error")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn internal(message: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PendingClientApproval {
|
||||
controller: ActorRef<ClientApprovalController>,
|
||||
}
|
||||
|
||||
pub struct UserAgentSession {
|
||||
props: UserAgentConnection,
|
||||
state: UserAgentStateMachine<DummyContext>,
|
||||
sender: Box<dyn Sender<OutOfBand>>,
|
||||
|
||||
pending_client_approvals: HashMap<VerifyingKey, PendingClientApproval>,
|
||||
}
|
||||
|
||||
pub mod connection;
|
||||
|
||||
impl UserAgentSession {
|
||||
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
||||
Self {
|
||||
props,
|
||||
state: UserAgentStateMachine::new(DummyContext),
|
||||
sender,
|
||||
pending_client_approvals: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
|
||||
struct DummySender;
|
||||
|
||||
#[async_trait]
|
||||
impl Sender<OutOfBand> for DummySender {
|
||||
async fn send(
|
||||
&mut self,
|
||||
_item: OutOfBand,
|
||||
) -> Result<(), arbiter_proto::transport::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
|
||||
}
|
||||
|
||||
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> {
|
||||
self.state.process_event(event).map_err(|e| {
|
||||
error!(?e, "State transition failed");
|
||||
Error::State
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub async fn begin_new_client_approval(
|
||||
&mut self,
|
||||
client: ClientProfile,
|
||||
controller: ActorRef<ClientApprovalController>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.sender
|
||||
.send(OutOfBand::ClientConnectionRequest {
|
||||
profile: client.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
?e,
|
||||
actor = "user_agent",
|
||||
event = "failed to announce new client connection"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_client_approvals
|
||||
.insert(client.pubkey, PendingClientApproval { controller });
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for UserAgentSession {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Error;
|
||||
|
||||
async fn on_start(
|
||||
args: Self::Args,
|
||||
this: kameo::prelude::ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
args.props
|
||||
.actors
|
||||
.flow_coordinator
|
||||
.ask(RegisterUserAgent {
|
||||
actor: this.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
?err,
|
||||
"Failed to register user agent connection with flow coordinator"
|
||||
);
|
||||
Error::internal("Failed to register user agent connection with flow coordinator")
|
||||
})?;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: kameo::prelude::WeakActorRef<Self>,
|
||||
id: kameo::prelude::ActorId,
|
||||
_: kameo::prelude::ActorStopReason,
|
||||
) -> Result<std::ops::ControlFlow<kameo::prelude::ActorStopReason>, Self::Error> {
|
||||
let cancelled_pubkey = self
|
||||
.pending_client_approvals
|
||||
.iter()
|
||||
.find_map(|(k, v)| (v.controller.id() == id).then_some(*k));
|
||||
|
||||
if let Some(pubkey) = cancelled_pubkey {
|
||||
self.pending_client_approvals.remove(&pubkey);
|
||||
|
||||
if let Err(e) = self
|
||||
.sender
|
||||
.send(OutOfBand::ClientConnectionCancel { pubkey })
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
?e,
|
||||
actor = "user_agent",
|
||||
event = "failed to announce client connection cancellation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(std::ops::ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
||||
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
|
||||
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use kameo::error::SendError;
|
||||
use kameo::messages;
|
||||
use kameo::prelude::Context;
|
||||
use tracing::{error, info};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
use crate::actors::keyholder::KeyHolderState;
|
||||
use crate::actors::user_agent::session::Error;
|
||||
use crate::db::models::{
|
||||
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
||||
};
|
||||
use crate::evm::policies::{Grant, SpecificGrant};
|
||||
use crate::safe_cell::SafeCell;
|
||||
use crate::{
|
||||
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
|
||||
crypto::integrity::{self, Verified},
|
||||
};
|
||||
use crate::{
|
||||
actors::{
|
||||
evm::{
|
||||
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
|
||||
UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
|
||||
},
|
||||
keyholder::{self, Bootstrap, TryUnseal},
|
||||
user_agent::session::{
|
||||
UserAgentSession,
|
||||
state::{UnsealContext, UserAgentEvents, UserAgentStates},
|
||||
},
|
||||
user_agent::{AuthPublicKey, UserAgentCredentials},
|
||||
},
|
||||
db::schema::useragent_client,
|
||||
safe_cell::SafeCellHandle as _,
|
||||
};
|
||||
|
||||
fn is_vault_sealed_from_evm<M>(err: &SendError<M, crate::actors::evm::Error>) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
SendError::HandlerError(crate::actors::evm::Error::Keyholder(
|
||||
keyholder::Error::NotBootstrapped
|
||||
)) | SendError::HandlerError(crate::actors::evm::Error::Integrity(
|
||||
crate::crypto::integrity::Error::Keyholder(keyholder::Error::NotBootstrapped)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
impl UserAgentSession {
|
||||
async fn backfill_useragent_integrity(&self) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
let keyholder = self.props.actors.key_holder.clone();
|
||||
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let rows: Vec<(i32, i32, Vec<u8>, crate::db::models::KeyType)> =
|
||||
useragent_client::table
|
||||
.select((
|
||||
useragent_client::id,
|
||||
useragent_client::nonce,
|
||||
useragent_client::public_key,
|
||||
useragent_client::key_type,
|
||||
))
|
||||
.load(conn)
|
||||
.await?;
|
||||
|
||||
for (id, nonce, public_key, key_type) in rows {
|
||||
let pubkey = AuthPublicKey::try_from((key_type, public_key)).map_err(|e| {
|
||||
Error::internal(format!("Invalid user-agent key in db: {e}"))
|
||||
})?;
|
||||
|
||||
integrity::sign_entity(
|
||||
conn,
|
||||
&keyholder,
|
||||
&UserAgentCredentials { pubkey, nonce },
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal(format!("Failed to backfill user-agent integrity: {e}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
|
||||
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
|
||||
error!("Received encrypted key in invalid state");
|
||||
return Err(Error::internal("Invalid state for unseal encrypted key"));
|
||||
};
|
||||
|
||||
let ephemeral_secret = {
|
||||
#[allow(
|
||||
clippy::unwrap_used,
|
||||
reason = "Mutex poison is unrecoverable and should panic"
|
||||
)]
|
||||
let mut secret_lock = unseal_context.secret.lock().unwrap();
|
||||
let secret = secret_lock.take();
|
||||
match secret {
|
||||
Some(secret) => secret,
|
||||
None => {
|
||||
drop(secret_lock);
|
||||
error!("Ephemeral secret already taken");
|
||||
return Err(Error::internal("Ephemeral secret already taken"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok((ephemeral_secret, unseal_context.client_public_key))
|
||||
}
|
||||
|
||||
fn decrypt_client_key_material(
|
||||
ephemeral_secret: EphemeralSecret,
|
||||
client_public_key: PublicKey,
|
||||
nonce: &[u8],
|
||||
ciphertext: &[u8],
|
||||
associated_data: &[u8],
|
||||
) -> Result<SafeCell<Vec<u8>>, ()> {
|
||||
let nonce = XNonce::from_slice(nonce);
|
||||
|
||||
let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key);
|
||||
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
|
||||
|
||||
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
|
||||
|
||||
let decryption_result = key_buffer.write_inline(|write_handle| {
|
||||
cipher.decrypt_in_place(nonce, associated_data, write_handle)
|
||||
});
|
||||
|
||||
match decryption_result {
|
||||
Ok(_) => Ok(key_buffer),
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to decrypt encrypted key material");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UnsealStartResponse {
|
||||
pub server_pubkey: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UnsealError {
|
||||
#[error("Invalid key provided for unsealing")]
|
||||
InvalidKey,
|
||||
#[error("Internal error during unsealing process")]
|
||||
General(#[from] super::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error("Invalid key provided for bootstrapping")]
|
||||
InvalidKey,
|
||||
#[error("Vault is already bootstrapped")]
|
||||
AlreadyBootstrapped,
|
||||
|
||||
#[error("Internal error during bootstrapping process")]
|
||||
General(#[from] super::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SignTransactionError {
|
||||
#[error("Policy evaluation failed")]
|
||||
Vet(#[from] crate::evm::VetError),
|
||||
|
||||
#[error("Internal signing error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GrantMutationError {
|
||||
#[error("Vault is sealed")]
|
||||
VaultSealed,
|
||||
|
||||
#[error("Internal grant mutation error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub async fn handle_unseal_request(
|
||||
&mut self,
|
||||
client_pubkey: x25519_dalek::PublicKey,
|
||||
) -> Result<UnsealStartResponse, Error> {
|
||||
let secret = EphemeralSecret::random();
|
||||
let public_key = PublicKey::from(&secret);
|
||||
|
||||
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
|
||||
secret: Mutex::new(Some(secret)),
|
||||
client_public_key: client_pubkey,
|
||||
}))?;
|
||||
|
||||
Ok(UnsealStartResponse {
|
||||
server_pubkey: public_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_unseal_encrypted_key(
|
||||
&mut self,
|
||||
nonce: Vec<u8>,
|
||||
ciphertext: Vec<u8>,
|
||||
associated_data: Vec<u8>,
|
||||
) -> Result<(), UnsealError> {
|
||||
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
|
||||
Ok(values) => values,
|
||||
Err(Error::State) => {
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
return Err(UnsealError::InvalidKey);
|
||||
}
|
||||
Err(_err) => {
|
||||
return Err(Error::internal("Failed to take unseal secret").into());
|
||||
}
|
||||
};
|
||||
|
||||
let seal_key_buffer = match Self::decrypt_client_key_material(
|
||||
ephemeral_secret,
|
||||
client_public_key,
|
||||
&nonce,
|
||||
&ciphertext,
|
||||
&associated_data,
|
||||
) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(()) => {
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
return Err(UnsealError::InvalidKey);
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.key_holder
|
||||
.ask(TryUnseal {
|
||||
seal_key_raw: seal_key_buffer,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.backfill_useragent_integrity().await?;
|
||||
info!("Successfully unsealed key with client-provided key");
|
||||
self.transition(UserAgentEvents::ReceivedValidKey)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
Err(UnsealError::InvalidKey)
|
||||
}
|
||||
Err(SendError::HandlerError(err)) => {
|
||||
error!(?err, "Keyholder failed to unseal key");
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
Err(UnsealError::InvalidKey)
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to send unseal request to keyholder");
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
Err(Error::internal("Vault actor error").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_bootstrap_encrypted_key(
|
||||
&mut self,
|
||||
nonce: Vec<u8>,
|
||||
ciphertext: Vec<u8>,
|
||||
associated_data: Vec<u8>,
|
||||
) -> Result<(), BootstrapError> {
|
||||
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
|
||||
Ok(values) => values,
|
||||
Err(Error::State) => {
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
return Err(BootstrapError::InvalidKey);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
let seal_key_buffer = match Self::decrypt_client_key_material(
|
||||
ephemeral_secret,
|
||||
client_public_key,
|
||||
&nonce,
|
||||
&ciphertext,
|
||||
&associated_data,
|
||||
) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(()) => {
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
return Err(BootstrapError::InvalidKey);
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.key_holder
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: seal_key_buffer,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.backfill_useragent_integrity().await?;
|
||||
info!("Successfully bootstrapped vault with client-provided key");
|
||||
self.transition(UserAgentEvents::ReceivedValidKey)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => {
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
Err(BootstrapError::AlreadyBootstrapped)
|
||||
}
|
||||
Err(SendError::HandlerError(err)) => {
|
||||
error!(?err, "Keyholder failed to bootstrap vault");
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
Err(BootstrapError::InvalidKey)
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to send bootstrap request to keyholder");
|
||||
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
|
||||
Err(BootstrapError::General(Error::internal(
|
||||
"Vault actor error",
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
|
||||
use crate::actors::keyholder::GetState;
|
||||
|
||||
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
error!(?err, actor = "useragent", "keyholder.query.failed");
|
||||
return Err(Error::internal("Vault is in broken state"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(vault_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
|
||||
match self.props.actors.evm.ask(Generate {}).await {
|
||||
Ok(address) => Ok(address),
|
||||
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
|
||||
"EVM wallet generation failed: {err}"
|
||||
))),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM actor unreachable during wallet create");
|
||||
Err(Error::internal("EVM actor unreachable"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
|
||||
match self.props.actors.evm.ask(ListWallets {}).await {
|
||||
Ok(wallets) => Ok(wallets),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM wallet list failed");
|
||||
Err(Error::internal("Failed to list EVM wallets"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_list(
|
||||
&mut self,
|
||||
) -> Result<Vec<Grant<SpecificGrant>>, GrantMutationError> {
|
||||
match self.props.actors.evm.ask(UseragentListGrants {}).await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant list failed");
|
||||
Err(GrantMutationError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_create(
|
||||
&mut self,
|
||||
basic: crate::evm::policies::SharedGrantSettings,
|
||||
grant: crate::evm::policies::SpecificGrant,
|
||||
) -> Result<Verified<i32>, GrantMutationError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(UseragentCreateGrant { basic, grant })
|
||||
.await
|
||||
{
|
||||
Ok(grant_id) => Ok(grant_id),
|
||||
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant create failed");
|
||||
Err(GrantMutationError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_delete(
|
||||
&mut self,
|
||||
grant_id: i32,
|
||||
) -> Result<(), GrantMutationError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(UseragentDeleteGrant {
|
||||
_grant_id: grant_id,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant delete failed");
|
||||
Err(GrantMutationError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_sign_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(ClientSignTransaction {
|
||||
client_id,
|
||||
wallet_address,
|
||||
transaction,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(signature) => Ok(signature),
|
||||
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
|
||||
Err(SignTransactionError::Vet(vet_error))
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "EVM sign transaction failed in user-agent session");
|
||||
Err(SignTransactionError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_evm_wallet_access(
|
||||
&mut self,
|
||||
entries: Vec<NewEvmWalletAccess>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
|
||||
for entry in entries {
|
||||
diesel::insert_into(evm_wallet_access::table)
|
||||
.values(&entry)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_revoke_evm_wallet_access(
|
||||
&mut self,
|
||||
entries: Vec<i32>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
for entry in entries {
|
||||
diesel::delete(evm_wallet_access::table)
|
||||
.filter(evm_wallet_access::wallet_id.eq(entry))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_list_wallet_access(
|
||||
&mut self,
|
||||
) -> Result<Vec<EvmWalletAccess>, Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
let access_entries = evm_wallet_access::table
|
||||
.select(EvmWalletAccess::as_select())
|
||||
.load::<_>(&mut conn)
|
||||
.await?;
|
||||
Ok(access_entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message(ctx)]
|
||||
pub(crate) async fn handle_new_client_approve(
|
||||
&mut self,
|
||||
approved: bool,
|
||||
pubkey: ed25519_dalek::VerifyingKey,
|
||||
ctx: &mut Context<Self, Result<(), Error>>,
|
||||
) -> Result<(), Error> {
|
||||
let pending_approval = match self.pending_client_approvals.remove(&pubkey) {
|
||||
Some(approval) => approval,
|
||||
None => {
|
||||
error!("Received client connection response for unknown client");
|
||||
return Err(Error::internal("Unknown client in connection response"));
|
||||
}
|
||||
};
|
||||
|
||||
pending_approval
|
||||
.controller
|
||||
.tell(ClientApprovalAnswer { approved })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
?err,
|
||||
"Failed to send client approval response to controller"
|
||||
);
|
||||
Error::internal("Failed to send client approval response to controller")
|
||||
})?;
|
||||
|
||||
ctx.actor_ref().unlink(&pending_approval.controller).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_sdk_client_list(
|
||||
&mut self,
|
||||
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
|
||||
use crate::db::schema::{client_metadata, program_client};
|
||||
let mut conn = self.props.db.get().await?;
|
||||
|
||||
let clients = program_client::table
|
||||
.inner_join(client_metadata::table)
|
||||
.select((
|
||||
ProgramClient::as_select(),
|
||||
ProgramClientMetadata::as_select(),
|
||||
))
|
||||
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(clients)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
pub struct UnsealContext {
|
||||
pub client_public_key: PublicKey,
|
||||
pub secret: Mutex<Option<EphemeralSecret>>,
|
||||
}
|
||||
|
||||
smlang::statemachine!(
|
||||
name: UserAgent,
|
||||
custom_error: false,
|
||||
transitions: {
|
||||
*Idle + UnsealRequest(UnsealContext) / generate_temp_keypair = WaitingForUnsealKey(UnsealContext),
|
||||
WaitingForUnsealKey(UnsealContext) + ReceivedValidKey = Unsealed,
|
||||
WaitingForUnsealKey(UnsealContext) + ReceivedInvalidKey = Idle,
|
||||
}
|
||||
);
|
||||
|
||||
pub struct DummyContext;
|
||||
impl UserAgentStateMachineContext for DummyContext {
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> {
|
||||
Ok(event_data)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
use super::UserAgentActor;
|
||||
use arbiter_proto::proto::{
|
||||
UserAgentRequest, UserAgentResponse,
|
||||
auth::{
|
||||
self, AuthChallenge, AuthChallengeRequest, AuthOk, ClientMessage,
|
||||
ServerMessage as AuthServerMessage, client_message::Payload as ClientAuthPayload,
|
||||
server_message::Payload as ServerAuthPayload,
|
||||
},
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use kameo::{
|
||||
actor::{ActorRef, Spawn as _},
|
||||
error::SendError,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tonic::Status;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
actors::user_agent::{HandleAuthChallengeRequest, HandleAuthChallengeSolution},
|
||||
context::ServerContext,
|
||||
};
|
||||
|
||||
pub(crate) async fn handle_user_agent(
|
||||
context: ServerContext,
|
||||
mut req_stream: tonic::Streaming<UserAgentRequest>,
|
||||
tx: mpsc::Sender<Result<UserAgentResponse, Status>>,
|
||||
) {
|
||||
let actor = UserAgentActor::spawn(UserAgentActor::new(context, tx.clone()));
|
||||
|
||||
while let Some(Ok(req)) = req_stream.next().await
|
||||
&& actor.is_alive()
|
||||
{
|
||||
match process_message(&actor, req).await {
|
||||
Ok(resp) => {
|
||||
if tx.send(Ok(resp)).await.is_err() {
|
||||
error!(actor = "useragent", "Failed to send response to client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(status) => {
|
||||
let _ = tx.send(Err(status)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor.kill();
|
||||
}
|
||||
|
||||
async fn process_message(
|
||||
actor: &ActorRef<UserAgentActor>,
|
||||
req: UserAgentRequest,
|
||||
) -> Result<UserAgentResponse, Status> {
|
||||
let msg = req.payload.ok_or_else(|| {
|
||||
error!(actor = "useragent", "Received message with no payload");
|
||||
Status::invalid_argument("Expected message with payload")
|
||||
})?;
|
||||
|
||||
let UserAgentRequestPayload::AuthMessage(ClientMessage {
|
||||
payload: Some(client_message),
|
||||
}) = msg
|
||||
else {
|
||||
error!(
|
||||
actor = "useragent",
|
||||
"Received unexpected message type during authentication"
|
||||
);
|
||||
return Err(Status::invalid_argument(
|
||||
"Expected AuthMessage with ClientMessage payload",
|
||||
));
|
||||
};
|
||||
|
||||
match client_message {
|
||||
ClientAuthPayload::AuthChallengeRequest(req) => actor
|
||||
.ask(HandleAuthChallengeRequest { req })
|
||||
.await
|
||||
.map_err(into_status),
|
||||
ClientAuthPayload::AuthChallengeSolution(solution) => actor
|
||||
.ask(HandleAuthChallengeSolution { solution })
|
||||
.await
|
||||
.map_err(into_status),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_status<M>(e: SendError<M, Status>) -> Status {
|
||||
match e {
|
||||
SendError::HandlerError(status) => status,
|
||||
_ => {
|
||||
error!(actor = "useragent", "Failed to send message to actor");
|
||||
Status::internal("session failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use diesel::OptionalExtension as _;
|
||||
use diesel_async::RunQueryDsl as _;
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use kameo::actor::{ActorRef, Spawn};
|
||||
use miette::Diagnostic;
|
||||
use rand::rngs::StdRng;
|
||||
use smlang::statemachine;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
context::{
|
||||
bootstrap::{BootstrapActor, generate_token},
|
||||
lease::LeaseHandler,
|
||||
tls::{TlsDataRaw, TlsManager},
|
||||
},
|
||||
db::{
|
||||
self,
|
||||
models::ArbiterSetting,
|
||||
schema::{self, arbiter_settings},
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) mod bootstrap;
|
||||
pub(crate) mod lease;
|
||||
pub(crate) mod tls;
|
||||
|
||||
#[derive(Error, Debug, Diagnostic)]
|
||||
pub enum InitError {
|
||||
#[error("Database setup failed: {0}")]
|
||||
#[diagnostic(code(arbiter_server::init::database_setup))]
|
||||
DatabaseSetup(#[from] db::DatabaseSetupError),
|
||||
|
||||
#[error("Connection acquire failed: {0}")]
|
||||
#[diagnostic(code(arbiter_server::init::database_pool))]
|
||||
DatabasePool(#[from] db::PoolError),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::init::database_query))]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
|
||||
#[error("TLS initialization failed: {0}")]
|
||||
#[diagnostic(code(arbiter_server::init::tls_init))]
|
||||
Tls(#[from] tls::TlsInitError),
|
||||
|
||||
#[error("Bootstrap token generation failed: {0}")]
|
||||
#[diagnostic(code(arbiter_server::init::bootstrap_token))]
|
||||
BootstrapToken(#[from] bootstrap::BootstrapError),
|
||||
|
||||
#[error("I/O Error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::init::io))]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
// TODO: Placeholder for secure root key cell implementation
|
||||
pub struct KeyStorage;
|
||||
|
||||
statemachine! {
|
||||
name: Server,
|
||||
transitions: {
|
||||
*NotBootstrapped + Bootstrapped = Sealed,
|
||||
Sealed + Unsealed(KeyStorage) / move_key = Ready(KeyStorage),
|
||||
Ready(KeyStorage) + Sealed / dispose_key = Sealed,
|
||||
}
|
||||
}
|
||||
pub struct _Context;
|
||||
impl ServerStateMachineContext for _Context {
|
||||
fn move_key(&mut self, _event_data: KeyStorage) -> Result<KeyStorage, ()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
fn dispose_key(&mut self, _state_data: &KeyStorage) -> Result<(), ()> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct _ServerContextInner {
|
||||
pub db: db::DatabasePool,
|
||||
pub state: RwLock<ServerStateMachine<_Context>>,
|
||||
pub rng: StdRng,
|
||||
pub tls: TlsManager,
|
||||
pub bootstrapper: ActorRef<BootstrapActor>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ServerContext(Arc<_ServerContextInner>);
|
||||
|
||||
impl std::ops::Deref for ServerContext {
|
||||
type Target = _ServerContextInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerContext {
|
||||
async fn load_tls(
|
||||
db: &mut db::DatabaseConnection,
|
||||
settings: Option<&ArbiterSetting>,
|
||||
) -> Result<TlsManager, InitError> {
|
||||
match &settings {
|
||||
Some(settings) => {
|
||||
let tls_data_raw = TlsDataRaw {
|
||||
cert: settings.cert.clone(),
|
||||
key: settings.cert_key.clone(),
|
||||
};
|
||||
|
||||
Ok(TlsManager::new(Some(tls_data_raw)).await?)
|
||||
}
|
||||
None => {
|
||||
let tls = TlsManager::new(None).await?;
|
||||
let tls_data_raw = tls.bytes();
|
||||
|
||||
diesel::insert_into(arbiter_settings::table)
|
||||
.values(&ArbiterSetting {
|
||||
id: 1,
|
||||
root_key_id: None,
|
||||
cert_key: tls_data_raw.key,
|
||||
cert: tls_data_raw.cert,
|
||||
})
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(tls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
|
||||
let mut conn = db.get().await?;
|
||||
let rng = rand::make_rng();
|
||||
|
||||
let settings = arbiter_settings::table
|
||||
.first::<ArbiterSetting>(&mut conn)
|
||||
.await
|
||||
.optional()?;
|
||||
|
||||
let tls = Self::load_tls(&mut conn, settings.as_ref()).await?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
let mut state = ServerStateMachine::new(_Context);
|
||||
|
||||
if let Some(settings) = &settings
|
||||
&& settings.root_key_id.is_some()
|
||||
{
|
||||
// TODO: pass the encrypted root key to the state machine and let it handle decryption and transition to Sealed
|
||||
let _ = state.process_event(ServerEvents::Bootstrapped);
|
||||
}
|
||||
|
||||
Ok(Self(Arc::new(_ServerContextInner {
|
||||
bootstrapper: BootstrapActor::spawn(BootstrapActor::new(&db).await?),
|
||||
db,
|
||||
rng,
|
||||
tls,
|
||||
state: RwLock::new(state),
|
||||
})))
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
use arbiter_proto::{BOOTSTRAP_TOKEN_PATH, home_path};
|
||||
use diesel::{ExpressionMethods, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, messages};
|
||||
use memsafe::MemSafe;
|
||||
use miette::Diagnostic;
|
||||
use rand::{RngExt, distr::StandardUniform, make_rng, rngs::StdRng};
|
||||
use secrecy::SecretString;
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::{
|
||||
context::{self, ServerContext},
|
||||
db::{self, DatabasePool, schema},
|
||||
};
|
||||
|
||||
const TOKEN_LENGTH: usize = 64;
|
||||
|
||||
pub async fn generate_token() -> Result<String, std::io::Error> {
|
||||
let rng: StdRng = make_rng();
|
||||
|
||||
let token: String = rng
|
||||
.sample_iter::<char, _>(StandardUniform)
|
||||
.take(TOKEN_LENGTH)
|
||||
.fold(Default::default(), |mut accum, char| {
|
||||
accum += char.to_string().as_str();
|
||||
accum
|
||||
});
|
||||
|
||||
tokio::fs::write(home_path()?.join(BOOTSTRAP_TOKEN_PATH), token.as_str()).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Diagnostic)]
|
||||
pub enum BootstrapError {
|
||||
#[error("Database error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::bootstrap::database))]
|
||||
Database(#[from] db::PoolError),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::bootstrap::database_query))]
|
||||
Query(#[from] diesel::result::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::bootstrap::io))]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct BootstrapActor {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl BootstrapActor {
|
||||
pub async fn new(db: &DatabasePool) -> Result<Self, BootstrapError> {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
let row_count: i64 = schema::useragent_client::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
let token = if row_count == 0 {
|
||||
let token = generate_token().await?;
|
||||
info!(%token, "Generated bootstrap token");
|
||||
tokio::fs::write(home_path()?.join(BOOTSTRAP_TOKEN_PATH), token.as_str()).await?;
|
||||
Some(token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self { token })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get_token(&self) -> Option<String> {
|
||||
self.token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl BootstrapActor {
|
||||
#[message]
|
||||
pub fn is_correct_token(&self, token: String) -> bool {
|
||||
match &self.token {
|
||||
Some(expected) => *expected == token,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn consume_token(&mut self, token: String) -> bool {
|
||||
if self.is_correct_token(token) {
|
||||
self.token = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashSet;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LeaseStorage<T: Eq + std::hash::Hash>(Arc<DashSet<T>>);
|
||||
|
||||
// A lease that automatically releases the item when dropped
|
||||
pub struct Lease<T: Clone + std::hash::Hash + Eq> {
|
||||
item: T,
|
||||
storage: LeaseStorage<T>,
|
||||
}
|
||||
impl<T: Clone + std::hash::Hash + Eq> Drop for Lease<T> {
|
||||
fn drop(&mut self) {
|
||||
self.storage.0.remove(&self.item);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LeaseHandler<T: Clone + std::hash::Hash + Eq> {
|
||||
storage: LeaseStorage<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone + std::hash::Hash + Eq> LeaseHandler<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
storage: LeaseStorage(Arc::new(DashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acquire(&self, item: T) -> Result<Lease<T>, ()> {
|
||||
if self.storage.0.insert(item.clone()) {
|
||||
Ok(Lease {
|
||||
item,
|
||||
storage: self.storage.clone(),
|
||||
})
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server/crates/arbiter-server/src/context/mod.rs
Normal file
58
server/crates/arbiter-server/src/context/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
actors::GlobalActors,
|
||||
context::tls::TlsManager,
|
||||
db::{self},
|
||||
};
|
||||
|
||||
pub mod tls;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InitError {
|
||||
#[error("Database setup failed: {0}")]
|
||||
DatabaseSetup(#[from] db::DatabaseSetupError),
|
||||
|
||||
#[error("Connection acquire failed: {0}")]
|
||||
DatabasePool(#[from] db::PoolError),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
|
||||
#[error("TLS initialization failed: {0}")]
|
||||
Tls(#[from] tls::InitError),
|
||||
|
||||
#[error("Actor spawn failed: {0}")]
|
||||
ActorSpawn(#[from] crate::actors::SpawnError),
|
||||
|
||||
#[error("I/O Error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub struct _ServerContextInner {
|
||||
pub db: db::DatabasePool,
|
||||
pub tls: TlsManager,
|
||||
pub actors: GlobalActors,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct ServerContext(Arc<_ServerContextInner>);
|
||||
|
||||
impl std::ops::Deref for ServerContext {
|
||||
type Target = _ServerContextInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerContext {
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
|
||||
Ok(Self(Arc::new(_ServerContextInner {
|
||||
actors: GlobalActors::spawn(db.clone()).await?,
|
||||
tls: TlsManager::new(db.clone()).await?,
|
||||
db,
|
||||
})))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user