35 Commits

Author SHA1 Message Date
CleverWild
3aae3e1d83 feat(server): implement useragent_delete_grant hard delete cleanup
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 17:52:44 +02: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
0daad1dd37 Merge branch 'main' into push-zmyvyloztluy
Some checks failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-04-05 07:57:31 +00:00
9ea474e1b2 fix(server): use LOCALHOST const instead of hard-coded ip value
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-04 14:14:15 +00:00
CleverWild
c6f440fdad fix(client): evm-feature's code for new proto
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-04 14:10:44 +00:00
e17c25a604 ci(server-test): ensure that all features are compiling
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/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 failed
2026-04-04 14:06:02 +00: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
hdbg
083ff66af2 refactor(server): removed miette out of server
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-04 12:10:34 +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
68 changed files with 4494 additions and 649 deletions

View File

@@ -1,26 +0,0 @@
when:
- event: pull_request
path:
include: [".woodpecker/server-*.yaml", "server/**"]
- event: push
branch: main
path:
include: [".woodpecker/server-*.yaml", "server/**"]
steps:
- name: compile
image: jdxcode/mise:latest
directory: server
environment:
CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry
volumes:
- cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry
commands:
- apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain
- mise install rust
- mise install protoc
- cargo check --all-features

View File

@@ -24,4 +24,4 @@ steps:
- mise install rust
- mise install protoc
- mise install cargo:cargo-nextest
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features

View File

@@ -11,6 +11,7 @@ Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
---
@@ -42,7 +43,149 @@ There is no bootstrap mechanism for SDK clients. They must be explicitly approve
---
## 3. Server Identity
## 3. Multi-Operator Governance
When more than one User Agent is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
### 3.1 Voting Rules
Voting is based on the total number of registered operators:
- **1 operator:** no vote is needed; the single operator decides directly.
- **2 operators:** full consensus is required; both operators must approve.
- **3 or more operators:** quorum is `floor(N / 2) + 1`.
For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored.
Examples:
- **3 operators:** 2 approvals required
- **4 operators:** 3 approvals required
### 3.2 Actions Requiring a Vote
In multi-operator mode, a successful vote is required for:
- approving new SDK clients
- granting an SDK client visibility to a wallet
- approving a one-off transaction
- approving creation of a persistent grant
- approving operator replacement
- approving server updates
- updating Shamir secret-sharing parameters
### 3.3 Special Rule for Key Rotation
Key rotation always requires full quorum, regardless of the normal voting threshold.
This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently.
### 3.4 Root Key Custody
When the vault has multiple operators, the vault root key is protected using Shamir secret sharing.
The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password.
For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example:
- **2 operators:** `2-of-2`
- **3 operators:** `2-of-3`
- **4 operators:** `3-of-4`
In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example:
- **3 ordinary operators + 2 recovery shares:** `2-of-5`
This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows.
### 3.5 Recovery Operators
Recovery Operators are a separate peer type from ordinary vault operators.
Their role is intentionally narrow. They can only:
- participate in unsealing the vault
- vote for operator replacement
Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters.
### 3.6 Sleeping and Waking Recovery Operators
By default, Recovery Operators are **sleeping** and do not participate in any active flow.
Any ordinary operator may request that Recovery Operators **wake up**.
Any ordinary operator may also cancel a pending wake-up request.
This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**.
Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum.
The high-level recovery flow is:
```mermaid
sequenceDiagram
autonumber
actor Op as Ordinary Operator
participant Server
actor Other as Other Operator
actor Rec as Recovery Operator
Op->>Server: Request recovery wake-up
Server-->>Op: Wake-up pending
Note over Server: Default dispute window: 14 days
alt Wake-up cancelled during dispute window
Other->>Server: Cancel wake-up
Server-->>Op: Recovery cancelled
Server-->>Rec: Stay sleeping
else No cancellation for 14 days
Server-->>Rec: Wake up
Rec->>Server: Join recovery flow
critical Recovery authority
Rec->>Server: Participate in unseal
Rec->>Server: Vote on operator replacement
end
Server-->>Op: Recovery mode active
end
```
### 3.7 Committee Formation
There are two ways to form a multi-operator committee:
- convert an existing single-operator vault by adding new operators
- bootstrap an unbootstrapped vault directly into multi-operator mode
In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations.
### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
1. An operator connects to the unbootstrapped vault using a User Agent and the bootstrap token.
2. During bootstrap setup, that operator declares:
- the total number of ordinary operators
- the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**.
4. While in multi-bootstrap mode:
- every ordinary operator must connect with a User Agent using the bootstrap token
- every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually
- each participant's share is created and protected with that participant's credentials
5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully.
This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered.
### 3.9 Special Bootstrap Constraint for Two-Operator Vaults
If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap.
This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator.
---
## 4. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
@@ -55,9 +198,9 @@ Peers verify the server by its **public key fingerprint**:
---
## 4. Key Management
## 5. Key Management
### 4.1 Key Hierarchy
### 5.1 Key Hierarchy
There are three layers of keys:
@@ -72,19 +215,19 @@ This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password.
### 4.2 Encryption at Rest
### 5.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
---
## 5. Vault Lifecycle
## 6. Vault Lifecycle
### 5.1 Sealed State
### 6.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 5.2 Unseal Flow
### 6.2 Unseal Flow
To transition to the **Unsealed** state, a User Agent must provide the password:
@@ -95,7 +238,7 @@ To transition to the **Unsealed** state, a User Agent must provide the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
### 5.3 Memory Protection
### 6.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
@@ -107,9 +250,9 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
---
## 6. Permission Engine
## 7. Permission Engine
### 6.1 Fundamental Rules
### 7.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by a User Agent.
@@ -119,11 +262,45 @@ Each blockchain requires its own policy system due to differences in static tran
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 6.2 EVM Policies
### 7.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 6.2.1 Transaction Sub-Grants
#### 7.2.0 Transaction Signing Sequence
The high-level interaction order is:
```mermaid
sequenceDiagram
autonumber
actor SDK as SDK Client
participant Server
participant UA as User Agent
SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required
Server->>UA: Ask for wallet visibility approval
UA-->>Server: Vote result
end
Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context
alt Grant approval required
Server->>UA: Ask for execution / grant approval
UA-->>Server: Vote result
opt Create persistent grant
Server->>Server: Create and store grant
end
Server->>Server: Retry evaluation
end
critical Final authorization path
Server->>Server: Check limits and record execution
Server-->>Server: Signature or evaluation error
end
Server-->>SDK: Signature or error
```
#### 7.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
@@ -147,9 +324,9 @@ Available restrictions:
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 6.2.2 Global Limits
#### 7.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

View File

@@ -128,6 +128,52 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
The detailed branch structure is shown below:
```mermaid
flowchart TD
A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
B --> C{Wallet exists?}
C -- No --> Z1[Return wallet not found error]
C -- Yes --> D[Check SDK client wallet visibility]
D --> E{Wallet visible to SDK client?}
E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?}
G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility]
E -- Yes --> I[Classify transaction meaning]
H --> I
I --> J{Meaning supported?}
J -- No --> Z3[Return unsupported transaction error]
J -- Yes --> K[Find matching grant]
K --> L{Grant exists?}
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{User-agent decision}
O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]
P --> Q[Persist grant]
Q --> M
M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error]
R -- No --> S[Record transaction in logs]
S --> T[Produce signature]
T --> U[Return signature to SDK client]
note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
K -. uses .-> note2
M -. checks .-> note1
```
### Policy Trait
| Method | Purpose |

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 |

96
server/Cargo.lock generated
View File

@@ -724,6 +724,7 @@ name = "arbiter-server"
version = "0.1.0"
dependencies = [
"alloy",
"anyhow",
"arbiter-proto",
"arbiter-tokens-registry",
"argon2",
@@ -737,12 +738,14 @@ dependencies = [
"ed25519-dalek",
"fatality",
"futures",
"hmac",
"insta",
"k256",
"kameo",
"memsafe",
"miette",
"pem",
"postcard",
"prost",
"prost-types",
"rand 0.10.0",
"rcgen",
@@ -750,6 +753,8 @@ dependencies = [
"rsa",
"rustls",
"secrecy",
"serde",
"serde_with",
"sha2 0.10.9",
"smlang",
"spki",
@@ -1052,6 +1057,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -1442,6 +1456,15 @@ dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "console"
version = "0.15.11"
@@ -1549,6 +1572,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1954,6 +1983,7 @@ version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"serde",
"signature 3.0.0-rc.10",
]
@@ -1966,6 +1996,7 @@ dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"rand_core 0.10.0",
"serde",
"sha2 0.11.0-rc.5",
"subtle",
"zeroize",
@@ -2012,6 +2043,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -2051,7 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2429,6 +2472,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -2463,6 +2515,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version 0.4.1",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -3186,7 +3252,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3211,6 +3277,7 @@ dependencies = [
"num-iter",
"num-traits",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
]
@@ -3522,6 +3589,19 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -4150,6 +4230,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"serde",
"sha2 0.10.9",
"signature 2.2.0",
"spki",
@@ -4285,7 +4366,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4701,7 +4782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4709,6 +4790,9 @@ name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
@@ -4895,7 +4979,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -22,7 +22,6 @@ chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
smlang = "0.8.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
thiserror = "2.0.18"
async-trait = "0.1.89"
futures = "0.3.32"
@@ -43,3 +42,5 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }

