Compare commits

..

64 Commits

Author SHA1 Message Date
Skipper
9cf87b2058 merge: refactor-integrity-check into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:46:28 +02:00
Skipper
929d50b589 housekeeping(server): clean too-broad visibility markers and organize imports
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:30:09 +02:00
Skipper
70acfc99b5 merge: refactor-integrity-check into main 2026-04-18 13:19:13 +02:00
28f84d03ab Merge pull request 'housekeeping(server): dependencies upgrade' (#89) from push-zmvtzuwrnyyv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #89
2026-04-17 19:20:50 +00:00
Skipper
4a8e51ef32 docs: updated to new auth challenge format and removed stale TOCTOU race condition note
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-17 18:25:55 +02:00
Skipper
9ee86afc19 fix(useragent): now using new challenge format 2026-04-17 18:19:51 +02:00
Skipper
790026e93b fix(server::tests): api surface of auth challenge changed 2026-04-17 17:58:22 +02:00
Skipper
0e09afda5d refactor(server::{useragent::auth, client::auth}): use random based + timestamp nonce instead of monotonic counter in database 2026-04-17 17:44:42 +02:00
Skipper
51e6571d80 refactor(server): now keeps track of useragents, instead of 2026-04-17 00:00:43 +02:00
Skipper
3b828d5874 refactor(server::grpc::vault_gate): standard approach using / traits 2026-04-16 22:15:18 +02:00
Skipper
a6f94e3115 fix(server): sending fixed vault state when on stage 2026-04-16 19:36:41 +02:00
hdbg
f49e995c2f WIP: kameo::messages wiring for transport generalization
Some checks failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-16 17:18:46 +02:00
Skipper
e88df432fb housekeeping(server): dependencies upgrade
Some checks failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-14 19:10:07 +02:00
hdbg
87ee0fe87b feat(user-agent): add VaultGate for sealed vault authentication 2026-04-12 11:53:05 +02:00
hdbg
205227a3df fix(server::integrity): vault now differentias between expected/unexpected states for commands more granularly 2026-04-08 18:21:48 +02:00
hdbg
a4070e7df7 fix(useragent): unsafe, but working implementation of ml-dsa 2026-04-08 17:43:51 +02:00
hdbg
6b8da567dd fix(server::user_agent): useragents now self-sign themselves on bootstrap 2026-04-08 17:40:45 +02:00
hdbg
1585f90cae refactor(server): reorganized client/user_agent actors into separate module peers and added event MessageBus 2026-04-08 12:34:16 +02:00
62dff3f810 Merge pull request 'refactor(hashing): introduce Hashable derive macro and migrate server types' (#82) from hashing-proc-macro into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #82
Reviewed-by: Stas <business@jexter.tech>
2026-04-08 00:18:40 +00:00
CleverWild
6e22f368c9 refactor(hashing): introduce Hashable derive macro and migrate server types
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-08 01:32:59 +02:00
f3cf6a9438 Merge pull request 'Post-quantum crypto and better useragent security' (#80) from push-xrxykvkuxpsv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #80
2026-04-07 19:26:54 +00:00
hdbg
a9f9fc2a9d housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 16:28:47 +02:00
hdbg
d22ab49e3d refactor(server): moved shared module crypto into arbiter-crypto 2026-04-07 16:24:51 +02:00
hdbg
a845181ef6 docs: ml-dsa scheme everywhere
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 15:02:32 +02:00
hdbg
0d424f3afc refactor(server): migrated auth to ml-dsa 2026-04-07 14:55:31 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
hdbg
00745bb381 tests(server): fixed for new integrity checks
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 14:49:02 +02:00
hdbg
b122aa464c refactor(server): rework envelopes and integrity check
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-05 14:17:00 +02:00
hdbg
9fab945a00 fix(server): remove stale mentions of miette
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:45:24 +02:00
CleverWild
aeed664e9a chore: inline integrity proto types
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:44:21 +02:00
CleverWild
4057c1fc12 feat(server): integrity envelope engine for EVM grants with HMAC verification 2026-04-05 10:44:21 +02:00
hdbg
f5eb51978d docs: add recovery operators and multi-operator details 2026-04-05 08:27:24 +00:00
hdbg
d997e0f843 docs: add multi-operator governance section 2026-04-05 08:27:24 +00:00
hdbg
7aca281a81 merge: @main into client-integrity-verification
Some checks failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/useragent-analyze Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
2026-04-05 10:25:46 +02:00
hdbg
01b12515bd housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:33:48 +02:00
hdbg
4a50daa7ea refactor(user-agent): remove backfill pubkey integrity tags
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:32:00 +02:00
hdbg
352ee3ee63 fix(server): previously, user agent auth accepted invalid signatures
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:28:07 +02:00
hdbg
dd51d756da refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-04-04 14:21:52 +02:00
CleverWild
0bb6e596ac feat(auth): implement attestation status verification for public keys
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 12:10:45 +02:00
CleverWild
881f16bb1a fix(keyholder): comment drift 2026-04-04 12:02:50 +02:00
CleverWild
78895bca5b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-04-04 12:00:39 +02:00
CleverWild
a02ef68a70 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-03-30 00:17:04 +02:00
hdbg
e5be55e141 style(dashboard): format code and add title margin
Some checks failed
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-29 10:54:02 +02:00
hdbg
8f0eb7130b feat(grants-create): add configurable grant authorization fields 2026-03-29 00:37:58 +01:00
hdbg
94fe04a6a4 refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-03-29 00:37:58 +01:00
hdbg
976c11902c fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-03-29 00:37:58 +01:00
hdbg
c8d2662a36 refactor(grants): wrap grant list in SingleChildScrollView 2026-03-29 00:37:58 +01:00
hdbg
ac5fedddd1 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-03-29 00:37:58 +01:00
hdbg
0c2d4986a2 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-03-29 00:37:58 +01:00
hdbg
a3203936d2 feat(evm): add EVM grants screen with create UI and list 2026-03-29 00:37:58 +01:00
hdbg
fb1c0ec130 refactor(proto): restructure wallet access messages for improved data organization 2026-03-29 00:37:58 +01:00
hdbg
2a21758369 refactor(server::evm): removed repetetive errors and error variants 2026-03-29 00:37:58 +01:00
hdbg
1abb5fa006 refactor(useragent::evm::table): broke down into more widgets 2026-03-29 00:37:58 +01:00
hdbg
e1b1c857fa refactor(useragent::evm): moved out header into general widget 2026-03-29 00:37:58 +01:00
hdbg
4216007af3 feat(useragent): vibe-coded access list 2026-03-29 00:37:58 +01:00
110 changed files with 5447 additions and 3819 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

129
CLAUDE.md
View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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
```

View 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 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.vault.unseal; package arbiter.user_agent.vault.unseal;
message UnsealStart { message UnsealStart {
bytes client_pubkey = 1; bytes client_pubkey = 1;
@@ -20,7 +20,6 @@ enum UnsealResult {
UNSEAL_RESULT_SUCCESS = 1; UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2; UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3; UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
UNSEAL_RESULT_LOCKED_OUT = 4;
} }
message Request { message Request {

View File

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

View File

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

2457
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -20,10 +20,8 @@ tonic.features = ["tls-aws-lc"]
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
thiserror.workspace = true thiserror.workspace = true
http = "1.5.0" http = "1.4.0"
rustls-webpki = { version = "0.103.15", features = ["aws-lc-rs"] } rustls-webpki = { version = "0.103.12", features = ["aws-lc-rs"] }
async-trait.workspace = true async-trait.workspace = true
rand.workspace = true
chrono.workspace = true chrono.workspace = true
[lib]
doctest = false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,16 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use hmac::digest::Digest; use hmac::digest::Digest;
use ml_dsa::{ use ml_dsa::{
EncodedVerifyingKey, Error, ExpandedSigningKey, Generate, MlDsa87, Seed, EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
Signature as MlDsaSignature, SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
}; };
use rand::RngExt; use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
const NONCE_SIZE: usize = 32; const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidLength {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthChallenge { pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE], pub nonce: [u8; NONCE_SIZE],
@@ -50,12 +43,9 @@ impl AuthChallenge {
} }
} }
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> { pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, ()> {
let random_nonce = nonce.as_array().ok_or(InvalidLength { let random_nonce = nonce.as_array().ok_or(())?;
expected: NONCE_SIZE, Ok(AuthChallenge {
actual: nonce.len(),
})?;
Ok(Self {
nonce: *random_nonce, nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp), timestamp: DateTime::from_timestamp_nanos(timestamp),
}) })
@@ -77,17 +67,13 @@ impl crate::hashing::Hashable for PublicKey {
pub struct Signature(Box<MlDsaSignature<KeyParams>>); pub struct Signature(Box<MlDsaSignature<KeyParams>>);
#[derive(Debug)] #[derive(Debug)]
pub struct SigningKey { pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
key: Box<ExpandedSigningKey<KeyParams>>,
seed: Seed,
}
impl PublicKey { impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> { pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec() self.0.encode().0.to_vec()
} }
#[must_use]
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool { pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
let challenge = challenge.format(); let challenge = challenge.format();
self.0 self.0
@@ -103,31 +89,24 @@ impl Signature {
impl SigningKey { impl SigningKey {
pub fn generate() -> Self { pub fn generate() -> Self {
let seed = MlDsaSigningKey::<KeyParams>::generate_from_rng(&mut rand::rng()).to_seed(); Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
Self {
key: Box::new(ExpandedSigningKey::from_seed(&seed)),
seed,
}
} }
pub fn from_seed(seed: [u8; 32]) -> Self { pub fn from_seed(seed: [u8; 32]) -> Self {
let seed = Seed::from(seed); Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
Self {
key: Box::new(ExpandedSigningKey::from_seed(&seed)),
seed,
}
} }
pub fn to_seed(&self) -> [u8; 32] { pub fn to_seed(&self) -> [u8; 32] {
self.seed.into() self.0.to_seed().into()
} }
pub fn public_key(&self) -> PublicKey { pub fn public_key(&self) -> PublicKey {
self.key.verifying_key().into() self.0.verifying_key().into()
} }
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> { pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
self.key self.0
.signing_key()
.sign_deterministic(message, context) .sign_deterministic(message, context)
.map(Into::into) .map(Into::into)
} }
@@ -155,6 +134,12 @@ impl From<MlDsaSignature<KeyParams>> for Signature {
} }
} }
impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl TryFrom<Vec<u8>> for PublicKey { impl TryFrom<Vec<u8>> for PublicKey {
type Error = (); type Error = ();
@@ -192,15 +177,15 @@ impl TryFrom<&'_ [u8]> for Signature {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use ml_dsa::{Generate as _, MlDsa87, SigningKey as RealSigningKey, signature::Keypair as _}; use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
use crate::authn::AuthChallenge; use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, OPERATOR_CONTEXT, PublicKey, Signature, SigningKey}; use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
#[test] #[test]
fn public_key_round_trip_decodes() { fn public_key_round_trip_decodes() {
let key = RealSigningKey::<MlDsa87>::generate(); let key = MlDsa87::key_gen(&mut rand::rng());
let encoded = PublicKey::from(key.verifying_key()).to_bytes(); let encoded = PublicKey::from(key.verifying_key()).to_bytes();
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode"); let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
@@ -231,7 +216,7 @@ mod tests {
.expect("signature should be created"); .expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature)); assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
} }
#[test] #[test]

View File

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

View File

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

View File

@@ -5,12 +5,11 @@ edition = "2024"
[lib] [lib]
proc-macro = true proc-macro = true
doctest = false
[dependencies] [dependencies]
proc-macro2 = "1.0" proc-macro2 = "1.0"
quote = "1.0" quote = "1.0"
syn = { version = "3.0", features = ["derive", "fold", "full", "visit-mut"] } syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] }
[dev-dependencies] [dev-dependencies]
arbiter-crypto = { path = "../arbiter-crypto" } arbiter-crypto = { path = "../arbiter-crypto" }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,48 +1,29 @@
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, rngs::SysRng}; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use rand_core::UnwrapErr;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _; use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use tracing::warn;
const TOKEN_LENGTH: usize = 64; const TOKEN_LENGTH: usize = 64;
async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> { pub async fn generate_token() -> Result<String, std::io::Error> {
tokio::fs::write(path, content.as_bytes()).await?; let rng: StdRng = make_rng();
#[cfg(unix)] let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
{ Default::default(),
use std::os::unix::fs::PermissionsExt as _; |mut accum, char| {
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; accum += char.to_string().as_str();
} accum
},
);
Ok(()) tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
}
async fn generate_token(path: &Path) -> Result<SafeCell<[u8; TOKEN_LENGTH]>, std::io::Error> { Ok(token)
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
{
let mut buf = cell.write();
for (slot, b) in buf
.iter_mut()
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
{
*slot = b;
}
}
let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
write_token_file(path, &token_str).await?;
Ok(cell)
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@@ -50,17 +31,16 @@ pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] db::PoolError), Database(#[from] db::PoolError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database query error: {0}")] #[error("Database query error: {0}")]
Query(#[from] diesel::result::Error), Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
} }
#[derive(Actor)] #[derive(Actor)]
pub struct Bootstrapper { pub struct Bootstrapper {
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>, token: Option<String>,
token_path: Option<PathBuf>,
} }
impl Bootstrapper { impl Bootstrapper {
@@ -68,43 +48,43 @@ impl Bootstrapper {
let row_count: i64 = { let row_count: i64 = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
schema::operator::table schema::useragent_client::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await? .await?
}; };
let (token, token_path) = if row_count == 0 { let token = if row_count == 0 {
let path = home_path()?.join(BOOTSTRAP_PATH); let token = generate_token().await?;
let token = generate_token(&path).await?; Some(token)
(Some(token), Some(path))
} else { } else {
(None, None) None
}; };
Ok(Self { token, token_path }) Ok(Self { token })
}
}
impl Bootstrapper {
fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token)))
})
} }
} }
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub async fn consume_token(&mut self, token: Vec<u8>) -> bool { pub fn is_correct_token(&self, token: String) -> bool {
if self.is_correct_token(&token) { match &self.token {
self.token = None; Some(expected) => {
if let Some(path) = self.token_path.take() let expected_bytes = expected.as_bytes();
&& let Err(e) = tokio::fs::remove_file(&path).await let token_bytes = token.as_bytes();
{
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption"); 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 true
} else { } else {
false false
@@ -115,9 +95,7 @@ impl Bootstrapper {
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn get_token(&mut self) -> Option<String> { pub fn get_token(&self) -> Option<String> {
self.token self.token.clone()
.as_mut()
.map(|cell| cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()))
} }
} }

View File

@@ -1,9 +1,9 @@
use crate::{ use crate::{
actors::vault::{CreateNew, Decrypt, Vault}, actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity::{self, Integrable}, crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, EvmWalletId}, models::{self},
schema, schema,
}, },
evm::{ evm::{
@@ -16,44 +16,21 @@ use crate::{
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
};
use diesel::{ use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
}; };
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use tracing::error;
pub use crate::evm::safe_signer; pub use crate::evm::safe_signer;
/// Integrity guard that binds a wallet's encrypted key ID to its Ethereum address.
/// Both fields are included in the HMAC — swapping `aead_encrypted_id` in the DB
/// invalidates the envelope MAC, and the AEAD ciphertext is also bound to `address`
/// as AAD, so decryption fails too.
#[derive(arbiter_macros::Hashable)]
struct EvmWalletIntegrity {
aead_encrypted_id: i32,
address: Address,
}
impl Integrable for EvmWalletIntegrity {
const KIND: &'static str = "evm_wallet";
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SignTransactionError { pub enum SignTransactionError {
#[error("Wallet not found")] #[error("Wallet not found")]
WalletNotFound, WalletNotFound,
#[error("Decrypted key does not match requested wallet address")]
KeyAddressMismatch,
#[error("Internal signing error")]
Internal,
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
@@ -85,12 +62,6 @@ pub enum Error {
Integrity(#[from] integrity::Error), Integrity(#[from] integrity::Error),
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}
#[derive(Actor)] #[derive(Actor)]
pub struct EvmActor { pub struct EvmActor {
pub vault: ActorRef<Vault>, pub vault: ActorRef<Vault>,
@@ -124,45 +95,26 @@ impl EvmActor {
let aead_id: i32 = self let aead_id: i32 = self
.vault .vault
.ask(CreateNew { .ask(CreateNew { plaintext })
plaintext,
aad: address.as_slice().to_vec(),
})
.await .await
.map_err(|_| Error::VaultSend)?; .map_err(|_| Error::VaultSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = conn let wallet_id = insert_into(schema::evm_wallet::table)
.exclusive_transaction(async |conn| {
let wallet_id: i32 = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet { .values(&models::NewEvmWallet {
address: address.as_slice().to_vec(), address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id, aead_encrypted_id: aead_id,
}) })
.returning(schema::evm_wallet::id) .returning(schema::evm_wallet::id)
.get_result(conn) .get_result(&mut conn)
.await .await
.map_err(DatabaseError::from) .map_err(DatabaseError::from)?;
.map_err(Error::Database)?;
integrity::sign_entity(
conn,
&self.vault,
&EvmWalletIntegrity { address, aead_encrypted_id: aead_id },
wallet_id,
)
.await
.map_err(Error::Integrity)?;
Ok::<i32, Error>(wallet_id)
})
.await?;
Ok((wallet_id, address)) Ok((wallet_id, address))
} }
#[message] #[message]
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> { pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select()) .select(models::EvmWallet::as_select())
@@ -180,7 +132,7 @@ impl EvmActor {
#[messages] #[messages]
impl EvmActor { impl EvmActor {
#[message] #[message]
pub async fn operator_create_grant( pub async fn useragent_create_grant(
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
@@ -206,18 +158,32 @@ impl EvmActor {
} }
#[message] #[message]
pub async fn useragent_delete_grant( pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
&mut self, // let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
grant_id: i32, // let vault = self.vault.clone();
) -> Result<(), Error> {
self.engine // diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
.revoke_grant(grant_id) // Box::pin(async move {
.await // diesel::update(schema::evm_basic_grant::table)
.map_err(Error::from) // .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] #[message]
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await { match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants), Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)), Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
@@ -287,55 +253,21 @@ impl EvmActor {
.ok_or(SignTransactionError::WalletNotFound)?; .ok_or(SignTransactionError::WalletNotFound)?;
drop(conn); drop(conn);
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let attestation = integrity::verify_entity(
&mut conn,
&self.vault,
&EvmWalletIntegrity {
address: wallet_address,
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id.to_raw(),
)
.await
.map_err(|e| {
error!(?e, ?wallet.id, "EVM wallet integrity check failed");
SignTransactionError::Internal
})?;
drop(conn);
if attestation != integrity::AttestationStatus::Attested {
error!(
?wallet.id,
"EVM wallet integrity unavailable; refusing to sign"
);
return Err(SignTransactionError::Internal);
}
let raw_key: SafeCell<Vec<u8>> = self let raw_key: SafeCell<Vec<u8>> = self
.vault .vault
.ask(Decrypt { .ask(Decrypt {
aead_id: wallet.aead_encrypted_id, aead_id: wallet.aead_encrypted_id,
aad: wallet.address.clone(),
}) })
.await .await
.map_err(|_| SignTransactionError::VaultSend)?; .map_err(|_| SignTransactionError::VaultSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?; let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
if signer.address() != wallet_address {
error!(
expected = %wallet_address,
actual = %signer.address(),
"Decrypted private key address does not match requested wallet"
);
return Err(SignTransactionError::KeyAddressMismatch);
}
self.engine self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution) .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?; .await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?) Ok(signer.sign_transaction_sync(&mut transaction)?)
} }
} }

View File

@@ -2,7 +2,7 @@ use crate::{
actors::flow_coordinator::ApprovalError, actors::flow_coordinator::ApprovalError,
peers::{ peers::{
client::ClientProfile, client::ClientProfile,
operator::{OperatorSession, session::BeginNewClientApproval}, user_agent::{UserAgentSession, session::BeginNewClientApproval},
}, },
}; };
@@ -11,18 +11,16 @@ use kameo::{
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef}, prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender, reply::ReplySender,
}; };
use std::{ops::ControlFlow, time::Duration}; use std::ops::ControlFlow;
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
pub operators: Vec<ActorRef<OperatorSession>>, pub user_agents: Vec<ActorRef<UserAgentSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>, pub reply: ReplySender<Result<bool, ApprovalError>>,
} }
pub struct ClientApprovalController { pub struct ClientApprovalController {
/// Number of operators that have not yet responded (approval or denial) or died. /// Number of UAs that have not yet responded (approval or denial) or died.
pending: usize, pending: usize,
/// Number of approvals received so far. /// Number of approvals received so far.
approved: usize, approved: usize,
@@ -44,21 +42,20 @@ impl Actor for ClientApprovalController {
async fn on_start( async fn on_start(
Args { Args {
client, client,
operators, mut user_agents,
reply, reply,
}: Self::Args, }: Self::Args,
actor_ref: ActorRef<Self>, actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let this = Self { let this = Self {
pending: operators.len(), pending: user_agents.len(),
approved: 0, approved: 0,
reply: Some(reply), reply: Some(reply),
}; };
for operator in operators { for user_agent in user_agents.drain(..) {
actor_ref.link(&operator).await; actor_ref.link(&user_agent).await;
let _ = user_agent
let _ = operator
.tell(BeginNewClientApproval { .tell(BeginNewClientApproval {
client: client.clone(), client: client.clone(),
controller: actor_ref.clone(), controller: actor_ref.clone(),
@@ -66,14 +63,6 @@ impl Actor for ClientApprovalController {
.await; .await;
} }
let weak = actor_ref.downgrade();
tokio::spawn(async move {
tokio::time::sleep(APPROVAL_TIMEOUT).await;
if let Some(r) = weak.upgrade() {
let _ = r.tell(OnApprovalTimeout {}).await;
}
});
Ok(this) Ok(this)
} }
@@ -83,10 +72,10 @@ impl Actor for ClientApprovalController {
_: ActorId, _: ActorId,
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
// A linked operator died before responding — counts as a non-approval. // A linked UA died before responding — counts as a non-approval.
self.pending = self.pending.saturating_sub(1); self.pending = self.pending.saturating_sub(1);
if self.pending == 0 { if self.pending == 0 {
// At least one operator didn't approve: deny. // At least one UA didn't approve: deny.
self.send_reply(Ok(false)); self.send_reply(Ok(false));
return Ok(ControlFlow::Break(ActorStopReason::Normal)); return Ok(ControlFlow::Break(ActorStopReason::Normal));
} }
@@ -97,7 +86,7 @@ impl Actor for ClientApprovalController {
#[messages] #[messages]
impl ClientApprovalController { impl ClientApprovalController {
#[message(ctx)] #[message(ctx)]
pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) { pub async fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
if !approved { if !approved {
// Denial wins immediately regardless of other pending responses. // Denial wins immediately regardless of other pending responses.
self.send_reply(Ok(false)); self.send_reply(Ok(false));
@@ -109,19 +98,9 @@ impl ClientApprovalController {
self.pending = self.pending.saturating_sub(1); self.pending = self.pending.saturating_sub(1);
if self.pending == 0 { if self.pending == 0 {
// Every connected operator approved. // Every connected UA approved.
self.send_reply(Ok(true)); self.send_reply(Ok(true));
ctx.stop(); ctx.stop();
} }
} }
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
/// by then is treated as a denial to prevent zombie sessions from blocking the flow.
#[message(ctx)]
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
if self.pending > 0 {
self.send_reply(Ok(false));
ctx.stop();
}
}
} }

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::client_connect_approval::ClientApprovalController, flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::{GetConnected, OperatorRegistry}, useragent_registry::{GetConnected, UserAgentRegistry},
}, },
peers::client::{ClientProfile, session::ClientSession}, peers::client::{ClientProfile, session::ClientSession},
}; };
@@ -20,17 +20,14 @@ pub mod client_connect_approval;
pub struct FlowCoordinator { pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>, pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
/// Maps DB `client_id` → `ActorId` for fast connected-client lookup. useragent_registry: ActorRef<UserAgentRegistry>,
client_ids: HashMap<i32, ActorId>,
operator_registry: ActorRef<OperatorRegistry>,
} }
impl FlowCoordinator { impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self { pub fn new(useragent_registry: ActorRef<UserAgentRegistry>) -> Self {
Self { Self {
clients: HashMap::default(), clients: HashMap::default(),
client_ids: HashMap::default(), useragent_registry,
operator_registry,
} }
} }
} }
@@ -51,7 +48,6 @@ impl Actor for FlowCoordinator {
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() { if self.clients.remove(&id).is_some() {
self.client_ids.retain(|_, actor_id| *actor_id != id);
info!( info!(
?id, ?id,
actor = "FlowCoordinator", actor = "FlowCoordinator",
@@ -70,8 +66,8 @@ impl Actor for FlowCoordinator {
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)] #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError { pub enum ApprovalError {
#[error("No operators connected")] #[error("No user agents connected")]
NoOperatorsConnected, NoUserAgentsConnected,
} }
#[messages] #[messages]
@@ -79,28 +75,14 @@ impl FlowCoordinator {
#[message(ctx)] #[message(ctx)]
pub async fn register_client( pub async fn register_client(
&mut self, &mut self,
client_id: i32,
actor: ActorRef<ClientSession>, actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>, ctx: &mut Context<Self, ()>,
) { ) {
info!(id = %actor.id(), client_id, actor = "FlowCoordinator", event = "client.connected"); info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await; ctx.actor_ref().link(&actor).await;
self.client_ids.insert(client_id, actor.id());
self.clients.insert(actor.id(), actor); self.clients.insert(actor.id(), actor);
} }
#[message]
pub fn is_client_connected(&self, client_id: i32) -> bool {
self.client_ids.contains_key(&client_id)
}
/// Returns the DB `client_ids` of all currently connected SDK clients.
/// Used by operator sessions on startup to seed their approved-client set.
#[message]
pub fn get_connected_client_ids(&self) -> Vec<i32> {
self.client_ids.keys().copied().collect()
}
#[message(ctx)] #[message(ctx)]
pub async fn request_client_approval( pub async fn request_client_approval(
&mut self, &mut self,
@@ -111,19 +93,22 @@ impl FlowCoordinator {
unreachable!("Expected `request_client_approval` to have callback channel"); unreachable!("Expected `request_client_approval` to have callback channel");
}; };
let Ok(refs) = self.operator_registry.ask(GetConnected).await else { let refs = match self.useragent_registry.ask(GetConnected).await {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected)); Ok(refs) => refs,
Err(_) => {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply; return reply;
}
}; };
if refs.is_empty() { if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected)); reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply; return reply;
} }
ClientApprovalController::spawn(client_connect_approval::Args { ClientApprovalController::spawn(client_connect_approval::Args {
client, client,
operators: refs, user_agents: refs,
reply: reply_sender, reply: reply_sender,
}); });

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ use crate::{
}, },
db::{ db::{
self, self,
models::{self, RootKeyHistory, RootKeyHistoryId}, models::{self, RootKeyHistory},
schema::{self}, schema::{self},
}, },
}; };
@@ -22,9 +22,10 @@ use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish}; use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info, warn}; use tracing::{error, info};
pub mod events { pub mod events {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Bootstrapped; pub struct Bootstrapped;
@@ -45,8 +46,6 @@ pub enum Error {
Sealed, Sealed,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("Requested aead entry not found")] #[error("Requested aead entry not found")]
NotFound, NotFound,
@@ -62,13 +61,10 @@ pub enum Error {
#[error("Broken database")] #[error("Broken database")]
BrokenDatabase, BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope:?}, current key is {current:?}")]
KeyVersionMismatch { envelope: RootKeyHistoryId, current: RootKeyHistoryId },
} }
struct Unsealed { struct Unsealed {
root_key_history_id: RootKeyHistoryId, root_key_history_id: i32,
root_key: KeyCell, root_key: KeyCell,
} }
@@ -77,17 +73,13 @@ struct Unsealed {
enum State { enum State {
#[default] #[default]
Unbootstrapped, Unbootstrapped,
Sealed { Sealed {
root_key_history_id: RootKeyHistoryId, root_key_history_id: i32,
}, },
Unsealed(Unsealed), Unsealed(Unsealed),
} }
const MAX_UNSEAL_ATTEMPTS: u32 = 5;
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor. /// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)] #[derive(Actor)]
@@ -95,7 +87,6 @@ pub struct Vault {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>, events: ActorRef<MessageBus>,
unseal_failures: u32,
} }
#[messages] #[messages]
@@ -118,28 +109,26 @@ impl Vault {
} }
}; };
Ok(Self { db, state, events, unseal_failures: 0 }) Ok(Self { db, state, events })
} }
// Exclusive transaction to avoid race condtions if multiple vaults write // Exclusive transaction to avoid race condtions if multiple vaults write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce( async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
pool: &db::DatabasePool,
root_key_id: RootKeyHistoryId,
) -> Result<Nonce, Error> {
let mut conn = pool.get().await?; let mut conn = pool.get().await?;
let nonce = conn let nonce = conn
.exclusive_transaction(async |conn| { .exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id)) .filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce) .select(schema::root_key_history::data_encryption_nonce)
.first(&mut *conn) .first(conn)
.await?; .await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| { let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!( error!(
"Broken database: invalid nonce for root key history id={:#?}", "Broken database: invalid nonce for root key history id={}",
root_key_id root_key_id
); );
Error::BrokenDatabase Error::BrokenDatabase
@@ -149,17 +138,18 @@ impl Vault {
update(schema::root_key_history::table) update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id)) .filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec())) .set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(&mut *conn) .execute(conn)
.await?; .await?;
Result::<_, Error>::Ok(nonce) Result::<_, Error>::Ok(nonce)
}) })
})
.await?; .await?;
Ok(nonce) Ok(nonce)
} }
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> { fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state { match state {
State::Unsealed(unsealed) => Ok(unsealed), State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped), State::Unbootstrapped => Err(Error::NotBootstrapped),
@@ -194,28 +184,28 @@ impl Vault {
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn let root_key_history_id = conn
.transaction(async |conn| { .transaction(|conn| {
let root_key_history_id = insert_into(schema::root_key_history::table) Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory { .values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(), ciphertext: root_key_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(), tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(), root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(), data_encryption_nonce: data_encryption_nonce_bytes,
schema_version: 1, schema_version: 1,
salt: salt.to_vec(), salt: salt.to_vec(),
}) })
.returning(schema::root_key_history::id) .returning(schema::root_key_history::id)
.get_result(&mut *conn) .get_result(conn)
.await?; .await?;
update(schema::arbiter_settings::table) update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id)) .set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut *conn) .execute(conn)
.await?; .await?;
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw( Result::<_, diesel::result::Error>::Ok(root_key_history_id)
root_key_history_id, })
))
}) })
.await?; .await?;
@@ -232,10 +222,6 @@ impl Vault {
#[message] #[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
return Err(Error::LockedOut);
}
let State::Sealed { let State::Sealed {
root_key_history_id, root_key_history_id,
} = &self.state } = &self.state
@@ -262,33 +248,20 @@ impl Vault {
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { |_| {
error!("Broken database: invalid nonce for root key"); error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase Error::BrokenDatabase
},
)?;
seal_key
.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
})?; })?;
if seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.is_err()
{
self.unseal_failures += 1;
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
error!(
attempts = self.unseal_failures,
"Vault locked: maximum failed unseal attempts reached"
);
} else {
warn!(
attempts = self.unseal_failures,
remaining = MAX_UNSEAL_ATTEMPTS - self.unseal_failures,
"Failed unseal attempt"
);
}
return Err(Error::InvalidKey);
}
self.unseal_failures = 0;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| { root_key: KeyCell::try_from(root_key).map_err(|err| {
@@ -303,10 +276,8 @@ impl Vault {
Ok(()) Ok(())
} }
/// Decrypts an AEAD entry. The `aad` must match the value used at encryption time;
/// a mismatch causes authentication failure, preventing cross-wallet key swaps.
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
@@ -320,7 +291,7 @@ impl Vault {
.ok_or(Error::NotFound)? .ok_or(Error::NotFound)?
}; };
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| { let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
error!( error!(
"Broken database: invalid nonce for aead_encrypted id={}", "Broken database: invalid nonce for aead_encrypted id={}",
aead_id aead_id
@@ -328,15 +299,13 @@ impl Vault {
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut output = SafeCell::new(row.ciphertext); let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, &aad, &mut output)?; root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
Ok(output) Ok(output)
} }
/// Creates a new `aead_encrypted` entry and returns its ID.
/// The `aad` is bound into the ciphertext and must be reproduced exactly at decryption time.
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>, aad: Vec<u8>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
@@ -348,7 +317,7 @@ impl Vault {
let mut ciphertext_buffer = plaintext.write(); let mut ciphertext_buffer = plaintext.write();
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut(); let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
root_key.encrypt_in_place(&nonce, &aad, &mut *ciphertext_buffer)?; root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer); let ciphertext = std::mem::take(ciphertext_buffer);
@@ -375,20 +344,19 @@ impl Vault {
} }
#[message] #[message]
pub fn sign_integrity( pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
&mut self,
mac_input: Vec<u8>,
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
let Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?; } = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key.0.read_inline(|k| { let mut hmac = root_key
HmacSha256::new_from_slice(k) .0
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) .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_raw().to_be_bytes()); hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec(); let mac = hmac.finalize().into_bytes().to_vec();
@@ -400,7 +368,7 @@ impl Vault {
&mut self, &mut self,
mac_input: Vec<u8>, mac_input: Vec<u8>,
expected_mac: Vec<u8>, expected_mac: Vec<u8>,
key_version: RootKeyHistoryId, key_version: i32,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
@@ -408,17 +376,16 @@ impl Vault {
} = Self::expect_unsealed(&mut self.state)?; } = Self::expect_unsealed(&mut self.state)?;
if *root_key_history_id != key_version { if *root_key_history_id != key_version {
return Err(Error::KeyVersionMismatch { return Ok(false);
envelope: key_version,
current: *root_key_history_id,
});
} }
let mut hmac = root_key.0.read_inline(|k| { let mut hmac = root_key
HmacSha256::new_from_slice(k) .0
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) .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_raw().to_be_bytes()); hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok()) Ok(hmac.verify_slice(&expected_mac).is_ok())
@@ -441,9 +408,15 @@ impl Vault {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::actors::GlobalActors; use diesel::SelectableHelper;
use crate::db::models::RootKeyHistory;
use arbiter_crypto::safecell::SafeCellHandle as _; use diesel_async::RunQueryDsl;
use crate::{
actors::GlobalActors,
db::{self},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::*; use super::*;
@@ -461,13 +434,12 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
let State::Unsealed(Unsealed { State::Unsealed(Unsealed {
root_key_history_id, root_key_history_id,
.. ..
}) = actor.state }) => root_key_history_id,
else { _ => panic!("expected unsealed state"),
panic!("expected unsealed state")
}; };
let n1 = Vault::get_new_nonce(&db, root_key_history_id) let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -479,15 +451,15 @@ mod tests {
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let root_row: RootKeyHistory = schema::root_key_history::table let root_row: models::RootKeyHistory = schema::root_key_history::table
.select(RootKeyHistory::as_select()) .select(models::RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await .await
.unwrap(); .unwrap();
assert_eq!(root_row.data_encryption_nonce, n2.to_vec()); assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec()), b"test-aad".to_vec()) .create_new(SafeCell::new(b"post-interleave".to_vec()))
.await .await
.unwrap(); .unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table let row: models::AeadEncrypted = schema::aead_encrypted::table

View File

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

View File

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

View File

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

View File

@@ -64,11 +64,6 @@ fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
} }
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) { fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #85"
)]
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes); out.extend_from_slice(bytes);
} }
@@ -120,7 +115,7 @@ pub async fn sign_entity<E: Integrable>(
.ask(SignIntegrity { mac_input }) .ask(SignIntegrity { mac_input })
.await .await
.map_err(|err| match err { .map_err(|err| match err {
SendError::HandlerError(inner) => Error::Vault(inner), kameo::error::SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend, _ => Error::VaultSend,
})?; })?;
@@ -130,7 +125,7 @@ pub async fn sign_entity<E: Integrable>(
entity_id, entity_id,
payload_version: E::VERSION, payload_version: E::VERSION,
key_version, key_version,
mac: mac.clone(), mac: mac.to_vec(),
}) })
.on_conflict(( .on_conflict((
integrity_envelope::entity_id, integrity_envelope::entity_id,
@@ -192,9 +187,7 @@ pub async fn verify_entity<E: Integrable>(
Ok(false) => Err(Error::MacMismatch { Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND, entity_kind: E::KIND,
}), }),
Err(SendError::HandlerError( Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
vault::Error::Sealed | vault::Error::KeyVersionMismatch { .. },
)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend), Err(_) => Err(Error::VaultSend),
} }
} }
@@ -246,12 +239,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn sign_writes_envelope_and_verify_passes() { async fn sign_writes_envelope_and_verify_passes() {
const ENTITY_ID: &[u8] = b"entity-id-7";
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await; let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity { let entity = DummyEntity {
payload_version: 1, payload_version: 1,
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
@@ -277,12 +270,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn tampered_mac_fails_verification() { async fn tampered_mac_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-11";
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await; let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity { let entity = DummyEntity {
payload_version: 1, payload_version: 1,
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
@@ -308,12 +301,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn changed_payload_fails_verification() { async fn changed_payload_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-21";
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await; let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity { let entity = DummyEntity {
payload_version: 1, payload_version: 1,
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
@@ -333,47 +326,4 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. })); assert!(matches!(err, Error::MacMismatch { .. }));
} }
#[tokio::test]
async fn key_version_mismatch_returns_unavailable_not_mac_mismatch() {
use crate::db::schema::integrity_envelope;
use super::AttestationStatus;
const ENTITY_ID: &[u8] = b"entity-id-rotation-test";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
// Simulate key rotation: update the stored key_version to a stale value.
// After real rotation the vault's root_key_history_id would advance, but
// here we achieve the same mismatch by back-dating the envelope's key_version.
diesel::update(integrity_envelope::table)
.filter(integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(integrity_envelope::entity_id.eq(ENTITY_ID))
.set(integrity_envelope::key_version.eq(0))
.execute(&mut conn)
.await
.unwrap();
// Must NOT error — version mismatch is Unavailable, not tampered.
let status = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.expect("key version mismatch must not be treated as an error");
assert_eq!(
status,
AttestationStatus::Unavailable,
"stale key_version must yield Unavailable, not MacMismatch"
);
}
} }

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
#![allow( #![allow(unused)]
clippy::duplicated_attributes, #![allow(clippy::all)]
reason = "restructed's #[view] causes false positives"
)]
use crate::db::schema::{ use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
@@ -9,6 +7,7 @@ use crate::db::schema::{
integrity_envelope, root_key_history, tls_history, integrity_envelope, root_key_history, tls_history,
}; };
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models; use restructed::Models;
@@ -28,16 +27,16 @@ pub mod types {
pub struct SqliteTimestamp(pub DateTime<Utc>); pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp { impl SqliteTimestamp {
pub fn now() -> Self { pub fn now() -> Self {
Self(Utc::now()) SqliteTimestamp(Utc::now())
} }
} }
impl From<DateTime<Utc>> for SqliteTimestamp { impl From<chrono::DateTime<Utc>> for SqliteTimestamp {
fn from(dt: DateTime<Utc>) -> Self { fn from(dt: chrono::DateTime<Utc>) -> Self {
Self(dt) SqliteTimestamp(dt)
} }
} }
impl From<SqliteTimestamp> for DateTime<Utc> { impl From<SqliteTimestamp> for chrono::DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self { fn from(ts: SqliteTimestamp) -> Self {
ts.0 ts.0
} }
@@ -48,11 +47,6 @@ pub mod types {
&'b self, &'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>, out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result { ) -> diesel::serialize::Result {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84; this will break up in 2038 :3"
)]
let unix_timestamp = self.0.timestamp() as i32; let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp); out.set_value(unix_timestamp);
Ok(IsNull::No) Ok(IsNull::No)
@@ -75,72 +69,9 @@ pub mod types {
let datetime = let datetime =
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?; DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
Ok(Self(datetime)) Ok(SqliteTimestamp(datetime))
} }
} }
macro_rules! declare_id {
($name:ident) => {
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)]
#[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct $name(i32);
impl $name {
pub const fn to_raw(self) -> i32 {
self.0
}
pub const fn from_raw(raw: i32) -> Self {
Self(raw)
}
}
impl FromSql<Integer, Sqlite> for $name {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for $name {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
};
}
declare_id!(ChainId);
#[expect(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
)]
const _: () = {
impl From<ChainId> for alloy::primitives::ChainId {
fn from(chain_id: ChainId) -> Self {
chain_id.0 as Self
}
}
impl From<alloy::primitives::ChainId> for ChainId {
fn from(chain_id: alloy::primitives::ChainId) -> Self {
Self(chain_id as _)
}
}
};
declare_id!(OperatorId);
declare_id!(OperatorIdentityId);
declare_id!(AeadEncryptedId);
declare_id!(RootKeyHistoryId);
declare_id!(TlsHistoryId);
declare_id!(EvmWalletId);
declare_id!(ClientId);
} }
pub use types::*; pub use types::*;
@@ -153,12 +84,12 @@ pub use types::*;
)] )]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))] #[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted { pub struct AeadEncrypted {
pub id: AeadEncryptedId, pub id: i32,
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub tag: Vec<u8>, pub tag: Vec<u8>,
pub current_nonce: Vec<u8>, pub current_nonce: Vec<u8>,
pub schema_version: i32, pub schema_version: i32,
pub associated_root_key_id: RootKeyHistoryId, pub associated_root_key_id: i32, // references root_key_history.id
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
@@ -171,7 +102,7 @@ pub struct AeadEncrypted {
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct RootKeyHistory { pub struct RootKeyHistory {
pub id: RootKeyHistoryId, pub id: i32,
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub tag: Vec<u8>, pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>, pub root_key_encryption_nonce: Vec<u8>,
@@ -189,7 +120,7 @@ pub struct RootKeyHistory {
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct TlsHistory { pub struct TlsHistory {
pub id: TlsHistoryId, pub id: i32,
pub cert: String, pub cert: String,
pub cert_key: String, // PEM Encoded private key pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing pub ca_cert: String, // PEM Encoded certificate for cert signing
@@ -214,7 +145,7 @@ pub struct ArbiterSettings {
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmWallet { pub struct EvmWallet {
pub id: EvmWalletId, pub id: i32,
pub address: Vec<u8>, pub address: Vec<u8>,
pub aead_encrypted_id: i32, pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -236,7 +167,7 @@ pub struct EvmWallet {
)] )]
pub struct EvmWalletAccess { pub struct EvmWalletAccess {
pub id: i32, pub id: i32,
pub wallet_id: EvmWalletId, pub wallet_id: i32,
pub client_id: i32, pub client_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
@@ -263,7 +194,7 @@ pub struct ProgramClientMetadataHistory {
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient { pub struct ProgramClient {
pub id: ClientId, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub metadata_id: i32, pub metadata_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -271,24 +202,14 @@ pub struct ProgramClient {
} }
#[derive(Queryable, Debug)] #[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct OperatorClient { pub struct UseragentClient {
pub id: OperatorIdentityId, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
} }
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
pub struct Operator {
pub id: OperatorId,
pub share: Vec<u8>,
pub share_nonce: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))] #[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
#[view( #[view(
@@ -314,7 +235,7 @@ pub struct EvmEtherTransferLimit {
pub struct EvmBasicGrant { pub struct EvmBasicGrant {
pub id: i32, pub id: i32,
pub wallet_access_id: i32, // references evm_wallet_access.id pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: ChainId, pub chain_id: i32,
pub valid_from: Option<SqliteTimestamp>, pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>, pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>, pub max_gas_fee_per_gas: Option<Vec<u8>>,
@@ -337,7 +258,7 @@ pub struct EvmTransactionLog {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain_id: ChainId, pub chain_id: i32,
pub eth_value: Vec<u8>, pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
} }
@@ -412,7 +333,7 @@ pub struct EvmTokenTransferLog {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub log_id: i32, pub log_id: i32,
pub chain_id: ChainId, pub chain_id: i32,
pub token_contract: Vec<u8>, pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>, pub recipient_address: Vec<u8>,
pub value: Vec<u8>, pub value: Vec<u8>,
@@ -432,7 +353,7 @@ pub struct IntegrityEnvelope {
pub entity_kind: String, pub entity_kind: String,
pub entity_id: Vec<u8>, pub entity_id: Vec<u8>,
pub payload_version: i32, pub payload_version: i32,
pub key_version: RootKeyHistoryId, pub key_version: i32,
pub mac: Vec<u8>, pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,

View File

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

View File

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

View File

@@ -1,34 +1,28 @@
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{ use crate::{
actors::vault::Vault, actors::vault::Vault,
crypto::integrity, crypto::integrity,
db::{ db::{
self, DatabaseError, self, DatabaseError,
models::{ models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmEtherTransferLimit, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
}, },
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
}; };
use alloy::{ use alloy::{
consensus::TxEip1559, consensus::TxEip1559,
primitives::{Address, TxKind, U256}, primitives::{TxKind, U256},
}; };
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper, use diesel_async::{AsyncConnection, RunQueryDsl};
insert_into, sqlite::Sqlite, update, use kameo::actor::ActorRef;
};
pub mod abi; pub mod abi;
pub mod safe_signer; pub mod safe_signer;
@@ -40,7 +34,7 @@ mod utils;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum PolicyError { pub enum PolicyError {
#[error("Database error")] #[error("Database error")]
Database(#[from] DatabaseError), Database(#[from] crate::db::DatabaseError),
#[error("Transaction violates policy: {0:?}")] #[error("Transaction violates policy: {0:?}")]
Violations(Vec<EvalViolation>), Violations(Vec<EvalViolation>),
#[error("No matching grant found")] #[error("No matching grant found")]
@@ -72,7 +66,7 @@ pub enum AnalyzeError {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ListError { pub enum ListError {
#[error("Database error")] #[error("Database error")]
Database(#[from] DatabaseError), Database(#[from] crate::db::DatabaseError),
#[error("Integrity verification failed for grant")] #[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error), Integrity(#[from] integrity::Error),
@@ -133,7 +127,7 @@ async fn check_shared_constraints(
.get_result(conn) .get_result(conn)
.await?; .await?;
if count >= rate_limit.count.into() { if count >= rate_limit.count as i64 {
violations.push(EvalViolation::RateLimitExceeded); violations.push(EvalViolation::RateLimitExceeded);
} }
} }
@@ -185,23 +179,25 @@ impl Engine {
} }
if run_kind == RunKind::Execution { if run_kind == RunKind::Execution {
conn.transaction(async |conn| { conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table) let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog { .values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id, grant_id: grant.common_settings_id,
wallet_access_id: context.target.id, wallet_access_id: context.target.id,
chain_id: context.chain.into(), chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(), eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(), signed_at: Utc::now().into(),
}) })
.returning(evm_transaction_log::id) .returning(evm_transaction_log::id)
.get_result(&mut *conn) .get_result(conn)
.await?; .await?;
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?; P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
QueryResult::Ok(()) QueryResult::Ok(())
}) })
})
.await .await
.map_err(DatabaseError::from)?; .map_err(DatabaseError::from)?;
} }
@@ -211,7 +207,7 @@ impl Engine {
} }
impl Engine { impl Engine {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self { pub fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self { db, vault } Self { db, vault }
} }
@@ -226,18 +222,13 @@ impl Engine {
let vault = self.vault.clone(); let vault = self.vault.clone();
let id = conn let id = conn
.transaction(async |conn| { .transaction(|conn| {
Box::pin(async move {
use schema::evm_basic_grant; use schema::evm_basic_grant;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant { .values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(), chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id, wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp), valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp), valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
@@ -262,167 +253,23 @@ impl Engine {
revoked_at: None, revoked_at: None,
}) })
.returning(evm_basic_grant::all_columns) .returning(evm_basic_grant::all_columns)
.get_result(&mut *conn) .get_result(conn)
.await?; .await?;
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?; P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id) integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
.await .await
.map_err(|_| diesel::result::Error::RollbackTransaction)?; .map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id) QueryResult::Ok(basic_grant.id)
}) })
})
.await?; .await?;
Ok(id) Ok(id)
} }
pub async fn revoke_grant(
&self,
basic_grant_id: i32,
) -> Result<(), DatabaseError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let vault = self.vault.clone();
conn.transaction(async move |conn| {
use crate::db::schema::{
evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target,
evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_volume_limit,
};
update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(basic_grant_id))
.set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now())))
.execute(&mut *conn)
.await?;
let basic_grant: EvmBasicGrant = evm_basic_grant::table
.filter(evm_basic_grant::id.eq(basic_grant_id))
.select(EvmBasicGrant::as_select())
.first(&mut *conn)
.await?;
let shared = SharedGrantSettings::try_from_model(basic_grant)?;
if let Some(ether_grant) = evm_ether_transfer_grant::table
.filter(evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmEtherTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let target_rows: Vec<EvmEtherTransferGrantTarget> =
evm_ether_transfer_grant_target::table
.filter(evm_ether_transfer_grant_target::grant_id.eq(ether_grant.id))
.select(EvmEtherTransferGrantTarget::as_select())
.load(&mut *conn)
.await?;
let targets: Vec<Address> = target_rows
.into_iter()
.filter_map(|target| {
let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr))
})
.collect();
let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq(ether_grant.limit_id))
.select(EvmEtherTransferLimit::as_select())
.first(&mut *conn)
.await?;
let settings = CombinedSettings {
shared: shared.clone(),
specific: policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(limit.window_secs.into()),
},
},
};
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
if let Some(token_grant) = evm_token_transfer_grant::table
.filter(evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmTokenTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let volume_limit_rows: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id))
.select(EvmTokenTransferVolumeLimit::as_select())
.load(&mut *conn)
.await?;
let volume_limits: Vec<VolumeRateLimit> = volume_limit_rows
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;
let target: Option<Address> = match token_grant.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let token_contract: [u8; 20] =
token_grant.token_contract.clone().try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
)
})?;
let settings = CombinedSettings {
shared,
specific: policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
};
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
Err(diesel::result::Error::NotFound)
})
.await
.map_err(DatabaseError::from)
}
async fn list_one_kind<Kind: Policy, Y>( async fn list_one_kind<Kind: Policy, Y>(
&self, &self,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
@@ -466,7 +313,7 @@ impl Engine {
let TxKind::Call(to) = transaction.to else { let TxKind::Call(to) = transaction.to else {
return Err(VetError::ContractCreationNotSupported); return Err(VetError::ContractCreationNotSupported);
}; };
let context = EvalContext { let context = policies::EvalContext {
target, target,
chain: transaction.chain_id, chain: transaction.chain_id,
to, to,
@@ -502,27 +349,20 @@ impl Engine {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use alloy::primitives::{Address, Bytes, U256, address}; use alloy::primitives::{Address, Bytes, U256, address};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into}; use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use rstest::rstest; use rstest::rstest;
use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}};
use crate::crypto::integrity;
use crate::db::{ use crate::db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
SqliteTimestamp,
}, },
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}; };
use crate::evm::policies::ether_transfer::EtherTransfer;
use crate::evm::policies::{ use crate::evm::policies::{
CombinedSettings, EvalContext, EvalViolation, Policy, SharedGrantSettings, EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
TransactionRateLimit, VolumeRateLimit,
}; };
use super::check_shared_constraints; use super::check_shared_constraints;
@@ -535,7 +375,7 @@ mod tests {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(5), wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
@@ -554,7 +394,6 @@ mod tests {
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,
@@ -565,16 +404,10 @@ mod tests {
conn: &mut DatabaseConnection, conn: &mut DatabaseConnection,
shared: &SharedGrantSettings, shared: &SharedGrantSettings,
) -> EvmBasicGrant { ) -> EvmBasicGrant {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_basic_grant::table) insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant { .values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id, wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain.into(), chain_id: shared.chain as i32,
valid_from: shared.valid_from.map(SqliteTimestamp), valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp), valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared max_gas_fee_per_gas: shared
@@ -738,7 +571,7 @@ mod tests {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id: basic_grant.id, grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(), eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
@@ -763,115 +596,4 @@ mod tests {
assert!(violations.is_empty()); assert!(violations.is_empty());
} }
} }
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn revoke_grant_preserves_revoked_integrity() {
use crate::db::schema::evm_basic_grant;
use diesel::ExpressionMethods as _;
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let engine = super::Engine::new(db.clone(), vault.clone());
let full_grant = CombinedSettings {
shared: SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
},
specific: super::policies::ether_transfer::Settings {
target: vec![RECIPIENT],
limit: VolumeRateLimit {
max_volume: U256::from(100u64),
window: Duration::hours(1),
},
},
};
let grant_id = engine
.create_grant::<EtherTransfer>(full_grant)
.await
.unwrap();
engine.revoke_grant(grant_id).await.unwrap();
let mut conn = db.get().await.unwrap();
diesel::update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(grant_id))
.set(evm_basic_grant::revoked_at.eq::<Option<SqliteTimestamp>>(None))
.execute(&mut conn)
.await
.unwrap();
let wallet_access = EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
};
let context = EvalContext {
target: wallet_access,
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ONE,
calldata: Bytes::new(),
max_fee_per_gas: 1,
max_priority_fee_per_gas: 1,
};
let grant = EtherTransfer::try_find_grant(
&context, &mut conn,
)
.await
.unwrap()
.unwrap();
let result =
integrity::verify_entity(&mut conn, &vault, &grant.settings, grant.id).await;
assert!(matches!(
result,
Err(integrity::Error::MacMismatch { .. })
));
}
#[test]
fn shared_settings_hash_changes_when_revoked_at_changes() {
use arbiter_crypto::hashing::Hashable;
use sha2::Digest;
let active = shared_settings();
let revoked = SharedGrantSettings {
revoked_at: Some(Utc::now()),
..shared_settings()
};
let mut active_hash = sha2::Sha256::new();
active.hash(&mut active_hash);
let mut revoked_hash = sha2::Sha256::new();
revoked.hash(&mut revoked_hash);
assert_ne!(active_hash.finalize(), revoked_hash.finalize());
}
} }

View File

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

View File

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

View File

@@ -3,8 +3,7 @@ use crate::{
db::{ db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
SqliteTimestamp,
}, },
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}, },
@@ -23,7 +22,7 @@ use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
const WALLET_ACCESS_ID: i32 = 1; const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1; const CHAIN_ID: u64 = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111"); const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222"); const OTHER: Address = address!("2222222222222222222222222222222222222222");
@@ -32,7 +31,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10), wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
@@ -49,7 +48,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table) insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant { .values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
@@ -80,7 +79,6 @@ fn shared() -> SharedGrantSettings {
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,
@@ -163,7 +161,7 @@ async fn evaluate_passes_when_volume_within_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
@@ -205,7 +203,7 @@ async fn evaluate_rejects_volume_over_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
@@ -248,7 +246,7 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })

View File

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

View File

@@ -2,7 +2,7 @@ use super::{Settings, TokenTransfer};
use crate::{ use crate::{
db::{ db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp}, models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant, schema::evm_basic_grant,
}, },
evm::{ evm::{
@@ -45,7 +45,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10), wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
@@ -62,7 +62,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table) insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant { .values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
@@ -98,7 +98,6 @@ fn shared() -> SharedGrantSettings {
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,
@@ -242,11 +241,12 @@ async fn evaluate_passes_volume_at_exact_limit() {
.unwrap(); .unwrap();
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit // Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
insert_into(db::schema::evm_token_transfer_log::table) use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
.values(db::models::NewEvmTokenTransferLog { insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
grant_id, grant_id,
log_id: 0, log_id: 0,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(), token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(), recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
@@ -286,11 +286,12 @@ async fn evaluate_rejects_volume_over_limit() {
.await .await
.unwrap(); .unwrap();
insert_into(db::schema::evm_token_transfer_log::table) use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
.values(db::models::NewEvmTokenTransferLog { insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
grant_id, grant_id,
log_id: 0, log_id: 0,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(), token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(), recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess}, db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
evm::policies::{ evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer, SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers, token_transfers,
}, },
grpc::{Convert, TryConvert}, grpc::Convert,
grpc::TryConvert,
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::evm::{ proto::evm::{
@@ -13,7 +14,7 @@ use arbiter_proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType, specific_grant::Grant as ProtoSpecificGrantType,
}, },
proto::operator::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess}, proto::user_agent::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
}; };
use alloy::primitives::{Address, U256}; use alloy::primitives::{Address, U256};
@@ -21,11 +22,11 @@ use chrono::{DateTime, TimeZone, Utc};
use prost_types::Timestamp as ProtoTimestamp; use prost_types::Timestamp as ProtoTimestamp;
use tonic::Status; use tonic::Status;
fn address_from_bytes(bytes: &[u8]) -> Result<Address, Status> { fn address_from_bytes(bytes: Vec<u8>) -> Result<Address, Status> {
if bytes.len() != 20 { if bytes.len() != 20 {
return Err(Status::invalid_argument("Invalid EVM address")); return Err(Status::invalid_argument("Invalid EVM address"));
} }
Ok(Address::from_slice(bytes)) Ok(Address::from_slice(&bytes))
} }
fn u256_from_proto_bytes(bytes: &[u8]) -> Result<U256, Status> { fn u256_from_proto_bytes(bytes: &[u8]) -> Result<U256, Status> {
@@ -40,7 +41,7 @@ impl TryConvert for ProtoTimestamp {
type Error = Status; type Error = Status;
fn try_convert(self) -> Result<DateTime<Utc>, Status> { fn try_convert(self) -> Result<DateTime<Utc>, Status> {
Utc.timestamp_opt(self.seconds, self.nanos.try_into().unwrap_or_default()) Utc.timestamp_opt(self.seconds, self.nanos as u32)
.single() .single()
.ok_or_else(|| Status::invalid_argument("Invalid timestamp")) .ok_or_else(|| Status::invalid_argument("Invalid timestamp"))
} }
@@ -86,7 +87,6 @@ impl TryConvert for ProtoSharedSettings {
.valid_until .valid_until
.map(ProtoTimestamp::try_convert) .map(ProtoTimestamp::try_convert)
.transpose()?, .transpose()?,
revoked_at: None,
max_gas_fee_per_gas: self max_gas_fee_per_gas: self
.max_gas_fee_per_gas .max_gas_fee_per_gas
.as_deref() .as_deref()
@@ -116,8 +116,7 @@ impl TryConvert for ProtoSpecificGrant {
limit, limit,
})) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings { })) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets target: targets
.iter() .into_iter()
.map(Vec::as_slice)
.map(address_from_bytes) .map(address_from_bytes)
.collect::<Result<_, _>>()?, .collect::<Result<_, _>>()?,
limit: limit limit: limit
@@ -131,10 +130,8 @@ impl TryConvert for ProtoSpecificGrant {
target, target,
volume_limits, volume_limits,
})) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings { })) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: address_from_bytes(&token_contract)?, token_contract: address_from_bytes(token_contract)?,
target: target target: target.map(address_from_bytes).transpose()?,
.map(|target| address_from_bytes(&target))
.transpose()?,
volume_limits: volume_limits volume_limits: volume_limits
.into_iter() .into_iter()
.map(ProtoVolumeRateLimit::try_convert) .map(ProtoVolumeRateLimit::try_convert)
@@ -150,7 +147,7 @@ impl Convert for WalletAccess {
fn convert(self) -> Self::Output { fn convert(self) -> Self::Output {
NewEvmWalletAccess { NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(self.wallet_id), wallet_id: self.wallet_id,
client_id: self.sdk_client_id, client_id: self.sdk_client_id,
} }
} }
@@ -165,7 +162,7 @@ impl TryConvert for SdkClientWalletAccess {
return Err(Status::invalid_argument("Missing wallet access entry")); return Err(Status::invalid_argument("Missing wallet access entry"));
}; };
Ok(CoreEvmWalletAccess { Ok(CoreEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(access.wallet_id), wallet_id: access.wallet_id,
client_id: access.sdk_client_id, client_id: access.sdk_client_id,
id: self.id, id: self.id,
}) })

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
use crate::{ use crate::{
actors::vault::VaultState, actors::vault::VaultState,
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState, proto::shared::VaultState as ProtoVaultState,
proto::operator::{ proto::user_agent::{
operator_response::Payload as OperatorResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
vault::{ vault::{
self as proto_vault, request::Payload as VaultRequestPayload, self as proto_vault, request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload, response::Payload as VaultResponsePayload,
@@ -17,22 +17,22 @@ use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload { fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
OperatorResponsePayload::Vault(proto_vault::Response { UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload), payload: Some(payload),
}) })
} }
pub(super) async fn dispatch( pub(super) async fn dispatch(
actor: &ActorRef<OperatorSession>, actor: &ActorRef<UserAgentSession>,
req: proto_vault::Request, req: proto_vault::Request,
) -> Result<Option<OperatorResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else { let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing vault request payload")); return Err(Status::invalid_argument("Missing vault request payload"));
}; };
match payload { match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await, VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
Err(Status::permission_denied( Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session", "Vault is already unsealed; unseal/bootstrap not permitted in session",
@@ -42,8 +42,8 @@ pub(super) async fn dispatch(
} }
async fn handle_query_vault_state( async fn handle_query_vault_state(
actor: &ActorRef<OperatorSession>, actor: &ActorRef<UserAgentSession>,
) -> Result<Option<OperatorResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Sealed) => ProtoVaultState::Sealed,

View File

@@ -1,7 +1,7 @@
use super::auth::AuthTransportAdapter; use super::auth::AuthTransportAdapter;
use crate::{ use crate::{
grpc::TryConvert, grpc::TryConvert,
peers::operator::vault_gate::{self as vault_gate}, peers::user_agent::vault_gate::{self as vault_gate},
}; };
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender}; use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
@@ -15,12 +15,13 @@ mod outbound;
#[async_trait] #[async_trait]
impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> { impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
async fn recv(&mut self) -> Option<vault_gate::Inbound> { async fn recv(&mut self) -> Option<vault_gate::Inbound> {
loop {
let request = match self.bi_mut().recv().await? { let request = match self.bi_mut().recv().await? {
Ok(request) => request, Ok(request) => request,
Err(error) => { Err(error) => {
warn!( warn!(
?error, ?error,
"Failed to receive operator request during vault gate" "Failed to receive user agent request during vault gate"
); );
return None; return None;
} }
@@ -40,10 +41,11 @@ impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
}; };
match payload.try_convert() { match payload.try_convert() {
Ok(inbound) => Some(inbound), Ok(inbound) => return Some(inbound),
Err(status) => { Err(status) => {
let _ = self.bi_mut().send(Err(status)).await; let _ = self.bi_mut().send(Err(status)).await;
None return None;
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ use crate::{
crypto::integrity::{self, AttestationStatus}, crypto::integrity::{self, AttestationStatus},
db::{ db::{
self, self,
models::ProgramClientMetadata, models::{ProgramClientMetadata, SqliteTimestamp},
schema::program_client, schema::program_client,
}, },
}; };
@@ -18,13 +18,14 @@ use arbiter_proto::{
transport::{Bi, expect_message}, transport::{Bi, expect_message},
}; };
use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _, ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
dsl::insert_into, dsl::insert_into, update,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use kameo::{actor::ActorRef, error::SendError}; use kameo::{actor::ActorRef, error::SendError};
use tracing::{error, warn}; use tracing::error;
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
@@ -53,7 +54,7 @@ impl From<diesel::result::Error> for Error {
pub enum ApproveError { pub enum ApproveError {
#[error("Internal error")] #[error("Internal error")]
Internal, Internal,
#[error("Client connection denied by operators")] #[error("Client connection denied by user agents")]
Denied, Denied,
#[error("Upstream error: {0}")] #[error("Upstream error: {0}")]
Upstream(flow_coordinator::ApprovalError), Upstream(flow_coordinator::ApprovalError),
@@ -160,8 +161,7 @@ async fn insert_client(
pubkey: &authn::PublicKey, pubkey: &authn::PublicKey,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<i32, Error> { ) -> Result<i32, Error> {
use crate::db::schema::client_metadata; use crate::db::schema::{client_metadata, program_client};
let pubkey = pubkey.clone(); let pubkey = pubkey.clone();
let metadata = metadata.clone(); let metadata = metadata.clone();
@@ -170,7 +170,10 @@ async fn insert_client(
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(async |conn| { conn.exclusive_transaction(|conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
let metadata_id = insert_into(client_metadata::table) let metadata_id = insert_into(client_metadata::table)
.values(( .values((
client_metadata::name.eq(&metadata.name), client_metadata::name.eq(&metadata.name),
@@ -178,7 +181,7 @@ async fn insert_client(
client_metadata::version.eq(&metadata.version), client_metadata::version.eq(&metadata.version),
)) ))
.returning(client_metadata::id) .returning(client_metadata::id)
.get_result::<i32>(&mut *conn) .get_result::<i32>(conn)
.await?; .await?;
let client_id = insert_into(program_client::table) let client_id = insert_into(program_client::table)
@@ -188,12 +191,12 @@ async fn insert_client(
)) ))
.on_conflict_do_nothing() .on_conflict_do_nothing()
.returning(program_client::id) .returning(program_client::id)
.get_result::<i32>(&mut *conn) .get_result::<i32>(conn)
.await?; .await?;
integrity::sign_entity( integrity::sign_entity(
&mut *conn, conn,
vault, &vault,
&ClientCredentials { &ClientCredentials {
pubkey: pubkey.clone(), pubkey: pubkey.clone(),
}, },
@@ -207,50 +210,79 @@ async fn insert_client(
Ok(client_id) Ok(client_id)
}) })
})
.await .await
} }
/// Compares stored metadata against what a reconnecting client presents. async fn sync_client_metadata(
/// Metadata is frozen after initial operator approval and must not be silently
/// overwritten. Doing so would let an approved client forge its displayed
/// identity in later approval prompts. Drift is logged and ignored.
async fn check_metadata_drift(
db: &db::DatabasePool, db: &db::DatabasePool,
client_id: i32, client_id: i32,
presented: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<(), Error> { ) -> Result<(), Error> {
use crate::db::schema::client_metadata; use crate::db::schema::{client_metadata, client_metadata_history};
let now = SqliteTimestamp(Utc::now());
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
let current: ProgramClientMetadata = program_client::table 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) .find(client_id)
.inner_join(client_metadata::table) .inner_join(client_metadata::table)
.select(ProgramClientMetadata::as_select()) .select((
.first(&mut conn) 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 .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Database error"); error!(error = ?e, "Database error");
Error::DatabaseOperationFailed Error::DatabaseOperationFailed
})?; })
let changed = current.name != presented.name
|| current.description != presented.description
|| current.version != presented.version;
if changed {
warn!(
client_id,
stored_name = %current.name,
presented_name = %presented.name,
"reconnecting client presented different metadata; ignoring - metadata is frozen after operator approval"
);
}
Ok(())
} }
async fn challenge_client<T>( async fn challenge_client<T>(
@@ -273,7 +305,7 @@ where
let signature = expect_message(transport, |req: Inbound| match req { let signature = expect_message(transport, |req: Inbound| match req {
Inbound::AuthChallengeSolution { signature } => Some(signature), Inbound::AuthChallengeSolution { signature } => Some(signature),
Inbound::AuthChallengeRequest { .. } => None, _ => None,
}) })
.await .await
.map_err(|e| { .map_err(|e| {
@@ -297,11 +329,12 @@ where
return Err(Error::Transport); return Err(Error::Transport);
}; };
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? { let client_id = match get_client_id(&props.db, &pubkey).await? {
Some(id) => {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?; verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
check_metadata_drift(&props.db, id, &metadata).await?;
id id
} else { }
None => {
approve_new_client( approve_new_client(
&props.actors, &props.actors,
ClientProfile { ClientProfile {
@@ -311,8 +344,11 @@ where
) )
.await?; .await?;
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await? insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
}
}; };
sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
challenge_client(transport, pubkey, challenge).await?; challenge_client(transport, pubkey, challenge).await?;

View File

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

View File

@@ -20,7 +20,7 @@ pub struct ClientSession {
} }
impl ClientSession { impl ClientSession {
pub(crate) const fn new(props: ClientConnection, client_id: i32) -> Self { pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props, client_id } Self { props, client_id }
} }
} }
@@ -83,7 +83,7 @@ impl Actor for ClientSession {
args.props args.props
.actors .actors
.flow_coordinator .flow_coordinator
.ask(RegisterClient { client_id: args.client_id, actor: this }) .ask(RegisterClient { actor: this })
.await .await
.map_err(|_| Error::ConnectionRegistrationFailed)?; .map_err(|_| Error::ConnectionRegistrationFailed)?;
Ok(args) Ok(args)
@@ -91,7 +91,7 @@ impl Actor for ClientSession {
} }
impl ClientSession { impl ClientSession {
pub const fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self { pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors); let props = ClientConnection::new(db, actors);
Self { Self {
props, props,

View File

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

View File

@@ -1,472 +0,0 @@
use super::{Error, OperatorSession};
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
},
flow_coordinator::{IsClientConnected, client_connect_approval::ClientApprovalAnswer},
vault::VaultState,
},
db::{
models::{
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
schema::program_client,
},
evm::policies::{Grant, SpecificGrant},
};
use arbiter_crypto::authn;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{error::SendError, messages, prelude::Context};
use tracing::{error, info, warn};
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Client not connected")]
ClientNotConnected,
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "operator", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
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<(EvmWalletId, 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 OperatorSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(OperatorListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(OperatorCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(OperatorDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
if !self.approved_client_ids.contains(&client_id) {
warn!(
client_id,
"operator attempted to sign for client not in its approved set"
);
return Err(SignTransactionError::ClientNotConnected);
}
let connected = self
.props
.actors
.flow_coordinator
.ask(IsClientConnected { client_id })
.await
.unwrap_or(false);
if !connected {
self.approved_client_ids.remove(&client_id);
warn!(client_id, "operator attempted to sign for disconnected client");
return Err(SignTransactionError::ClientNotConnected);
}
info!(client_id, event = "sign_transaction", "operator.sign_transaction");
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 operator 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(async |conn| {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(&mut *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(async |conn| {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(entry))
.execute(&mut *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?;
let access_entries = crate::db::schema::evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl OperatorSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let Some(pending_approval) = self.pending_client_approvals.remove(&pubkey.to_bytes())
else {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
};
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;
if approved {
let pubkey_bytes = pending_approval.pubkey.to_bytes();
match self.props.db.get().await {
Ok(mut conn) => {
match program_client::table
.filter(program_client::public_key.eq(pubkey_bytes.as_slice()))
.select(program_client::id)
.first::<i32>(&mut conn)
.await
{
Ok(client_id) => {
self.approved_client_ids.insert(client_id);
}
Err(err) => {
error!(?err, "Failed to look up client_id for approved pubkey");
}
}
}
Err(err) => {
error!(?err, "DB pool error after client approval");
}
}
}
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)
}
}
#[cfg(test)]
mod tests {
use crate::db::{self, models::{EvmWalletId, NewEvmWalletAccess}, schema::evm_wallet_access};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
/// Regression test: revocation must delete by access-entry `id`, not by `wallet_id`.
///
/// Before the fix, revoking `entry_id=1` would delete all rows where `wallet_id=1`,
/// wiping out every client's access to wallet #1.
#[tokio::test]
async fn revoke_deletes_by_entry_id_not_wallet_id() {
use crate::db::models::EvmWalletAccess;
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.expect("pool connection");
// Insert two access entries for the same wallet but different clients.
// entry A: id will be 1, wallet_id=1, client_id=10
// entry B: id will be 2, wallet_id=1, client_id=20
let entry_a = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
client_id: 10,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert entry A");
let entry_b = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
client_id: 20,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert entry B");
// Revoke only entry A by its primary key id.
conn.transaction(async |conn| {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(entry_a.id))
.execute(&mut *conn)
.await
})
.await
.expect("revoke entry A");
// Entry A must be gone.
let gone = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(entry_a.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count entry A");
assert_eq!(gone, 0, "revoked entry must be deleted");
// Entry B (same wallet, different client) must still exist.
let still_there = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(entry_b.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count entry B");
assert_eq!(still_there, 1, "unrelated entry must not be deleted");
}
/// Regression test: when `entry_id` and `wallet_id` differ, only the correct row is removed.
///
/// This specifically catches the case where `entry.id=5` and `wallet_id=1` are different values;
/// the old bug would delete by `wallet_id`, potentially matching a completely different entry.
#[tokio::test]
async fn revoke_with_mismatched_wallet_and_entry_ids() {
use crate::db::models::EvmWalletAccess;
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.expect("pool connection");
// Insert entries to force auto-increment IDs to diverge from wallet_ids.
// We'll insert 5 placeholder entries first so that the real entry gets id=6.
for i in 1_i32..=5 {
diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(99),
client_id: i,
})
.execute(&mut *conn)
.await
.expect("insert placeholder");
}
// Real target: wallet_id=1, will get id=6.
let target = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
client_id: 1,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert target");
// Sanity: target.id != target.wallet_id
assert_ne!(
target.id, target.wallet_id.to_raw(),
"test prerequisite: id and wallet_id must differ"
);
// Revoke by entry id.
conn.transaction(async |conn| {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(target.id))
.execute(&mut *conn)
.await
})
.await
.expect("revoke target");
let remaining = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(target.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count target");
assert_eq!(remaining, 0, "target must be deleted by its entry id");
// Placeholders for wallet_id=99 must be untouched.
let placeholders = evm_wallet_access::table
.filter(evm_wallet_access::wallet_id.eq(99))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count placeholders");
assert_eq!(placeholders, 5, "unrelated entries must survive");
}
}

View File

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

View File

@@ -1,13 +1,13 @@
use super::{ use super::{
super::{Credentials, OperatorConnection}, super::{Credentials, UserAgentConnection},
Error, Error,
}; };
use crate::{ use crate::{
actors::bootstrap::ConsumeToken, actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::operator_identity}, db::{DatabasePool, schema::useragent_client},
peers::operator::auth::Outbound, peers::user_agent::auth::Outbound,
}; };
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
@@ -16,12 +16,13 @@ use tracing::error;
pub(super) struct ChallengeRequest { pub(super) struct ChallengeRequest {
pub(super) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<Vec<u8>>, pub(super) bootstrap_token: Option<String>,
} }
pub struct ChallengeContext { pub(super) struct ChallengeContext {
pub(super) challenge: AuthChallenge, pub(super) challenge: AuthChallenge,
pub(super) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<String>,
} }
pub(super) struct ChallengeSolution { pub(super) struct ChallengeSolution {
@@ -43,9 +44,9 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<O
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
operator_identity::table useragent_client::table
.filter(operator_identity::public_key.eq(pubkey.to_bytes())) .filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.select(operator_identity::id) .select(useragent_client::id)
.first::<i32>(&mut conn) .first::<i32>(&mut conn)
.await .await
.optional() .optional()
@@ -62,9 +63,9 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
let id: i32 = diesel::insert_into(operator_identity::table) let id: i32 = diesel::insert_into(useragent_client::table)
.values((operator_identity::public_key.eq(pubkey_bytes),)) .values((useragent_client::public_key.eq(pubkey_bytes),))
.returning(operator_identity::id) .returning(useragent_client::id)
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -76,24 +77,19 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
} }
pub(super) struct AuthContext<'a, T: ?Sized> { pub(super) struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut OperatorConnection, pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: &'a mut T, pub(super) transport: &'a mut T,
bootstrap_token: Option<Vec<u8>>,
} }
impl<'a, T: ?Sized> AuthContext<'a, T> { impl<'a, T: ?Sized> AuthContext<'a, T> {
pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self { pub(super) fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
Self { Self { conn, transport }
conn,
transport,
bootstrap_token: None,
}
} }
} }
impl<T> AuthStateMachineContext for AuthContext<'_, T> impl<T> AuthStateMachineContext for AuthContext<'_, T>
where where
T: Bi<super::Inbound, Result<Outbound, Error>> + Send + ?Sized, T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send + ?Sized,
{ {
type Error = Error; type Error = Error;
@@ -112,8 +108,6 @@ where
} }
} }
self.bootstrap_token = bootstrap_token;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport self.transport
@@ -126,20 +120,30 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(ChallengeContext { challenge, pubkey }) Ok(ChallengeContext {
challenge,
pubkey,
bootstrap_token,
})
} }
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution( async fn verify_solution(
&mut self, &mut self,
ChallengeContext { challenge, pubkey }: &ChallengeContext, ChallengeContext {
challenge,
pubkey,
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution, ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> { ) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
error!("Failed to decode signature in challenge solution"); error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution Error::InvalidChallengeSolution
})?; })?;
let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature); let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
if !valid { if !valid {
self.transport self.transport
@@ -150,13 +154,15 @@ where
} }
// Resolve client id: bootstrap (consume token + register) or lookup // Resolve client id: bootstrap (consume token + register) or lookup
let id = match self.bootstrap_token.take() { let id = match bootstrap_token {
Some(token) => { Some(token) => {
let token_ok: bool = self let token_ok: bool = self
.conn .conn
.actors .actors
.bootstrapper .bootstrapper
.ask(ConsumeToken { token }) .ask(ConsumeToken {
token: token.clone(),
})
.await .await
.map_err(|e| { .map_err(|e| {
error!(?e, "Failed to consume bootstrap token"); error!(?e, "Failed to consume bootstrap token");

View File

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

View File

@@ -0,0 +1,283 @@
use super::{Error, UserAgentSession};
use crate::{
actors::evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
},
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
actors::vault::VaultState,
db::models::{EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata},
evm::policies::{Grant, SpecificGrant},
};
use arbiter_crypto::authn;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{error::SendError, messages, prelude::Context};
use tracing::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(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(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 })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[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: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey.to_bytes()) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -1,14 +1,16 @@
use super::{OutOfBand, OperatorConnection}; use super::{OutOfBand, UserAgentConnection};
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::{GetConnectedClientIds, client_connect_approval::{ClientApprovalAnswer, ClientApprovalController}}, operator_registry::ConnectOperator, flow_coordinator::client_connect_approval::ClientApprovalController,
}, peers::client::ClientProfile, useragent_registry::ConnectUseragent,
},
peers::client::ClientProfile,
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
use arbiter_proto::transport::Sender; use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use std::{borrow::Cow, collections::{HashMap, HashSet}}; use std::{borrow::Cow, collections::HashMap};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
@@ -47,32 +49,27 @@ pub struct PendingClientApproval {
controller: ActorRef<ClientApprovalController>, controller: ActorRef<ClientApprovalController>,
} }
pub struct OperatorSession { pub struct UserAgentSession {
props: OperatorConnection, props: UserAgentConnection,
sender: Box<dyn Sender<OutOfBand>>, sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>, pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
/// DB `client_ids` this operator session is allowed to sign for.
/// Seeded from currently-connected clients on start, then updated as
/// approvals are granted or denied during the session lifetime.
approved_client_ids: HashSet<i32>,
} }
pub mod handlers; pub mod handlers;
impl OperatorSession { impl UserAgentSession {
pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self { pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self { Self {
props, props,
sender, sender,
pending_client_approvals: HashMap::default(), pending_client_approvals: Default::default(),
approved_client_ids: HashSet::default(),
} }
} }
} }
#[messages] #[messages]
impl OperatorSession { impl UserAgentSession {
#[message] #[message]
pub async fn begin_new_client_approval( pub async fn begin_new_client_approval(
&mut self, &mut self,
@@ -88,10 +85,9 @@ impl OperatorSession {
{ {
error!( error!(
?e, ?e,
actor = "operator", actor = "user_agent",
event = "failed to announce new client connection" event = "failed to announce new client connection"
); );
let _ = controller.tell(ClientApprovalAnswer { approved: false }).await;
return; return;
} }
@@ -105,36 +101,29 @@ impl OperatorSession {
} }
} }
impl Actor for OperatorSession { impl Actor for UserAgentSession {
type Args = Self; type Args = Self;
type Error = Error; type Error = Error;
async fn on_start(mut args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> { async fn on_start(
args: Self::Args,
this: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
.operator_registry .useragent_registry
.ask(ConnectOperator { .ask(ConnectUseragent {
actor: this.clone(), actor: this.clone(),
}) })
.await .await
.map_err(|err| { .map_err(|err| {
error!( error!(
?err, ?err,
"Failed to register operator connection with operator registry" "Failed to register user agent connection with user agent registry"
); );
Error::internal("Failed to register operator connection with operator registry") Error::internal("Failed to register user agent connection with user agent registry")
})?; })?;
// Seed approved set with clients already connected when this session starts.
// New clients will be added via handle_new_client_approve as they are approved.
match args.props.actors.flow_coordinator.ask(GetConnectedClientIds {}).await {
Ok(ids) => args.approved_client_ids.extend(ids),
Err(err) => {
error!(?err, "Failed to fetch connected client IDs on operator session start");
}
}
Ok(args) Ok(args)
} }
@@ -163,7 +152,7 @@ impl Actor for OperatorSession {
{ {
error!( error!(
?e, ?e,
actor = "operator", actor = "user_agent",
event = "failed to announce client connection cancellation" event = "failed to announce client connection cancellation"
); );
} }

View File

@@ -8,9 +8,9 @@ use crate::{
db::DatabasePool, db::DatabasePool,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use state::State; use state::*;
use chacha20poly1305::{AeadInOut, KeyInit as _, XChaCha20Poly1305, XNonce}; use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
use kameo::{Actor, error::SendError, messages, prelude::Message}; use kameo::{Actor, error::SendError, messages, prelude::Message};
use kameo_actors::message_bus::Register; use kameo_actors::message_bus::Register;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -25,8 +25,6 @@ pub enum Error {
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("State transition failed")] #[error("State transition failed")]
State, State,
@@ -101,21 +99,18 @@ impl VaultGate {
ciphertext: &[u8], ciphertext: &[u8],
associated_data: &[u8], associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> { ) -> Result<SafeCell<Vec<u8>>, ()> {
let Ok(nonce) = XNonce::try_from(nonce) else { let nonce = XNonce::from_slice(nonce);
error!("Encrypted key material carries a nonce of the wrong length");
return Err(());
};
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into()); let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec()); let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| { let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(&nonce, associated_data, write_handle) cipher.decrypt_in_place(nonce, associated_data, write_handle)
}); });
match decryption_result { match decryption_result {
Ok(()) => Ok(key_buffer), Ok(_) => Ok(key_buffer),
Err(err) => { Err(err) => {
error!(?err, "Failed to decrypt encrypted key material"); error!(?err, "Failed to decrypt encrypted key material");
Err(()) Err(())
@@ -127,9 +122,9 @@ impl VaultGate {
#[messages(messages = Inbound, replies = Outbound)] #[messages(messages = Inbound, replies = Outbound)]
impl VaultGate { impl VaultGate {
#[message] #[message]
pub fn handle_handshake( pub async fn handle_handshake(
&mut self, &mut self,
client_pubkey: PublicKey, client_pubkey: x25519_dalek::PublicKey,
) -> Result<HandshakeResponse, Error> { ) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random(); let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret); let public_key = PublicKey::from(&ephemeral_secret);
@@ -137,7 +132,7 @@ impl VaultGate {
let secret = ephemeral_secret.diffie_hellman(&client_pubkey); let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange { self.state = State::ReadyForExchange {
server_key: public_key, server_key: public_key.clone(),
secret, secret,
}; };
@@ -157,9 +152,12 @@ impl VaultGate {
return Err(Error::State); return Err(Error::State);
}; };
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else { {
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey); return Err(Error::InvalidKey);
}
}; };
match self match self
@@ -170,19 +168,18 @@ impl VaultGate {
}) })
.await .await
{ {
Ok(()) => { Ok(_) => {
info!("Successfully unsealed key with client-provided key"); info!("Successfully unsealed key with client-provided key");
Ok(()) Ok(())
} }
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey), Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(vault::Error::LockedOut)) => Err(Error::LockedOut),
Err(SendError::HandlerError(err)) => { Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key"); error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey) Err(Error::InvalidKey)
} }
Err(err) => { Err(err) => {
error!(?err, "Failed to send unseal request to vault"); error!(?err, "Failed to send unseal request to vault");
Err(Error::internal("Vault actor error")) Err(Error::internal("Vault actor error").into())
} }
} }
} }
@@ -198,9 +195,12 @@ impl VaultGate {
return Err(Error::State); return Err(Error::State);
}; };
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else { {
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey); return Err(Error::InvalidKey);
}
}; };
match self match self
@@ -211,7 +211,7 @@ impl VaultGate {
}) })
.await .await
{ {
Ok(()) => { Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key"); info!("Successfully bootstrapped vault with client-provided key");
Ok(()) Ok(())
} }

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