View File

@@ -122,9 +122,7 @@ async fn receive_auth_confirmation(
.await
.map_err(|_| AuthError::UnexpectedAuthResponse)?;
let payload = response
.payload
.ok_or(AuthError::UnexpectedAuthResponse)?;
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
match payload {
ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Result(result))

View File

@@ -1,4 +1,3 @@
use std::io::{self, Write};
use arbiter_client::ArbiterClient;
@@ -22,8 +21,6 @@ async fn main() {
return;
}
let url = match ArbiterUrl::try_from(input) {
Ok(url) => url,
Err(err) => {
@@ -32,7 +29,7 @@ async fn main() {
}
};
println!("{:#?}", url);
println!("{:#?}", url);
let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_string(),
@@ -44,4 +41,4 @@ async fn main() {
Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {:#?}", err),
}
}
}

View File

@@ -1,11 +1,16 @@
use arbiter_proto::{ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl};
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
use crate::{
StorageError, auth::{AuthError, authenticate}, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport}
StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
};
#[cfg(feature = "evm")]
@@ -30,7 +35,6 @@ pub enum Error {
#[error("Storage error")]
Storage(#[from] StorageError),
}
pub struct ArbiterClient {
@@ -61,10 +65,11 @@ impl ArbiterClient {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor);
let channel = tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.tls_config(tls)?
.connect()
.await?;
let channel =
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.tls_config(tls)?
.connect()
.await?;
let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);

View File

@@ -9,4 +9,4 @@ pub use client::{ArbiterClient, Error};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
#[cfg(feature = "evm")]
pub use wallets::evm::ArbiterEvmWallet;
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};

View File

@@ -10,14 +10,48 @@ use tokio::sync::Mutex;
use arbiter_proto::proto::{
client::{
ClientRequest, client_request::Payload as ClientRequestPayload,
ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
evm::{
self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload,
},
},
evm::evm_sign_transaction_response::Result as EvmSignTransactionResult,
evm::{
EvmSignTransactionRequest,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::TransactionEvalError,
};
use crate::transport::{ClientTransport, next_request_id};
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
///
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
/// interpret the concrete policy evaluation failure instead of parsing strings.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ArbiterEvmSignTransactionError {
#[error("transaction rejected by policy: {0:?}")]
PolicyEval(TransactionEvalError),
}
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
type Error = ();
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
if let Error::Other(inner) = value
&& let Some(eval_error) = inner.downcast_ref()
{
Ok(eval_error)
} else {
Err(())
}
}
}
pub struct ArbiterEvmWallet {
transport: Arc<Mutex<ClientTransport>>,
address: Address,
@@ -96,12 +130,14 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
transport
.send(ClientRequest {
request_id,
payload: Some(ClientRequestPayload::EvmSignTransaction(
arbiter_proto::proto::evm::EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
payload: Some(EvmRequestPayload::SignTransaction(
EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
})),
})
.await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
@@ -121,12 +157,21 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
.payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::EvmSignTransaction(response) = payload else {
let ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
}) = payload
else {
return Err(Error::other(
"unexpected response payload for evm sign transaction request",
));
};
let EvmResponsePayload::SignTransaction(response) = payload else {
return Err(Error::other(
"unexpected evm response payload for sign transaction request",
));
};
let result = response
.result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
@@ -136,9 +181,9 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server"))
}
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(format!(
"transaction rejected by policy: {eval_error:?}"
))),
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
)),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}"
))),

View File

@@ -11,7 +11,7 @@ tokio.workspace = true
futures.workspace = true
hex = "0.4.3"
tonic-prost = "0.14.5"
prost = "0.14.3"
prost.workspace = true
kameo.workspace = true
url = "2.5.8"
miette.workspace = true

View File

@@ -7,7 +7,6 @@ const ARBITER_URL_SCHEME: &str = "arbiter";
const CERT_QUERY_KEY: &str = "cert";
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
#[derive(Debug, Clone)]
pub struct ArbiterUrl {
pub host: String,

View File

@@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [
"tokio",
] }
ed25519-dalek.workspace = true
ed25519-dalek.features = ["serde"]
arbiter-proto.path = "../arbiter-proto"
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -25,7 +26,6 @@ tonic.features = ["tls-aws-lc"]
tokio.workspace = true
rustls.workspace = true
smlang.workspace = true
miette.workspace = true
thiserror.workspace = true
fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
@@ -47,12 +47,20 @@ restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6"
k256.workspace = true
k256.features = ["serde"]
rsa.workspace = true
rsa.features = ["serde"]
sha2.workspace = true
hmac = "0.12"
spki.workspace = true
alloy.workspace = true
prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
postcard = { version = "1.1.3", features = ["use-std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_with = "3.18.0"
[dev-dependencies]
insta = "1.46.3"

View File

@@ -191,3 +191,19 @@ create table if not exists evm_ether_transfer_grant_target (
) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
-- ===============================
-- Integrity Envelopes
-- ===============================
create table if not exists integrity_envelope (
id integer not null primary key,
entity_kind text not null,
entity_id blob not null,
payload_version integer not null,
key_version integer not null,
mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);

View File

@@ -2,7 +2,7 @@ use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use miette::Diagnostic;
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use thiserror::Error;
@@ -25,18 +25,15 @@ pub async fn generate_token() -> Result<String, std::io::Error> {
Ok(token)
}
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum Error {
#[error("Database error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database))]
Database(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database_query))]
Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::io))]
Io(#[from] std::io::Error),
}

View File

@@ -287,10 +287,7 @@ where
Ok(())
}
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<i32, Error>
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
@@ -319,7 +316,7 @@ where
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
.await

View File

@@ -20,10 +20,7 @@ pub struct ClientConnection {
impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self {
db,
actors,
}
Self { db, actors }
}
}

View File

@@ -6,11 +6,10 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{
actors::{
GlobalActors,
client::ClientConnection, flow_coordinator::RegisterClient,
client::ClientConnection,
evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient,
keyholder::KeyHolderState,
},
db,
evm::VetError,
@@ -95,7 +94,10 @@ impl Actor for ClientSession {
impl ClientSession {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self { props, client_id: 0 }
Self {
props,
client_id: 0,
}
}
}

View File

@@ -1,22 +1,24 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
BoolExpressionMethods as _, ExpressionMethods, OptionalExtension as _, QueryDsl,
SelectableHelper as _, dsl::insert_into,
};
use diesel_async::RunQueryDsl;
use diesel_async::{AsyncConnection as _, RunQueryDsl};
use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self, SqliteTimestamp},
models::{self},
schema,
},
evm::{
self, RunKind,
self, ListError, RunKind,
policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
},
@@ -25,46 +27,40 @@ use crate::{
pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionError {
#[error("Wallet not found")]
#[diagnostic(code(arbiter::evm::sign::wallet_not_found))]
WalletNotFound,
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] DatabaseError),
#[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::sign::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::sign::keyholder_send))]
KeyholderSend,
#[error("Signing error: {0}")]
#[diagnostic(code(arbiter::evm::sign::signing))]
Signing(#[from] alloy::signers::Error),
#[error("Policy error: {0}")]
#[diagnostic(code(arbiter::evm::sign::vet))]
Vet(#[from] evm::VetError),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend,
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Actor)]
@@ -80,7 +76,7 @@ impl EvmActor {
// is it safe to seed rng from system once?
// todo: audit
let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone());
let engine = evm::Engine::new(db.clone(), keyholder.clone());
Self {
keyholder,
db,
@@ -141,46 +137,146 @@ impl EvmActor {
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, DatabaseError> {
) -> Result<i32, Error> {
match grant {
SpecificGrant::EtherTransfer(settings) => {
self.engine
.create_grant::<EtherTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
SpecificGrant::EtherTransfer(settings) => self
.engine
.create_grant::<EtherTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from),
SpecificGrant::TokenTransfer(settings) => self
.engine
.create_grant::<TokenTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from),
}
}
#[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;
// We intentionally perform a hard delete here to avoid leaving revoked grants and their
// related rows as long-lived DB garbage. We also don't rely on SQLite FK cascades because
// they can be disabled per-connection.
conn.transaction(|conn| {
Box::pin(async move {
// First, resolve policy-specific rows by basic grant id.
let token_grant_id: Option<i32> = schema::evm_token_transfer_grant::table
.select(schema::evm_token_transfer_grant::id)
.filter(schema::evm_token_transfer_grant::basic_grant_id.eq(grant_id))
.first::<i32>(conn)
.await
.optional()?;
let ether_grant: Option<(i32, i32)> = schema::evm_ether_transfer_grant::table
.select((
schema::evm_ether_transfer_grant::id,
schema::evm_ether_transfer_grant::limit_id,
))
.filter(schema::evm_ether_transfer_grant::basic_grant_id.eq(grant_id))
.first::<(i32, i32)>(conn)
.await
.optional()?;
// Token-transfer: logs must be deleted before transaction logs (FK restrict).
if let Some(token_grant_id) = token_grant_id {
diesel::delete(
schema::evm_token_transfer_log::table
.filter(schema::evm_token_transfer_log::grant_id.eq(token_grant_id)),
)
.execute(conn)
.await?;
diesel::delete(schema::evm_token_transfer_volume_limit::table.filter(
schema::evm_token_transfer_volume_limit::grant_id.eq(token_grant_id),
))
.execute(conn)
.await?;
diesel::delete(
schema::evm_token_transfer_grant::table
.filter(schema::evm_token_transfer_grant::id.eq(token_grant_id)),
)
.execute(conn)
.await?;
}
// Shared transaction logs for any grant kind.
diesel::delete(
schema::evm_transaction_log::table
.filter(schema::evm_transaction_log::grant_id.eq(grant_id)),
)
.execute(conn)
.await?;
// Ether-transfer: delete targets, grant row, then its limit row.
if let Some((ether_grant_id, limit_id)) = ether_grant {
diesel::delete(schema::evm_ether_transfer_grant_target::table.filter(
schema::evm_ether_transfer_grant_target::grant_id.eq(ether_grant_id),
))
.execute(conn)
.await?;
diesel::delete(
schema::evm_ether_transfer_grant::table
.filter(schema::evm_ether_transfer_grant::id.eq(ether_grant_id)),
)
.execute(conn)
.await?;
diesel::delete(
schema::evm_ether_transfer_limit::table
.filter(schema::evm_ether_transfer_limit::id.eq(limit_id)),
)
.execute(conn)
.await?;
}
// Integrity envelopes are not FK-constrained; delete only grant-related kinds to
// avoid accidentally deleting other entities that share the same integer ID.
let entity_id = grant_id.to_be_bytes().to_vec();
diesel::delete(
schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_id.eq(entity_id))
.filter(
schema::integrity_envelope::entity_kind
.eq("EtherTransfer")
.or(schema::integrity_envelope::entity_kind.eq("TokenTransfer")),
),
)
.execute(conn)
.await?;
// Finally remove the basic grant row itself (idempotent if it doesn't exist).
diesel::delete(
schema::evm_basic_grant::table.filter(schema::evm_basic_grant::id.eq(grant_id)),
)
.execute(conn)
.await?;
diesel::result::QueryResult::Ok(())
})
})
.await
.map_err(DatabaseError::from)?;
Ok(())
}
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
Ok(self
.engine
.list_all_grants()
.await
.map_err(DatabaseError::from)?)
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
}
}
#[message]
@@ -263,3 +359,6 @@ impl EvmActor {
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,283 @@
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
use crate::{
actors::{evm::EvmActor, keyholder::KeyHolder},
db::{self, models, schema},
};
#[tokio::test]
async fn delete_ether_grant_cleans_related_tables() {
let db = db::create_test_pool().await;
let keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let mut actor = EvmActor::new(keyholder, db.clone());
let mut conn = db.get().await.unwrap();
let basic_id: i32 = insert_into(schema::evm_basic_grant::table)
.values(&models::NewEvmBasicGrant {
wallet_access_id: 1,
chain_id: 1,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit_count: None,
rate_limit_window_secs: None,
revoked_at: None,
})
.returning(schema::evm_basic_grant::id)
.get_result(&mut conn)
.await
.unwrap();
let limit_id: i32 = insert_into(schema::evm_ether_transfer_limit::table)
.values(&models::NewEvmEtherTransferLimit {
window_secs: 60,
max_volume: vec![1],
})
.returning(schema::evm_ether_transfer_limit::id)
.get_result(&mut conn)
.await
.unwrap();
let ether_grant_id: i32 = insert_into(schema::evm_ether_transfer_grant::table)
.values(&models::NewEvmEtherTransferGrant {
basic_grant_id: basic_id,
limit_id,
})
.returning(schema::evm_ether_transfer_grant::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::evm_ether_transfer_grant_target::table)
.values(&models::NewEvmEtherTransferGrantTarget {
grant_id: ether_grant_id,
address: vec![0u8; 20],
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::evm_transaction_log::table)
.values(&models::NewEvmTransactionLog {
grant_id: basic_id,
wallet_access_id: 1,
chain_id: 1,
eth_value: vec![0],
signed_at: models::SqliteTimestamp::now(),
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::integrity_envelope::table)
.values(&models::NewIntegrityEnvelope {
entity_kind: "EtherTransfer".to_owned(),
entity_id: basic_id.to_be_bytes().to_vec(),
payload_version: 1,
key_version: 1,
mac: vec![0u8; 32],
})
.execute(&mut conn)
.await
.unwrap();
drop(conn);
actor.useragent_delete_grant(basic_id).await.unwrap();
// Idempotency: second delete should be a no-op.
actor.useragent_delete_grant(basic_id).await.unwrap();
let mut conn = db.get().await.unwrap();
let basic_count: i64 = schema::evm_basic_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(basic_count, 0);
let ether_grant_count: i64 = schema::evm_ether_transfer_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(ether_grant_count, 0);
let target_count: i64 = schema::evm_ether_transfer_grant_target::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(target_count, 0);
let limit_count: i64 = schema::evm_ether_transfer_limit::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(limit_count, 0);
let log_count: i64 = schema::evm_transaction_log::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(log_count, 0);
let envelope_count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("EtherTransfer"))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(envelope_count, 0);
}
#[tokio::test]
async fn delete_token_grant_cleans_related_tables() {
let db = db::create_test_pool().await;
let keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let mut actor = EvmActor::new(keyholder, db.clone());
let mut conn = db.get().await.unwrap();
let basic_id: i32 = insert_into(schema::evm_basic_grant::table)
.values(&models::NewEvmBasicGrant {
wallet_access_id: 1,
chain_id: 1,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit_count: None,
rate_limit_window_secs: None,
revoked_at: None,
})
.returning(schema::evm_basic_grant::id)
.get_result(&mut conn)
.await
.unwrap();
let token_grant_id: i32 = insert_into(schema::evm_token_transfer_grant::table)
.values(&models::NewEvmTokenTransferGrant {
basic_grant_id: basic_id,
token_contract: vec![1u8; 20],
receiver: None,
})
.returning(schema::evm_token_transfer_grant::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::evm_token_transfer_volume_limit::table)
.values(&models::NewEvmTokenTransferVolumeLimit {
grant_id: token_grant_id,
window_secs: 60,
max_volume: vec![1],
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::evm_token_transfer_volume_limit::table)
.values(&models::NewEvmTokenTransferVolumeLimit {
grant_id: token_grant_id,
window_secs: 3600,
max_volume: vec![2],
})
.execute(&mut conn)
.await
.unwrap();
let tx_log_id: i32 = insert_into(schema::evm_transaction_log::table)
.values(&models::NewEvmTransactionLog {
grant_id: basic_id,
wallet_access_id: 1,
chain_id: 1,
eth_value: vec![0],
signed_at: models::SqliteTimestamp::now(),
})
.returning(schema::evm_transaction_log::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::evm_token_transfer_log::table)
.values(&models::NewEvmTokenTransferLog {
grant_id: token_grant_id,
log_id: tx_log_id,
chain_id: 1,
token_contract: vec![1u8; 20],
recipient_address: vec![2u8; 20],
value: vec![3],
})
.execute(&mut conn)
.await
.unwrap();
insert_into(schema::integrity_envelope::table)
.values(&models::NewIntegrityEnvelope {
entity_kind: "TokenTransfer".to_owned(),
entity_id: basic_id.to_be_bytes().to_vec(),
payload_version: 1,
key_version: 1,
mac: vec![0u8; 32],
})
.execute(&mut conn)
.await
.unwrap();
drop(conn);
actor.useragent_delete_grant(basic_id).await.unwrap();
let mut conn = db.get().await.unwrap();
let basic_count: i64 = schema::evm_basic_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(basic_count, 0);
let token_grant_count: i64 = schema::evm_token_transfer_grant::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(token_grant_count, 0);
let token_limits_count: i64 = schema::evm_token_transfer_volume_limit::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(token_limits_count, 0);
let token_logs_count: i64 = schema::evm_token_transfer_log::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(token_logs_count, 0);
let tx_logs_count: i64 = schema::evm_transaction_log::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(tx_logs_count, 0);
let envelope_count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("TokenTransfer"))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(envelope_count, 0);
}

View File

@@ -15,7 +15,7 @@ use crate::actors::{
pub struct Args {
pub client: ClientProfile,
pub user_agents: Vec<ActorRef<UserAgentSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>
pub reply: ReplySender<Result<bool, ApprovalError>>,
}
pub struct ClientApprovalController {
@@ -39,7 +39,11 @@ impl Actor for ClientApprovalController {
type Error = ();
async fn on_start(
Args { client, mut user_agents, reply }: Self::Args,
Args {
client,
mut user_agents,
reply,
}: Self::Args,
actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> {
let this = Self {

View File

@@ -4,11 +4,19 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
use crate::safe_cell::SafeCell;
use crate::{
crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::HmacSha256,
},
safe_cell::SafeCell,
};
use crate::{
db::{
self,
@@ -17,9 +25,6 @@ use crate::{
},
safe_cell::SafeCellHandle as _,
};
use encryption::v1::{self, KeyCell, Nonce};
pub mod encryption;
#[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -35,36 +40,28 @@ enum State {
},
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Keyholder is already bootstrapped")]
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))]
AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))]
NotBootstrapped,
#[error("Invalid key provided")]
#[diagnostic(code(arbiter::keyholder::invalid_key))]
InvalidKey,
#[error("Requested aead entry not found")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))]
NotFound,
#[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::broken_database))]
BrokenDatabase,
}
@@ -114,14 +111,13 @@ impl KeyHolder {
.first(conn)
.await?;
let mut nonce =
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
update(schema::root_key_history::table)
@@ -144,12 +140,12 @@ impl KeyHolder {
return Err(Error::AlreadyBootstrapped);
}
let salt = v1::generate_salt();
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
let root_key_nonce = v1::Nonce::default();
let data_encryption_nonce = v1::Nonce::default();
let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice();
@@ -224,7 +220,7 @@ impl KeyHolder {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -244,7 +240,7 @@ impl KeyHolder {
self.state = State::Unsealed {
root_key_history_id: current_key.id,
root_key: v1::KeyCell::try_from(root_key).map_err(|err| {
root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
@@ -255,7 +251,6 @@ impl KeyHolder {
Ok(())
}
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -291,6 +286,7 @@ impl KeyHolder {
let State::Unsealed {
root_key,
root_key_history_id,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
@@ -328,6 +324,60 @@ impl KeyHolder {
self.state.discriminant()
}
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
Ok((*root_key_history_id, mac))
}
#[message]
pub fn verify_integrity(
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
) -> Result<bool, Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
if *root_key_history_id != key_version {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message]
pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed {

View File

@@ -1,5 +1,4 @@
use kameo::actor::{ActorRef, Spawn};
use miette::Diagnostic;
use thiserror::Error;
use crate::{
@@ -17,14 +16,12 @@ pub mod flow_coordinator;
pub mod keyholder;
pub mod user_agent;
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")]
#[diagnostic(code(SpawnError::Bootstrapper))]
Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn KeyHolder actor")]
#[diagnostic(code(SpawnError::KeyHolder))]
KeyHolder(#[from] keyholder::Error),
}

View File

@@ -37,6 +37,13 @@ impl Error {
}
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::internal("Database error")
}
}
#[derive(Debug, Clone)]
pub enum Outbound {
AuthChallenge { nonce: i32 },

View File

@@ -1,15 +1,18 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
keyholder::KeyHolder,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
},
db::schema,
crypto::integrity::{self, AttestationStatus},
db::{DatabasePool, schema::useragent_client},
};
pub struct ChallengeRequest {
@@ -40,7 +43,11 @@ smlang::statemachine!(
}
);
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> {
/// Returns the current nonce, ready to use for the challenge nonce.
async fn get_current_nonce_and_id(
db: &DatabasePool,
key: &AuthPublicKey,
) -> Result<(i32, i32), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
@@ -48,19 +55,12 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
useragent_client::table
.filter(useragent_client::public_key.eq(key.to_stored_bytes()))
.filter(useragent_client::key_type.eq(key.key_type()))
.select((useragent_client::id, useragent_client::nonce))
.first::<(i32, i32)>(conn)
.await
})
})
.await
@@ -70,12 +70,93 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
Error::internal("Database operation failed")
})?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> {
async fn verify_integrity(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let result = integrity::verify_entity(
&mut db_conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::internal("Integrity verification failed")
})?;
Ok(())
}
async fn create_nonce(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let new_nonce = db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_stored_bytes()))
.filter(useragent_client::key_type.eq(pubkey.key_type()))
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
.returning((useragent_client::id, useragent_client::nonce))
.get_result(conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity signature update failed");
Error::internal("Database error")
})?;
Result::<_, Error>::Ok(new_nonce)
})
})
.await?;
Ok(new_nonce)
}
async fn register_key(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
@@ -83,18 +164,40 @@ async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> R
Error::internal("Database unavailable")
})?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type),
))
.execute(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
conn.transaction(|conn| {
Box::pin(async move {
const NONCE_START: i32 = 1;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((
useragent_client::public_key.eq(pubkey_bytes),
useragent_client::nonce.eq(NONCE_START),
useragent_client::key_type.eq(key_type),
))
.returning(useragent_client::id)
.get_result(conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
let entity = UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
};
integrity::sign_entity(conn, &keyholder, &entity, id)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new user-agent key");
Error::internal("Failed to register public key")
})?;
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
@@ -120,8 +223,9 @@ where
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
@@ -161,14 +265,24 @@ where
return Err(Error::InvalidBootstrapToken);
}
register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
match token_ok {
true => {
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
}
#[allow(missing_docs)]
@@ -210,16 +324,21 @@ where
}
};
if !valid {
error!("Invalid challenge solution signature");
return Err(Error::InvalidChallengeSolution);
match valid {
true => {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
false => {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
}
}
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
}

View File

@@ -1,18 +1,65 @@
use crate::{
actors::{GlobalActors, client::ClientProfile},
db::{self, models::KeyType},
actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType}
};
fn serialize_ecdsa<S>(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Serialize as hex string for easier debugging (33 bytes compressed SEC1 format)
let key = key.to_encoded_point(true);
let bytes = key.as_bytes();
serializer.serialize_bytes(bytes)
}
fn deserialize_ecdsa<'de, D>(deserializer: D) -> Result<k256::ecdsa::VerifyingKey, D::Error>
where
D: serde::Deserializer<'de>,
{
struct EcdsaVisitor;
impl<'de> serde::de::Visitor<'de> for EcdsaVisitor {
type Value = k256::ecdsa::VerifyingKey;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a compressed SEC1-encoded ECDSA public key")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let point = k256::EncodedPoint::from_bytes(v)
.map_err(|_| E::custom("invalid compressed SEC1 format"))?;
k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_| E::custom("invalid ECDSA public key"))
}
}
deserializer.deserialize_bytes(EcdsaVisitor)
}
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
#[serde(serialize_with = "serialize_ecdsa", deserialize_with = "deserialize_ecdsa")]
EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey),
}
#[derive(Debug, Serialize)]
pub struct UserAgentCredentials {
pub pubkey: AuthPublicKey,
pub nonce: i32
}
impl Integrable for UserAgentCredentials {
const KIND: &'static str = "useragent_credentials";
}
impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
@@ -91,4 +138,5 @@ pub mod auth;
pub mod session;
pub use auth::authenticate;
use serde::Serialize;
pub use session::UserAgentSession;

View File

@@ -5,8 +5,8 @@ use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::prelude::Context;
use kameo::messages;
use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -120,6 +120,15 @@ pub enum SignTransactionError {
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
@@ -331,7 +340,7 @@ impl UserAgentSession {
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
@@ -342,13 +351,16 @@ impl UserAgentSession {
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant"))
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> {
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
match self
.props
.actors
@@ -359,7 +371,7 @@ impl UserAgentSession {
Ok(()) => Ok(()),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant"))
Err(GrantMutationError::Internal)
}
}
}

View File

@@ -1,6 +1,5 @@
use std::sync::Arc;
use miette::Diagnostic;
use thiserror::Error;
use crate::{
@@ -11,30 +10,24 @@ use crate::{
pub mod tls;
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum InitError {
#[error("Database setup failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_setup))]
DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Connection acquire failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_pool))]
DatabasePool(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::init::database_query))]
DatabaseQuery(#[from] diesel::result::Error),
#[error("TLS initialization failed: {0}")]
#[diagnostic(code(arbiter_server::init::tls_init))]
Tls(#[from] tls::InitError),
#[error("Actor spawn failed: {0}")]
#[diagnostic(code(arbiter_server::init::actor_spawn))]
ActorSpawn(#[from] crate::actors::SpawnError),
#[error("I/O Error: {0}")]
#[diagnostic(code(arbiter_server::init::io))]
Io(#[from] std::io::Error),
}

View File

@@ -1,8 +1,8 @@
use std::{net::IpAddr, string::FromUtf8Error};
use std::{net::Ipv4Addr, string::FromUtf8Error};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
@@ -29,30 +29,24 @@ const ENCODE_CONFIG: pem::EncodeConfig = {
pem::EncodeConfig::new().set_line_ending(line_ending)
};
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum InitError {
#[error("Key generation error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_generation))]
KeyGeneration(#[from] rcgen::Error),
#[error("Key invalid format: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_invalid_format))]
KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key deserialization error: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_deserialization))]
KeyDeserializationError(rcgen::Error),
#[error("Database error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_error))]
DatabaseError(#[from] diesel::result::Error),
#[error("Pem deserialization error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::pem_deserialization))]
PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[error("Database pool acquire error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_pool_acquire))]
DatabasePoolAcquire(#[from] db::PoolError),
}
@@ -116,9 +110,7 @@ impl TlsCa {
];
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::from([
127, 0, 0, 1,
])));
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf");

View File

@@ -0,0 +1,3 @@
pub mod v1;
pub use v1::*;

View File

@@ -0,0 +1,109 @@
use argon2::password_hash::Salt as ArgonSalt;
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce(pub [u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
#[cfg(test)]
mod tests {
use std::ops::Deref as _;
use super::*;
use crate::{
crypto::derive_key,
safe_cell::{SafeCell, SafeCellHandle as _},
};
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_key(password, &salt);
let mut key2 = derive_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -0,0 +1,3 @@
pub mod v1;
pub use v1::*;

View File

@@ -0,0 +1,319 @@
use crate::{actors::keyholder, crypto::KeyCell,safe_cell::SafeCellHandle as _};
use chacha20poly1305::Key;
use hmac::{Hmac, Mac as _};
use serde::Serialize;
use sha2::Sha256;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _;
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")]
Keyholder(#[from] keyholder::Error),
#[error("KeyHolder mailbox error")]
KeyholderSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
found: i32,
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str },
#[error("Payload serialization error: {0}")]
PayloadSerialization(#[from] postcard::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
Unavailable,
}
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>;
pub trait Integrable: Serialize {
const KIND: &'static str;
const VERSION: i32 = 1;
}
fn payload_hash(payload: &[u8]) -> [u8; 32] {
Sha256::digest(payload).into()
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
pub trait IntoId {
fn into_id(self) -> Vec<u8>;
}
impl IntoId for i32 {
fn into_id(self) -> Vec<u8> {
self.to_be_bytes().to_vec()
}
}
impl IntoId for &'_ [u8] {
fn into_id(self) -> Vec<u8> {
self.to_vec()
}
}
pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<(), Error> {
let payload = postcard::to_stdvec(entity)?;
let payload_hash = payload_hash(&payload);
let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) = keyholder
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
_ => Error::KeyholderSend,
})?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id: entity_id,
payload_version: E::VERSION ,
key_version,
mac: mac.to_vec(),
})
.on_conflict((
integrity_envelope::entity_id,
integrity_envelope::entity_kind,
))
.do_update()
.set((
integrity_envelope::payload_version.eq(E::VERSION),
integrity_envelope::key_version.eq(key_version),
integrity_envelope::mac.eq(mac),
))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(())
}
pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into_id();
let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.first(conn)
.await
.map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind: E::KIND },
other => Error::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != E::VERSION {
return Err(Error::PayloadVersionMismatch {
entity_kind: E::KIND,
expected: E::VERSION,
found: envelope.payload_version,
});
}
let payload = postcard::to_stdvec(entity)?;
let payload_hash = payload_hash(&payload);
let mac_input = build_mac_input(
E::KIND,
&entity_id,
envelope.payload_version,
&payload_hash,
);
let result = keyholder
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await
;
match result {
Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch { entity_kind: E::KIND }),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::KeyholderSend),
}
}
#[cfg(test)]
mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use crate::{
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, serde::Serialize)]
struct DummyEntity {
payload_version: i32,
payload: Vec<u8>,
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
}
#[tokio::test]
async fn tampered_mac_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn changed_payload_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
}

View File

@@ -1,52 +1,21 @@
use std::ops::Deref as _;
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt};
use argon2::{Algorithm, Argon2};
use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use rand::{
Rng as _, SeedableRng,
Rng as _, SeedableRng as _,
rngs::{StdRng, SysRng},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub mod encryption;
pub mod integrity;
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce([u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
use encryption::v1::{Nonce, Salt};
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
@@ -133,22 +102,9 @@ impl KeyCell {
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
@@ -171,37 +127,11 @@ pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell
#[cfg(test)]
mod tests {
use super::*;
use crate::safe_cell::SafeCell;
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_seal_key(password, &salt);
let mut key2 = derive_seal_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
use super::{
derive_key,
encryption::v1::{Nonce, generate_salt},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
#[test]
pub fn encrypt_decrypt() {
@@ -209,7 +139,7 @@ mod tests {
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();
@@ -226,18 +156,4 @@ mod tests {
let buffer = buffer.read();
assert_eq!(*buffer, b"secret data");
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -5,7 +5,7 @@ use diesel_async::{
sync_connection_wrapper::SyncConnectionWrapper,
};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use miette::Diagnostic;
use thiserror::Error;
use tracing::info;
@@ -21,26 +21,21 @@ static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Diagnostic, Debug)]
#[derive(Error, Debug)]
pub enum DatabaseSetupError {
#[error("Failed to determine home directory")]
#[diagnostic(code(arbiter::db::home_dir))]
HomeDir(std::io::Error),
#[error(transparent)]
#[diagnostic(code(arbiter::db::connection))]
Connection(diesel::ConnectionError),
#[error(transparent)]
#[diagnostic(code(arbiter::db::concurrency))]
ConcurrencySetup(diesel::result::Error),
#[error(transparent)]
#[diagnostic(code(arbiter::db::migration))]
Migration(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)]
#[diagnostic(code(arbiter::db::pool))]
Pool(#[from] PoolInitError),
}

View File

@@ -5,7 +5,7 @@ use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
root_key_history, tls_history,
integrity_envelope, root_key_history, tls_history,
};
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite};
@@ -376,3 +376,22 @@ pub struct EvmTokenTransferLog {
pub value: Vec<u8>,
pub created_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
#[view(
NewIntegrityEnvelope,
derive(Insertable),
omit(id, signed_at, created_at),
attributes_with = "deriveless"
)]
pub struct IntegrityEnvelope {
pub id: i32,
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}

View File

@@ -139,6 +139,19 @@ diesel::table! {
}
}
diesel::table! {
integrity_envelope (id) {
id -> Integer,
entity_kind -> Text,
entity_id -> Binary,
payload_version -> Integer,
key_version -> Integer,
mac -> Binary,
signed_at -> Integer,
created_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -219,6 +232,7 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_transaction_log,
evm_wallet,
evm_wallet_access,
integrity_envelope,
program_client,
root_key_history,
tls_history,

View File

@@ -8,8 +8,11 @@ use alloy::{
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::keyholder::KeyHolder,
crypto::integrity,
db::{
self, DatabaseError,
models::{
@@ -18,7 +21,7 @@ use crate::{
schema::{self, evm_transaction_log},
},
evm::policies::{
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
@@ -28,42 +31,47 @@ pub mod policies;
mod utils;
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
#[error("Transaction violates policy: {0:?}")]
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
#[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))]
NoMatchingGrant,
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum VetError {
#[error("Contract creation transactions are not supported")]
#[diagnostic(code(arbiter_server::evm::vet_error::contract_creation_unsupported))]
ContractCreationNotSupported,
#[error("Engine can't classify this transaction")]
#[diagnostic(code(arbiter_server::evm::vet_error::unsupported))]
UnsupportedTransactionType,
#[error("Policy evaluation failed: {1}")]
#[diagnostic(code(arbiter_server::evm::vet_error::evaluated))]
Evaluated(SpecificMeaning, #[source] PolicyError),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum AnalyzeError {
#[error("Engine doesn't support granting permissions for contract creation")]
#[diagnostic(code(arbiter_server::evm::analyze_error::contract_creation_not_supported))]
ContractCreationNotSupported,
#[error("Unsupported transaction type")]
#[diagnostic(code(arbiter_server::evm::analyze_error::unsupported_transaction_type))]
UnsupportedTransactionType,
}
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error),
}
/// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind {
@@ -122,6 +130,7 @@ async fn check_shared_constraints(
// Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine {
db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>,
}
impl Engine {
@@ -130,7 +139,10 @@ impl Engine {
context: EvalContext,
meaning: &P::Meaning,
run_kind: RunKind,
) -> Result<(), PolicyError> {
) -> Result<(), PolicyError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn)
@@ -138,10 +150,16 @@ impl Engine {
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await
.map_err(DatabaseError::from)?;
integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?;
let mut violations = check_shared_constraints(
&context,
&grant.settings.shared,
grant.common_settings_id,
&mut conn,
)
.await
.map_err(DatabaseError::from)?;
violations.extend(
P::evaluate(&context, meaning, &grant, &mut conn)
.await
@@ -150,12 +168,14 @@ impl Engine {
if !violations.is_empty() {
return Err(PolicyError::Violations(violations));
} else if run_kind == RunKind::Execution {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.shared_grant_id,
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
@@ -179,15 +199,19 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool) -> Self {
Self { db }
pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db, keyholder }
}
pub async fn create_grant<P: Policy>(
&self,
full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError> {
full_grant: CombinedSettings<P::Settings>,
) -> Result<i32, DatabaseError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn
.transaction(|conn| {
@@ -196,25 +220,25 @@ impl Engine {
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.basic.chain as i32,
wallet_access_id: full_grant.basic.wallet_access_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp),
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.basic
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.basic
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.basic
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.basic
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
@@ -224,7 +248,18 @@ impl Engine {
.get_result(conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
integrity::sign_entity(
conn,
&keyholder,
&full_grant,
basic_grant.id,
)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
})
.await?;
@@ -232,33 +267,36 @@ impl Engine {
Ok(id)
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
let mut conn = self.db.get().await?;
async fn list_one_kind<Kind: Policy, Y>(
&self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<impl Iterator<Item = Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
let all_grants = Kind::find_all_grants(conn)
.await
.map_err(DatabaseError::from)?;
// Verify integrity of all grants before returning any results
for grant in &all_grants {
integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?;
}
Ok(all_grants.into_iter().map(|g| Grant {
id: g.id,
common_settings_id: g.common_settings_id,
settings: g.settings.generalize(),
}))
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
grants.extend(
EtherTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::EtherTransfer(g.settings),
}),
);
grants.extend(
TokenTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::TokenTransfer(g.settings),
}),
);
grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?);
grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?);
Ok(grants)
}

View File

@@ -6,12 +6,12 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use serde::Serialize;
use thiserror::Error;
use crate::{
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils
};
pub mod ether_transfer;
@@ -33,48 +33,41 @@ pub struct EvalContext {
pub max_priority_fee_per_gas: u128,
}
#[derive(Debug, Error, Diagnostic)]
#[derive(Debug, Error)]
pub enum EvalViolation {
#[error("This grant doesn't allow transactions to the target address {target}")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_target))]
InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::gas_limit_exceeded))]
GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>,
},
#[error("Rate limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::rate_limit_exceeded))]
RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::volumetric_limit_exceeded))]
VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_time))]
InvalidTime,
#[error("Transaction type is not allowed by this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_transaction_type))]
InvalidTransactionType,
}
pub type DatabaseID = i32;
#[derive(Debug)]
#[derive(Debug, Serialize)]
pub struct Grant<PolicySettings> {
pub id: DatabaseID,
pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub shared: SharedGrantSettings,
pub settings: PolicySettings,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub settings: CombinedSettings<PolicySettings>,
}
pub trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant>;
type Settings: Send + Sync + 'static + Into<SpecificGrant> + Integrable;
type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -130,19 +123,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct VolumeRateLimit {
pub max_volume: U256,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct SharedGrantSettings {
pub wallet_access_id: i32,
pub chain: ChainId,
@@ -157,7 +150,7 @@ pub struct SharedGrantSettings {
}
impl SharedGrantSettings {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
wallet_access_id: model.wallet_access_id,
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
@@ -203,7 +196,23 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
pub struct FullGrant<PolicyGrant> {
pub basic: SharedGrantSettings,
#[derive(Debug, Serialize)]
pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant,
}
impl<P> CombinedSettings<P> {
pub fn generalize<Y: From<P>>(self) -> CombinedSettings<Y> {
CombinedSettings {
shared: self.shared,
specific: self.specific.into(),
}
}
}
impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION;
}

View File

@@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
};
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use crate::{
db::{
@@ -51,11 +52,14 @@ impl From<Meaning> for SpecificMeaning {
}
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize)]
pub struct Settings {
pub target: Vec<Address>,
pub limit: VolumeRateLimit,
}
impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer";
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
@@ -95,17 +99,17 @@ async fn check_rate_limits(
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
let window = grant.settings.limit.window;
let window = grant.settings.specific.limit.window;
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.limit.window;
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
let prospective_cumulative_volume: U256 = past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > grant.settings.limit.max_volume {
if prospective_cumulative_volume > grant.settings.specific.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded);
}
@@ -138,7 +142,7 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets
if !grant.settings.target.contains(&meaning.to) {
if !grant.settings.specific.target.contains(&meaning.to) {
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
}
@@ -247,9 +251,11 @@ impl Policy for EtherTransfer {
Ok(Some(Grant {
id: grant.id,
shared_grant_id: grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
common_settings_id: grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings,
},
}))
}
@@ -327,15 +333,17 @@ impl Policy for EtherTransfer {
Ok(Grant {
id: specific.id,
shared_grant_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|e| diesel::result::Error::DeserializationError(Box::new(e)),
)?,
window: Duration::seconds(limit.window_secs as i64),
common_settings_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|e| diesel::result::Error::DeserializationError(Box::new(e)),
)?,
window: Duration::seconds(limit.window_secs as i64),
},
},
},
})

View File

@@ -11,7 +11,10 @@ use crate::db::{
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
@@ -108,9 +111,11 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(vec![ALLOWED], 1_000_000),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -127,9 +132,11 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(vec![ALLOWED], 1_000_000),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
};
let context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -167,9 +174,11 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -207,9 +216,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -248,9 +259,11 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -282,8 +295,11 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some());
let g = found.unwrap();
assert_eq!(g.settings.target, vec![ALLOWED]);
assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64));
assert_eq!(g.settings.specific.target, vec![ALLOWED]);
assert_eq!(
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
}
#[tokio::test]
@@ -347,7 +363,7 @@ async fn find_all_grants_excludes_revoked() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target, vec![ALLOWED]);
assert_eq!(all[0].settings.specific.target, vec![ALLOWED]);
}
#[tokio::test]
@@ -363,8 +379,11 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target.len(), 2);
assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64));
assert_eq!(all[0].settings.specific.target.len(), 2);
assert_eq!(
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
}
#[tokio::test]

View File

@@ -10,11 +10,8 @@ use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use serde::Serialize;
use crate::db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant,
NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp,
};
use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
@@ -26,6 +23,15 @@ use crate::evm::{
},
utils,
};
use crate::{
crypto::integrity::Integrable,
db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
evm::policies::CombinedSettings,
};
use super::{DatabaseID, EvalContext, EvalViolation};
@@ -38,9 +44,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
pub(crate) token: &'static TokenInfo,
pub(crate) to: Address,
pub(crate) value: U256,
pub token: &'static TokenInfo,
pub to: Address,
pub value: U256,
}
impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -58,12 +64,15 @@ impl From<Meaning> for SpecificMeaning {
}
// A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct Settings {
pub token_contract: Address,
pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>,
}
impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer";
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
@@ -106,13 +115,20 @@ async fn check_volume_rate_limits(
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
let Some(longest_window) = grant.settings.volume_limits.iter().map(|l| l.window).max() else {
let Some(longest_window) = grant
.settings
.specific
.volume_limits
.iter()
.map(|l| l.window)
.max()
else {
return Ok(violations);
};
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.volume_limits {
for limit in &grant.settings.specific.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
.iter()
@@ -158,7 +174,7 @@ impl Policy for TokenTransfer {
return Ok(violations);
}
if let Some(allowed) = grant.settings.target
if let Some(allowed) = grant.settings.specific.target
&& allowed != meaning.to
{
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
@@ -269,9 +285,11 @@ impl Policy for TokenTransfer {
Ok(Some(Grant {
id: token_grant.id,
shared_grant_id: token_grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
common_settings_id: token_grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings,
},
}))
}
@@ -369,12 +387,14 @@ impl Policy for TokenTransfer {
Ok(Grant {
id: specific.id,
shared_grant_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
common_settings_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
},
})
})

View File

@@ -11,7 +11,10 @@ use crate::db::{
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
@@ -134,9 +137,11 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(None, None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(None, None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata);
@@ -163,9 +168,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(None, None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(None, None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -183,9 +190,11 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(Some(RECIPIENT), None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(Some(RECIPIENT), None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -203,9 +212,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(Some(RECIPIENT), None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(Some(RECIPIENT), None),
},
};
let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -247,9 +258,11 @@ async fn evaluate_passes_volume_at_exact_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -290,9 +303,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata);
@@ -313,9 +328,11 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(None, None), // no volume limits
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(None, None), // no volume limits
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata);
@@ -349,10 +366,13 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some());
let g = found.unwrap();
assert_eq!(g.settings.token_contract, DAI);
assert_eq!(g.settings.target, Some(RECIPIENT));
assert_eq!(g.settings.volume_limits.len(), 1);
assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64));
assert_eq!(g.settings.specific.token_contract, DAI);
assert_eq!(g.settings.specific.target, Some(RECIPIENT));
assert_eq!(g.settings.specific.volume_limits.len(), 1);
assert_eq!(
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
}
#[tokio::test]
@@ -434,9 +454,9 @@ async fn find_all_grants_loads_volume_limits() {
let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.volume_limits.len(), 1);
assert_eq!(all[0].settings.specific.volume_limits.len(), 1);
assert_eq!(
all[0].settings.volume_limits[0].max_volume,
all[0].settings.specific.volume_limits[0].max_volume,
U256::from(9_999u64)
);
}

View File

@@ -140,7 +140,9 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let Some(payload) = auth_request.payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument("Missing client auth request payload")))
.send(Err(Status::invalid_argument(
"Missing client auth request payload",
)))
.await;
return None;
};
@@ -170,9 +172,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
metadata: client_metadata_from_proto(client_info),
})
}
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution {
signature,
}) => {
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else {
let _ = self
.send_auth_result(ProtoAuthResult::InvalidSignature)

View File

@@ -34,7 +34,9 @@ pub(super) async fn dispatch(
req: proto_evm::Request,
) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing client EVM request payload"));
return Err(Status::invalid_argument(
"Missing client EVM request payload",
));
};
match payload {
@@ -59,13 +61,13 @@ pub(super) async fn dispatch(
))) => EvmSignTransactionResponse {
result: Some(vet_error.convert()),
},
Err(kameo::error::SendError::HandlerError(
SignTransactionRpcError::Internal,
)) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
@@ -78,8 +80,8 @@ pub(super) async fn dispatch(
Ok(wrap_response(EvmResponsePayload::SignTransaction(response)))
}
EvmRequestPayload::AnalyzeTransaction(_) => {
Err(Status::unimplemented("EVM transaction analysis is not yet implemented"))
}
EvmRequestPayload::AnalyzeTransaction(_) => Err(Status::unimplemented(
"EVM transaction analysis is not yet implemented",
)),
}
}

View File

@@ -12,11 +12,9 @@ use kameo::{actor::ActorRef, error::SendError};
use tonic::Status;
use tracing::warn;
use crate::{
actors::{
client::session::{ClientSession, Error, HandleQueryVaultState},
keyholder::KeyHolderState,
},
use crate::actors::{
client::session::{ClientSession, Error, HandleQueryVaultState},
keyholder::KeyHolderState,
};
pub(super) async fn dispatch(
@@ -24,7 +22,9 @@ pub(super) async fn dispatch(
req: proto_vault::Request,
) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing client vault request payload"));
return Err(Status::invalid_argument(
"Missing client vault request payload",
));
};
match payload {

View File

@@ -28,9 +28,8 @@ impl TryConvert for RawEvmTransaction {
type Error = tonic::Status;
fn try_convert(self) -> Result<Self::Output, Self::Error> {
let tx = TxEip1559::decode(&mut self.0.as_slice()).map_err(|_| {
tonic::Status::invalid_argument("Invalid EVM transaction format")
})?;
let tx = TxEip1559::decode(&mut self.0.as_slice())
.map_err(|_| tonic::Status::invalid_argument("Invalid EVM transaction format"))?;
Ok(tx)
}
}
}

View File

@@ -1,9 +1,12 @@
use alloy::primitives::U256;
use arbiter_proto::proto::{
evm::{EvmError as ProtoEvmError, evm_sign_transaction_response::Result as EvmSignTransactionResult},
evm::{
EvmError as ProtoEvmError,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation,
NoMatchingGrantError, PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
eval_violation::Kind as ProtoEvalViolationKind,
specific_meaning::Meaning as ProtoSpecificMeaningKind,
@@ -105,12 +108,12 @@ impl Convert for VetError {
violations: violations.into_iter().map(Convert::convert).collect(),
})
}
PolicyError::Database(_) => {
PolicyError::Database(_)| PolicyError::Integrity(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
}
},
};
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) })
}
}

View File

@@ -1,12 +1,10 @@
use tokio::sync::mpsc;
use arbiter_proto::{
proto::{
user_agent::{
UserAgentRequest, UserAgentResponse,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
proto::user_agent::{
UserAgentRequest, UserAgentResponse,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
};
@@ -19,6 +17,7 @@ use crate::{
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
grpc::request_tracker::RequestTracker,
};
mod auth;
mod evm;
mod inbound;

View File

@@ -1,12 +1,14 @@
use arbiter_proto::{
proto::user_agent::{
UserAgentRequest, UserAgentResponse, auth::{
UserAgentRequest, UserAgentResponse,
auth::{
self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
KeyType as ProtoKeyType, request::Payload as AuthRequestPayload,
response::Payload as AuthResponsePayload,
}, user_agent_request::Payload as UserAgentRequestPayload,
},
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
@@ -63,7 +65,9 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
Ok(Outbound::AuthChallenge { nonce }) => {
AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce })
}
Ok(Outbound::AuthSuccess) => AuthResponsePayload::Result(ProtoAuthResult::Success.into()),
Ok(Outbound::AuthSuccess) => {
AuthResponsePayload::Result(ProtoAuthResult::Success.into())
}
Err(Error::UnregisteredPublicKey) => {
AuthResponsePayload::Result(ProtoAuthResult::InvalidKey.into())
}
@@ -171,9 +175,9 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
bootstrap_token,
})
}
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution {
signature,
}) => Some(auth::Inbound::AuthChallengeSolution { signature }),
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
Some(auth::Inbound::AuthChallengeSolution { signature })
}
}
}
}

View File

@@ -3,8 +3,7 @@ use arbiter_proto::proto::{
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList,
WalletListResponse,
evm_grant_create_response::Result as EvmGrantCreateResult,
WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_delete_response::Result as EvmGrantDeleteResult,
evm_grant_list_response::Result as EvmGrantListResult,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
@@ -27,8 +26,8 @@ use crate::{
actors::user_agent::{
UserAgentSession,
session::connection::{
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantList, HandleSignTransaction,
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantDelete, HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError,
},
},
@@ -115,10 +114,10 @@ async fn handle_grant_list(
grants: grants
.into_iter()
.map(|grant| GrantEntry {
id: grant.id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
id: grant.common_settings_id,
wallet_access_id: grant.settings.shared.wallet_access_id,
shared: Some(grant.settings.shared.convert()),
specific: Some(grant.settings.specific.convert()),
})
.collect(),
}),
@@ -149,6 +148,9 @@ async fn handle_grant_create(
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
@@ -165,8 +167,16 @@ async fn handle_grant_delete(
actor: &ActorRef<UserAgentSession>,
req: EvmGrantDeleteRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleGrantDelete { grant_id: req.grant_id }).await {
let result = match actor
.ask(HandleGrantDelete {
grant_id: req.grant_id,
})
.await
{
Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
@@ -202,18 +212,18 @@ async fn handle_sign_transaction(
signature.as_bytes().to_vec(),
)),
},
Err(kameo::error::SendError::HandlerError(
SessionSignTransactionError::Vet(vet_error),
)) => EvmSignTransactionResponse {
result: Some(vet_error.convert()),
},
Err(kameo::error::SendError::HandlerError(
SessionSignTransactionError::Internal,
)) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
},
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Vet(vet_error))) => {
EvmSignTransactionResponse {
result: Some(vet_error.convert()),
}
}
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
@@ -224,7 +234,7 @@ async fn handle_sign_transaction(
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::SignTransaction(
response,
))))
Ok(Some(wrap_evm_response(
EvmResponsePayload::SignTransaction(response),
)))
}

View File

@@ -5,9 +5,7 @@ use arbiter_proto::proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
},
user_agent::sdk_client::{
WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess,
},
user_agent::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
};
use chrono::{DateTime, Utc};
use prost_types::Timestamp as ProtoTimestamp;

View File

@@ -1,4 +1,5 @@
use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{
sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
@@ -13,7 +14,6 @@ use arbiter_proto::proto::{
},
user_agent_response::Payload as UserAgentResponsePayload,
},
shared::ClientInfo as ProtoClientMetadata,
};
use kameo::actor::ActorRef;
use tonic::Status;
@@ -62,18 +62,22 @@ pub(super) async fn dispatch(
req: proto_sdk_client::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing SDK client request payload"));
return Err(Status::invalid_argument(
"Missing SDK client request payload",
));
};
match payload {
SdkClientRequestPayload::ConnectionResponse(resp) => {
handle_connection_response(actor, resp).await
}
SdkClientRequestPayload::Revoke(_) => {
Err(Status::unimplemented("SdkClientRevoke is not yet implemented"))
}
SdkClientRequestPayload::Revoke(_) => Err(Status::unimplemented(
"SdkClientRevoke is not yet implemented",
)),
SdkClientRequestPayload::List(_) => handle_list(actor).await,
SdkClientRequestPayload::GrantWalletAccess(req) => handle_grant_wallet_access(actor, req).await,
SdkClientRequestPayload::GrantWalletAccess(req) => {
handle_grant_wallet_access(actor, req).await
}
SdkClientRequestPayload::RevokeWalletAccess(req) => {
handle_revoke_wallet_access(actor, req).await
}
@@ -128,11 +132,11 @@ async fn handle_list(
ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into())
}
};
Ok(Some(wrap_sdk_client_response(SdkClientResponsePayload::List(
ProtoSdkClientListResponse {
Ok(Some(wrap_sdk_client_response(
SdkClientResponsePayload::List(ProtoSdkClientListResponse {
result: Some(result),
},
))))
}),
)))
}
async fn handle_grant_wallet_access(

View File

@@ -1,3 +1,4 @@
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use arbiter_proto::proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
@@ -11,25 +12,21 @@ use arbiter_proto::proto::user_agent::{
unseal::{
self as proto_unseal, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart,
request::Payload as UnsealRequestPayload,
response::Payload as UnsealResponsePayload,
request::Payload as UnsealRequestPayload, response::Payload as UnsealResponsePayload,
},
},
};
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use kameo::{actor::ActorRef, error::SendError};
use tonic::Status;
use tracing::warn;
use crate::{
actors::{
keyholder::KeyHolderState,
user_agent::{
UserAgentSession,
session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleQueryVaultState,
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
use crate::actors::{
keyholder::KeyHolderState,
user_agent::{
UserAgentSession,
session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleQueryVaultState,
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
};
@@ -151,7 +148,9 @@ async fn handle_bootstrap_encrypted_key(
.await
{
Ok(()) => ProtoBootstrapResult::Success,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => ProtoBootstrapResult::InvalidKey,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
ProtoBootstrapResult::InvalidKey
}
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}

View File

@@ -3,6 +3,7 @@ use crate::context::ServerContext;
pub mod actors;
pub mod context;
pub mod crypto;
pub mod db;
pub mod evm;
pub mod grpc;

View File

@@ -1,8 +1,8 @@
use std::net::SocketAddr;
use anyhow::anyhow;
use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl};
use arbiter_server::{Server, actors::bootstrap::GetToken, context::ServerContext, db};
use miette::miette;
use rustls::crypto::aws_lc_rs;
use tonic::transport::{Identity, ServerTlsConfig};
use tracing::info;
@@ -10,7 +10,7 @@ use tracing::info;
const PORT: u16 = 50051;
#[tokio::main]
async fn main() -> miette::Result<()> {
async fn main() -> anyhow::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();
tracing_subscriber::fmt()
@@ -46,11 +46,11 @@ async fn main() -> miette::Result<()> {
tonic::transport::Server::builder()
.tls_config(tls)
.map_err(|err| miette!("Faild to setup TLS: {err}"))?
.map_err(|err| anyhow!("Failed to setup TLS: {err}"))?
.add_service(ArbiterServiceServer::new(Server::new(context)))
.serve(addr)
.await
.map_err(|e| miette::miette!("gRPC server error: {e}"))?;
.map_err(|e| anyhow!("gRPC server error: {e}"))?;
unreachable!("gRPC server should run indefinitely");
}

View File

@@ -1,5 +1,6 @@
use arbiter_server::{
actors::keyholder::{Error, KeyHolder},
crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
@@ -25,16 +26,10 @@ async fn test_bootstrap() {
.unwrap();
assert_eq!(row.schema_version, 1);
assert_eq!(
row.tag,
arbiter_server::actors::keyholder::encryption::v1::ROOT_KEY_TAG
);
assert_eq!(row.tag, ROOT_KEY_TAG);
assert!(!row.ciphertext.is_empty());
assert!(!row.salt.is_empty());
assert_eq!(
row.data_encryption_nonce,
arbiter_server::actors::keyholder::encryption::v1::Nonce::default().to_vec()
);
assert_eq!(row.data_encryption_nonce, Nonce::default().to_vec());
}
#[tokio::test]

View File

@@ -1,7 +1,8 @@
use std::collections::HashSet;
use arbiter_server::{
actors::keyholder::{Error, encryption::v1},
actors::keyholder::Error,
crypto::encryption::v1::Nonce,
db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
@@ -102,7 +103,7 @@ async fn test_nonce_never_reused() {
assert_eq!(nonces.len(), unique.len(), "all nonces must be unique");
for (i, row) in rows.iter().enumerate() {
let mut expected = v1::Nonce::default();
let mut expected = Nonce::default();
for _ in 0..=i {
expected.increment();
}

View File

@@ -3,9 +3,12 @@ use arbiter_server::{
actors::{
GlobalActors,
bootstrap::GetToken,
user_agent::{AuthPublicKey, UserAgentConnection, auth},
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl;
@@ -18,6 +21,13 @@ use super::common::ChannelTransport;
pub async fn test_bootstrap_token_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -83,7 +93,6 @@ pub async fn test_bootstrap_invalid_token_auth() {
Err(auth::Error::InvalidBootstrapToken)
));
// Verify no key was registered
let mut conn = db.get().await.unwrap();
let count: i64 = schema::useragent_client::table
.count()
@@ -98,21 +107,39 @@ pub async fn test_bootstrap_invalid_token_auth() {
pub async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
// Pre-register key with key_type
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&UserAgentCredentials {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
nonce: 1,
},
id,
)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -122,7 +149,6 @@ pub async fn test_challenge_auth() {
auth::authenticate(&mut props, server_transport).await
});
// Send challenge request
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
@@ -131,7 +157,6 @@ pub async fn test_challenge_auth() {
.await
.unwrap();
// Read the challenge response
let response = test_transport
.recv()
.await
@@ -168,14 +193,21 @@ pub async fn test_challenge_auth() {
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_invalid_signature() {
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
// Pre-register key with key_type
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
@@ -195,6 +227,67 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None,
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::Internal { .. })
));
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&UserAgentCredentials {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
nonce: 1,
},
id,
)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
@@ -215,7 +308,6 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign a different challenge value so signature format is valid but verification must fail.
let wrong_challenge = arbiter_proto::format_challenge(challenge + 1, &pubkey_bytes);
let signature = new_key.sign(&wrong_challenge);
@@ -226,8 +318,10 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
.await
.unwrap();
let expected_err = task.await.unwrap();
println!("Received expected error: {expected_err:#?}");
assert!(matches!(
task.await.unwrap(),
expected_err,
Err(auth::Error::InvalidChallengeSolution)
));
}

View File

@@ -2,14 +2,17 @@ use arbiter_server::{
actors::{
GlobalActors,
keyholder::{Bootstrap, Seal},
user_agent::{UserAgentSession, session::connection::{
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
}},
user_agent::{
UserAgentSession,
session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
},
db,
safe_cell::{SafeCell, SafeCellHandle as _},
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -148,4 +151,4 @@ pub async fn test_unseal_retry_after_invalid_key() {
let response = user_agent.ask(encrypted_key).await;
assert!(matches!(response, Ok(())));
}
}
}

View File

@@ -30,7 +30,6 @@ class GrantCard extends ConsumerWidget {
@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 [];
@@ -44,7 +43,6 @@ class GrantCard extends ConsumerWidget {
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
// Resolve wallet_access_id → wallet address + client name
final accessById = <int, ua_sdk.WalletAccessEntry>{
for (final a in walletAccesses) a.id: a,
};
@@ -94,7 +92,6 @@ class GrantCard extends ConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
@@ -104,7 +101,6 @@ class GrantCard extends ConsumerWidget {
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
@@ -114,7 +110,6 @@ class GrantCard extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
@@ -184,7 +179,6 @@ class GrantCard extends ConsumerWidget {
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(