2 Commits

Author SHA1 Message Date
CleverWild
a748bd54ab chore: inline integrity proto types
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 00:53:36 +02:00
CleverWild
7f5393650b feat(server): integrity envelope engine for EVM grants with HMAC verification
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 21:52:50 +02:00
62 changed files with 1098 additions and 4038 deletions

View File

@@ -11,7 +11,6 @@ Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
---
@@ -43,149 +42,7 @@ There is no bootstrap mechanism for SDK clients. They must be explicitly approve
---
## 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
## 3. 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.
@@ -198,9 +55,9 @@ Peers verify the server by its **public key fingerprint**:
---
## 5. Key Management
## 4. Key Management
### 5.1 Key Hierarchy
### 4.1 Key Hierarchy
There are three layers of keys:
@@ -215,19 +72,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.
### 5.2 Encryption at Rest
### 4.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.
---
## 6. Vault Lifecycle
## 5. Vault Lifecycle
### 6.1 Sealed State
### 5.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 6.2 Unseal Flow
### 5.2 Unseal Flow
To transition to the **Unsealed** state, a User Agent must provide the password:
@@ -238,7 +95,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.
### 6.3 Memory Protection
### 5.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
@@ -250,9 +107,9 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
---
## 7. Permission Engine
## 6. Permission Engine
### 7.1 Fundamental Rules
### 6.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by a User Agent.
@@ -262,45 +119,11 @@ 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**.
### 7.2 EVM Policies
### 6.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 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
#### 6.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:
@@ -324,9 +147,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.
#### 7.2.2 Global Limits
#### 6.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,52 +128,6 @@ 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

@@ -1,821 +0,0 @@
# 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

@@ -1,170 +0,0 @@
# 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 |

94
server/Cargo.lock generated
View File

@@ -724,7 +724,6 @@ name = "arbiter-server"
version = "0.1.0"
dependencies = [
"alloy",
"anyhow",
"arbiter-proto",
"arbiter-tokens-registry",
"argon2",
@@ -743,8 +742,8 @@ dependencies = [
"k256",
"kameo",
"memsafe",
"miette",
"pem",
"postcard",
"prost",
"prost-types",
"rand 0.10.0",
@@ -753,8 +752,6 @@ dependencies = [
"rsa",
"rustls",
"secrecy",
"serde",
"serde_with",
"sha2 0.10.9",
"smlang",
"spki",
@@ -1057,15 +1054,6 @@ 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"
@@ -1456,15 +1444,6 @@ 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"
@@ -1572,12 +1551,6 @@ 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"
@@ -1983,7 +1956,6 @@ 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",
]
@@ -1996,7 +1968,6 @@ dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"rand_core 0.10.0",
"serde",
"sha2 0.11.0-rc.5",
"subtle",
"zeroize",
@@ -2043,18 +2014,6 @@ 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"
@@ -2094,7 +2053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2472,15 +2431,6 @@ 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"
@@ -2515,20 +2465,6 @@ 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"
@@ -3252,7 +3188,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3277,7 +3213,6 @@ dependencies = [
"num-iter",
"num-traits",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
]
@@ -3589,19 +3524,6 @@ 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"
@@ -4230,7 +4152,6 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"serde",
"sha2 0.10.9",
"signature 2.2.0",
"spki",
@@ -4366,7 +4287,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4782,7 +4703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4790,9 +4711,6 @@ name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
@@ -4979,7 +4897,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]

View File

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

View File

@@ -1,16 +1,11 @@
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")]
@@ -35,6 +30,7 @@ pub enum Error {
#[error("Storage error")]
Storage(#[from] StorageError),
}
pub struct ArbiterClient {
@@ -65,11 +61,10 @@ 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

@@ -61,6 +61,10 @@ pub mod proto {
pub mod evm {
tonic::include_proto!("arbiter.evm");
}
pub mod integrity {
tonic::include_proto!("arbiter.integrity");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]

View File

@@ -7,6 +7,7 @@ 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,7 +17,6 @@ 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"] }
@@ -26,6 +25,7 @@ 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,20 +47,14 @@ 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"
hmac = "0.12.1"
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

@@ -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,15 +25,18 @@ pub async fn generate_token() -> Result<String, std::io::Error> {
Ok(token)
}
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)]
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,7 +287,10 @@ 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,
{
@@ -316,7 +319,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,7 +20,10 @@ pub struct ClientConnection {
impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
Self {
db,
actors,
}
}
}

View File

@@ -6,10 +6,11 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{
actors::{
GlobalActors,
client::ClientConnection,
client::ClientConnection, flow_coordinator::RegisterClient,
evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient,
keyholder::KeyHolderState,
},
db,
evm::VetError,
@@ -94,10 +95,7 @@ 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,66 +1,75 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{
BoolExpressionMethods as _, ExpressionMethods, OptionalExtension as _, QueryDsl,
SelectableHelper as _, dsl::insert_into,
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
};
use diesel_async::{AsyncConnection as _, RunQueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity,
actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState},
db::{
DatabaseError, DatabasePool,
models::{self},
models::{self, SqliteTimestamp},
schema,
},
evm::{
self, ListError, RunKind,
self, RunKind,
policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
},
integrity,
safe_cell::{SafeCell, SafeCellHandle as _},
};
pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error)]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
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)]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
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),
#[error("Vault is sealed")]
#[diagnostic(code(arbiter::evm::vault_sealed))]
VaultSealed,
}
#[derive(Actor)]
@@ -84,6 +93,20 @@ impl EvmActor {
engine,
}
}
async fn ensure_unsealed(&self) -> Result<(), Error> {
let state = self
.keyholder
.ask(GetState)
.await
.map_err(|_| Error::KeyholderSend)?;
if state != KeyHolderState::Unsealed {
return Err(Error::VaultSealed);
}
Ok(())
}
}
#[messages]
@@ -138,128 +161,49 @@ impl EvmActor {
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, Error> {
self.ensure_unsealed().await?;
match grant {
SpecificGrant::EtherTransfer(settings) => self
.engine
.create_grant::<EtherTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from),
SpecificGrant::TokenTransfer(settings) => self
.engine
.create_grant::<TokenTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from),
SpecificGrant::EtherTransfer(settings) => {
self.engine
.create_grant::<EtherTransfer>(FullGrant {
basic,
specific: settings,
})
.await
.map_err(Error::from)
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(FullGrant {
basic,
specific: settings,
})
.await
.map_err(Error::from)
}
}
}
#[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
self.ensure_unsealed().await?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let keyholder = self.keyholder.clone();
// 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| {
diesel_async::AsyncConnection::transaction(&mut conn, |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)
diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(conn)
.await?;
let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
integrity::sign_entity(conn, &keyholder, &signed)
.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?;
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
diesel::result::QueryResult::Ok(())
})
@@ -272,11 +216,7 @@ impl EvmActor {
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
}
Ok(self.engine.list_all_grants().await?)
}
#[message]
@@ -359,6 +299,3 @@ impl EvmActor {
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,283 +0,0 @@
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,11 +39,7 @@ 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

@@ -0,0 +1 @@
pub mod v1;

View File

@@ -1,21 +1,52 @@
use std::ops::Deref as _;
use argon2::{Algorithm, Argon2};
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt};
use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use rand::{
Rng as _, SeedableRng as _,
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub mod encryption;
pub mod integrity;
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
use encryption::v1::{Nonce, Salt};
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))
}
}
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
@@ -102,9 +133,22 @@ 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_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
pub fn derive_seal_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);
@@ -127,11 +171,37 @@ pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[cfg(test)]
mod tests {
use super::{
derive_key,
encryption::v1::{Nonce, generate_salt},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
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][..]);
}
#[test]
pub fn encrypt_decrypt() {
@@ -139,7 +209,7 @@ mod tests {
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let mut key = derive_seal_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();
@@ -156,4 +226,18 @@ 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

@@ -4,19 +4,13 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use hmac::{Hmac, Mac as _};
use kameo::{Actor, Reply, messages};
use sha2::Sha256;
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
use crate::{
crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::HmacSha256,
},
safe_cell::SafeCell,
};
use crate::safe_cell::SafeCell;
use crate::{
db::{
self,
@@ -25,6 +19,13 @@ use crate::{
},
safe_cell::SafeCellHandle as _,
};
use encryption::v1::{self, KeyCell, Nonce};
type HmacSha256 = Hmac<Sha256>;
const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub mod encryption;
#[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -40,28 +41,36 @@ enum State {
},
}
#[derive(Debug, thiserror::Error)]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
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,
}
@@ -111,13 +120,14 @@ impl KeyHolder {
.first(conn)
.await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
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
})?;
nonce.increment();
update(schema::root_key_history::table)
@@ -134,18 +144,31 @@ impl KeyHolder {
Ok(nonce)
}
fn derive_integrity_key(root_key: &mut KeyCell) -> [u8; 32] {
root_key.0.read_inline(|root_key_bytes| {
let mut hmac = match HmacSha256::new_from_slice(root_key_bytes.as_slice()) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
};
hmac.update(INTEGRITY_SUBKEY_TAG);
let mut out = [0u8; 32];
out.copy_from_slice(&hmac.finalize().into_bytes());
out
})
}
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default();
let root_key_nonce = v1::Nonce::default();
let data_encryption_nonce = v1::Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice();
@@ -220,7 +243,7 @@ impl KeyHolder {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -240,7 +263,7 @@ impl KeyHolder {
self.state = State::Unsealed {
root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| {
root_key: v1::KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
@@ -251,6 +274,7 @@ 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 {
@@ -286,7 +310,6 @@ impl KeyHolder {
let State::Unsealed {
root_key,
root_key_history_id,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
@@ -334,12 +357,12 @@ impl KeyHolder {
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"),
});
let integrity_key = Self::derive_integrity_key(root_key);
let mut hmac = match HmacSha256::new_from_slice(&integrity_key) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
};
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
@@ -366,12 +389,11 @@ impl KeyHolder {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
let integrity_key = Self::derive_integrity_key(root_key);
let mut hmac = match HmacSha256::new_from_slice(&integrity_key) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
};
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);

View File

@@ -1,4 +1,5 @@
use kameo::actor::{ActorRef, Spawn};
use miette::Diagnostic;
use thiserror::Error;
use crate::{
@@ -16,12 +17,14 @@ pub mod flow_coordinator;
pub mod keyholder;
pub mod user_agent;
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)]
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,13 +37,6 @@ 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,18 +1,15 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
keyholder::KeyHolder,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
},
crypto::integrity::{self, AttestationStatus},
db::{DatabasePool, schema::useragent_client},
db::schema,
};
pub struct ChallengeRequest {
@@ -43,11 +40,7 @@ smlang::statemachine!(
}
);
/// 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> {
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
@@ -55,12 +48,19 @@ async fn get_current_nonce_and_id(
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
useragent_client::table
.filter(useragent_client::public_key.eq(key.to_stored_bytes()))
.filter(useragent_client::key_type.eq(key.key_type()))
.select((useragent_client::id, useragent_client::nonce))
.first::<(i32, i32)>(conn)
.await
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)
})
})
.await
@@ -70,93 +70,12 @@ async fn get_current_nonce_and_id(
Error::internal("Database operation failed")
})?
.ok_or_else(|| {
error!(?key, "Public key not found in database");
error!(?pubkey_bytes, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn verify_integrity(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(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> {
async fn register_key(db: &crate::db::DatabasePool, 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| {
@@ -164,40 +83,18 @@ async fn register_key(
Error::internal("Database unavailable")
})?;
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?;
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")
})?;
Ok(())
}
@@ -223,9 +120,8 @@ where
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
@@ -265,24 +161,14 @@ where
return Err(Error::InvalidBootstrapToken);
}
match token_ok {
true => {
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
#[allow(missing_docs)]
@@ -324,21 +210,16 @@ where
}
};
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)
}
if !valid {
error!("Invalid challenge solution signature");
return Err(Error::InvalidChallengeSolution);
}
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
}

View File

@@ -1,65 +1,18 @@
use crate::{
actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType}
actors::{GlobalActors, client::ClientProfile},
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, Serialize)]
#[derive(Clone, Debug)]
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.
@@ -138,5 +91,4 @@ 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::messages;
use kameo::prelude::Context;
use kameo::messages;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -349,6 +349,9 @@ impl UserAgentSession {
.await
{
Ok(grant_id) => Ok(grant_id),
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
Err(GrantMutationError::VaultSealed)
}
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
@@ -369,6 +372,9 @@ impl UserAgentSession {
.await
{
Ok(()) => Ok(()),
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
Err(GrantMutationError::VaultSealed)
}
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(GrantMutationError::Internal)

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use miette::Diagnostic;
use thiserror::Error;
use crate::{
@@ -10,24 +11,30 @@ use crate::{
pub mod tls;
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)]
pub enum InitError {
#[error("Database setup failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_setup))]
DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Connection acquire failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_pool))]
DatabasePool(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::init::database_query))]
DatabaseQuery(#[from] diesel::result::Error),
#[error("TLS initialization failed: {0}")]
#[diagnostic(code(arbiter_server::init::tls_init))]
Tls(#[from] tls::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::Ipv4Addr, string::FromUtf8Error};
use std::{net::IpAddr, 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,24 +29,30 @@ const ENCODE_CONFIG: pem::EncodeConfig = {
pem::EncodeConfig::new().set_line_ending(line_ending)
};
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)]
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),
}
@@ -110,7 +116,9 @@ impl TlsCa {
];
params
.subject_alt_names
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
.push(SanType::IpAddress(IpAddr::from([
127, 0, 0, 1,
])));
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf");

View File

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

View File

@@ -1,109 +0,0 @@
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

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

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,21 +21,26 @@ static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Debug)]
#[derive(Error, Diagnostic, 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

@@ -12,7 +12,6 @@ use kameo::actor::ActorRef;
use crate::{
actors::keyholder::KeyHolder,
crypto::integrity,
db::{
self, DatabaseError,
models::{
@@ -21,57 +20,57 @@ use crate::{
schema::{self, evm_transaction_log},
},
evm::policies::{
DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings,
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
integrity,
};
pub mod policies;
mod utils;
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
#[derive(Debug, thiserror::Error)]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
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}")]
#[diagnostic(code(arbiter_server::evm::policy_error::integrity))]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error)]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
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)]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
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 {
@@ -150,16 +149,18 @@ impl Engine {
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?;
let signed_grant = integrity::evm::SignedEvmGrant::from_active_grant(&Grant {
id: grant.id,
shared_grant_id: grant.shared_grant_id,
shared: grant.shared.clone(),
settings: grant.settings.clone().into(),
});
integrity::verify_entity(&mut conn, &self.keyholder, &signed_grant).await?;
let mut violations = check_shared_constraints(
&context,
&grant.settings.shared,
grant.common_settings_id,
&mut conn,
)
.await
.map_err(DatabaseError::from)?;
let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await
.map_err(DatabaseError::from)?;
violations.extend(
P::evaluate(&context, meaning, &grant, &mut conn)
.await
@@ -169,13 +170,13 @@ impl Engine {
if !violations.is_empty() {
return Err(PolicyError::Violations(violations));
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
grant_id: grant.shared_grant_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
@@ -205,7 +206,7 @@ impl Engine {
pub async fn create_grant<P: Policy>(
&self,
full_grant: CombinedSettings<P::Settings>,
full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError>
where
P::Settings: Clone,
@@ -220,25 +221,25 @@ impl Engine {
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
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),
max_gas_fee_per_gas: full_grant
.shared
.basic
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.basic
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.basic
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.basic
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
@@ -250,14 +251,16 @@ impl Engine {
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)?;
let signed_grant = integrity::evm::SignedEvmGrant {
basic_grant_id: basic_grant.id,
shared: full_grant.basic.clone(),
specific: full_grant.specific.clone().into(),
revoked_at: basic_grant.revoked_at.map(Into::into),
};
integrity::sign_entity(conn, &keyholder, &signed_grant)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
@@ -267,36 +270,43 @@ impl Engine {
Ok(id)
}
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)?;
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
let mut conn = self.db.get().await?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?);
grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?);
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),
}),
);
for grant in &grants {
let signed = integrity::evm::SignedEvmGrant::from_active_grant(grant);
integrity::verify_entity(&mut conn, &self.keyholder, &signed)
.await
.map_err(|err| match err {
integrity::Error::Database(db_err) => db_err,
_ => DatabaseError::Connection(diesel::result::Error::RollbackTransaction),
})?;
}
Ok(grants)
}

View File

@@ -6,12 +6,12 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use serde::Serialize;
use miette::Diagnostic;
use thiserror::Error;
use crate::{
crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
pub mod ether_transfer;
@@ -33,41 +33,48 @@ pub struct EvalContext {
pub max_priority_fee_per_gas: u128,
}
#[derive(Debug, Error)]
#[derive(Debug, Error, Diagnostic)]
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, Serialize)]
#[derive(Debug)]
pub struct Grant<PolicySettings> {
pub id: DatabaseID,
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 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 trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant> + Integrable;
type Settings: Send + Sync + 'static + Into<SpecificGrant>;
type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -123,19 +130,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VolumeRateLimit {
pub max_volume: U256,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SharedGrantSettings {
pub wallet_access_id: i32,
pub chain: ChainId,
@@ -196,23 +203,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
#[derive(Debug, Serialize)]
pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub struct FullGrant<PolicyGrant> {
pub basic: 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,14 +8,13 @@ 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::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use crate::{
db::{
@@ -52,14 +51,11 @@ impl From<Meaning> for SpecificMeaning {
}
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone)]
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 {
@@ -99,17 +95,17 @@ async fn check_rate_limits(
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
let window = grant.settings.specific.limit.window;
let window = grant.settings.limit.window;
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
let window_start = chrono::Utc::now() - grant.settings.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.specific.limit.max_volume {
if prospective_cumulative_volume > grant.settings.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded);
}
@@ -142,7 +138,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.specific.target.contains(&meaning.to) {
if !grant.settings.target.contains(&meaning.to) {
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
}
@@ -251,11 +247,9 @@ impl Policy for EtherTransfer {
Ok(Some(Grant {
id: grant.id,
common_settings_id: grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings,
},
shared_grant_id: grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
}))
}
@@ -333,17 +327,15 @@ impl Policy for EtherTransfer {
Ok(Grant {
id: specific.id,
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),
},
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),
},
},
})

View File

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

View File

@@ -10,8 +10,11 @@ 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,
@@ -23,15 +26,6 @@ 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};
@@ -44,9 +38,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
pub token: &'static TokenInfo,
pub to: Address,
pub value: U256,
pub(crate) token: &'static TokenInfo,
pub(crate) to: Address,
pub(crate) value: U256,
}
impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -64,15 +58,12 @@ impl From<Meaning> for SpecificMeaning {
}
// A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone)]
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)
@@ -115,20 +106,13 @@ async fn check_volume_rate_limits(
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
let Some(longest_window) = grant
.settings
.specific
.volume_limits
.iter()
.map(|l| l.window)
.max()
else {
let Some(longest_window) = grant.settings.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.specific.volume_limits {
for limit in &grant.settings.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
.iter()
@@ -174,7 +158,7 @@ impl Policy for TokenTransfer {
return Ok(violations);
}
if let Some(allowed) = grant.settings.specific.target
if let Some(allowed) = grant.settings.target
&& allowed != meaning.to
{
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
@@ -285,11 +269,9 @@ impl Policy for TokenTransfer {
Ok(Some(Grant {
id: token_grant.id,
common_settings_id: token_grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings,
},
shared_grant_id: token_grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
}))
}
@@ -387,14 +369,12 @@ impl Policy for TokenTransfer {
Ok(Grant {
id: specific.id,
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,
},
shared_grant_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
})
})

View File

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

View File

@@ -140,9 +140,7 @@ 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;
};
@@ -172,7 +170,9 @@ 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,9 +34,7 @@ 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 {
@@ -61,13 +59,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 {
@@ -80,8 +78,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,9 +12,11 @@ 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(
@@ -22,9 +24,7 @@ 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,8 +28,9 @@ 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,12 +1,9 @@
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,

View File

@@ -1,10 +1,12 @@
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},
};
@@ -17,7 +19,6 @@ use crate::{
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
grpc::request_tracker::RequestTracker,
};
mod auth;
mod evm;
mod inbound;

View File

@@ -1,14 +1,12 @@
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},
@@ -65,9 +63,7 @@ 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())
}
@@ -175,9 +171,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

@@ -114,10 +114,10 @@ async fn handle_grant_list(
grants: grants
.into_iter()
.map(|grant| GrantEntry {
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()),
id: grant.shared_grant_id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
})
.collect(),
}),

View File

@@ -5,7 +5,9 @@ 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,5 +1,4 @@
use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{
sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
@@ -14,6 +13,7 @@ use arbiter_proto::proto::{
},
user_agent_response::Payload as UserAgentResponsePayload,
},
shared::ClientInfo as ProtoClientMetadata,
};
use kameo::actor::ActorRef;
use tonic::Status;
@@ -62,22 +62,18 @@ 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::List(_) => handle_list(actor).await,
SdkClientRequestPayload::GrantWalletAccess(req) => {
handle_grant_wallet_access(actor, req).await
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::RevokeWalletAccess(req) => {
handle_revoke_wallet_access(actor, req).await
}
@@ -132,11 +128,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,4 +1,3 @@
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use arbiter_proto::proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
@@ -12,21 +11,25 @@ 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,
},
},
},
};
@@ -148,9 +151,7 @@ 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

@@ -0,0 +1,336 @@
use alloy::primitives::Address;
use chrono::{DateTime, Utc};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use prost::Message;
use prost_types::Timestamp;
use crate::{
db::{models, schema},
evm::policies::{Grant, SharedGrantSettings, SpecificGrant, VolumeRateLimit},
integrity::IntegrityEntity,
};
pub const EVM_GRANT_ENTITY_KIND: &str = "evm_grant";
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityVolumeRateLimit {
#[prost(bytes, tag = "1")]
pub max_volume: Vec<u8>,
#[prost(int64, tag = "2")]
pub window_secs: i64,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityTransactionRateLimit {
#[prost(uint32, tag = "1")]
pub count: u32,
#[prost(int64, tag = "2")]
pub window_secs: i64,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegritySharedGrantSettings {
#[prost(int32, tag = "1")]
pub wallet_access_id: i32,
#[prost(uint64, tag = "2")]
pub chain_id: u64,
#[prost(message, optional, tag = "3")]
pub valid_from: Option<::prost_types::Timestamp>,
#[prost(message, optional, tag = "4")]
pub valid_until: Option<::prost_types::Timestamp>,
#[prost(bytes, optional, tag = "5")]
pub max_gas_fee_per_gas: Option<Vec<u8>>,
#[prost(bytes, optional, tag = "6")]
pub max_priority_fee_per_gas: Option<Vec<u8>>,
#[prost(message, optional, tag = "7")]
pub rate_limit: Option<IntegrityTransactionRateLimit>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityEtherTransferSettings {
#[prost(bytes, repeated, tag = "1")]
pub targets: Vec<Vec<u8>>,
#[prost(message, optional, tag = "2")]
pub limit: Option<IntegrityVolumeRateLimit>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityTokenTransferSettings {
#[prost(bytes, tag = "1")]
pub token_contract: Vec<u8>,
#[prost(bytes, optional, tag = "2")]
pub target: Option<Vec<u8>>,
#[prost(message, repeated, tag = "3")]
pub volume_limits: Vec<IntegrityVolumeRateLimit>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegritySpecificGrant {
#[prost(oneof = "integrity_specific_grant::Grant", tags = "1, 2")]
pub grant: Option<integrity_specific_grant::Grant>,
}
pub mod integrity_specific_grant {
use super::*;
#[derive(Clone, PartialEq, ::prost::Oneof)]
pub enum Grant {
#[prost(message, tag = "1")]
EtherTransfer(IntegrityEtherTransferSettings),
#[prost(message, tag = "2")]
TokenTransfer(IntegrityTokenTransferSettings),
}
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityEvmGrantPayloadV1 {
#[prost(int32, tag = "1")]
pub basic_grant_id: i32,
#[prost(message, optional, tag = "2")]
pub shared: Option<IntegritySharedGrantSettings>,
#[prost(message, optional, tag = "3")]
pub specific: Option<IntegritySpecificGrant>,
#[prost(message, optional, tag = "4")]
pub revoked_at: Option<::prost_types::Timestamp>,
}
#[derive(Debug, Clone)]
pub struct SignedEvmGrant {
pub basic_grant_id: i32,
pub shared: SharedGrantSettings,
pub specific: SpecificGrant,
pub revoked_at: Option<DateTime<Utc>>,
}
impl SignedEvmGrant {
pub fn from_active_grant(grant: &Grant<SpecificGrant>) -> Self {
Self {
basic_grant_id: grant.shared_grant_id,
shared: grant.shared.clone(),
specific: grant.settings.clone(),
revoked_at: None,
}
}
}
fn timestamp(value: DateTime<Utc>) -> Timestamp {
Timestamp {
seconds: value.timestamp(),
nanos: 0,
}
}
fn encode_shared(shared: &SharedGrantSettings) -> IntegritySharedGrantSettings {
IntegritySharedGrantSettings {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain,
valid_from: shared.valid_from.map(timestamp),
valid_until: shared.valid_until.map(timestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|v| v.to_le_bytes::<32>().to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|v| v.to_le_bytes::<32>().to_vec()),
rate_limit: shared
.rate_limit
.as_ref()
.map(|rl| IntegrityTransactionRateLimit {
count: rl.count,
window_secs: rl.window.num_seconds(),
}),
}
}
fn encode_volume_limit(limit: &VolumeRateLimit) -> IntegrityVolumeRateLimit {
IntegrityVolumeRateLimit {
max_volume: limit.max_volume.to_le_bytes::<32>().to_vec(),
window_secs: limit.window.num_seconds(),
}
}
fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<alloy::primitives::U256> {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
format!("Expected 32-byte U256 payload, got {}", bytes.len()).into(),
)
})?;
Ok(alloy::primitives::U256::from_le_bytes(bytes))
}
fn encode_specific(specific: &SpecificGrant) -> IntegritySpecificGrant {
let grant = match specific {
SpecificGrant::EtherTransfer(settings) => {
let mut targets: Vec<Vec<u8>> =
settings.target.iter().map(|addr| addr.to_vec()).collect();
targets.sort_unstable();
integrity_specific_grant::Grant::EtherTransfer(IntegrityEtherTransferSettings {
targets,
limit: Some(encode_volume_limit(&settings.limit)),
})
}
SpecificGrant::TokenTransfer(settings) => {
let mut volume_limits: Vec<IntegrityVolumeRateLimit> = settings
.volume_limits
.iter()
.map(encode_volume_limit)
.collect();
volume_limits.sort_by(|left, right| {
left.window_secs
.cmp(&right.window_secs)
.then_with(|| left.max_volume.cmp(&right.max_volume))
});
integrity_specific_grant::Grant::TokenTransfer(IntegrityTokenTransferSettings {
token_contract: settings.token_contract.to_vec(),
target: settings.target.map(|a| a.to_vec()),
volume_limits,
})
}
};
IntegritySpecificGrant { grant: Some(grant) }
}
impl IntegrityEntity for SignedEvmGrant {
fn entity_kind(&self) -> &'static str {
EVM_GRANT_ENTITY_KIND
}
fn entity_id_bytes(&self) -> Vec<u8> {
self.basic_grant_id.to_be_bytes().to_vec()
}
fn payload_version(&self) -> i32 {
1
}
fn canonical_payload_bytes(&self) -> Vec<u8> {
IntegrityEvmGrantPayloadV1 {
basic_grant_id: self.basic_grant_id,
shared: Some(encode_shared(&self.shared)),
specific: Some(encode_specific(&self.specific)),
revoked_at: self.revoked_at.map(timestamp),
}
.encode_to_vec()
}
}
pub async fn load_signed_grant_by_basic_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
basic_grant_id: i32,
) -> diesel::result::QueryResult<SignedEvmGrant> {
let basic: models::EvmBasicGrant = schema::evm_basic_grant::table
.filter(schema::evm_basic_grant::id.eq(basic_grant_id))
.select(models::EvmBasicGrant::as_select())
.first(conn)
.await?;
let specific_token: Option<models::EvmTokenTransferGrant> =
schema::evm_token_transfer_grant::table
.filter(schema::evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(models::EvmTokenTransferGrant::as_select())
.first(conn)
.await
.optional()?;
let revoked_at = basic.revoked_at.clone().map(Into::into);
let shared = SharedGrantSettings::try_from_model(basic)?;
if let Some(token) = specific_token {
let limits: Vec<models::EvmTokenTransferVolumeLimit> =
schema::evm_token_transfer_volume_limit::table
.filter(schema::evm_token_transfer_volume_limit::grant_id.eq(token.id))
.select(models::EvmTokenTransferVolumeLimit::as_select())
.load(conn)
.await?;
let token_contract: [u8; 20] = token.token_contract.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
)
})?;
let target = match token.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let volume_limits = limits
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: try_bytes_to_u256(&row.max_volume)?,
window: chrono::Duration::seconds(row.window_secs as i64),
})
})
.collect::<diesel::result::QueryResult<Vec<_>>>()?;
return Ok(SignedEvmGrant {
basic_grant_id,
shared,
specific: SpecificGrant::TokenTransfer(
crate::evm::policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
),
revoked_at,
});
}
let ether: models::EvmEtherTransferGrant = schema::evm_ether_transfer_grant::table
.filter(schema::evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(models::EvmEtherTransferGrant::as_select())
.first(conn)
.await?;
let targets_rows: Vec<models::EvmEtherTransferGrantTarget> =
schema::evm_ether_transfer_grant_target::table
.filter(schema::evm_ether_transfer_grant_target::grant_id.eq(ether.id))
.select(models::EvmEtherTransferGrantTarget::as_select())
.load(conn)
.await?;
let limit: models::EvmEtherTransferLimit = schema::evm_ether_transfer_limit::table
.filter(schema::evm_ether_transfer_limit::id.eq(ether.limit_id))
.select(models::EvmEtherTransferLimit::as_select())
.first(conn)
.await?;
let targets = targets_rows
.into_iter()
.map(|row| {
let arr: [u8; 20] = row.address.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid ether target address length".into(),
)
})?;
Ok(Address::from(arr))
})
.collect::<diesel::result::QueryResult<Vec<_>>>()?;
Ok(SignedEvmGrant {
basic_grant_id,
shared,
specific: SpecificGrant::EtherTransfer(crate::evm::policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: try_bytes_to_u256(&limit.max_volume)?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
}),
revoked_at,
})
}

View File

@@ -1,13 +1,7 @@
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 kameo::actor::ActorRef;
use sha2::{Digest as _, Sha256};
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
@@ -18,23 +12,39 @@ use crate::{
},
};
#[derive(Debug, thiserror::Error)]
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub mod evm;
pub trait IntegrityEntity {
fn entity_kind(&self) -> &'static str;
fn entity_id_bytes(&self) -> Vec<u8>;
fn payload_version(&self) -> i32;
fn canonical_payload_bytes(&self) -> Vec<u8>;
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::integrity::database))]
Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")]
Keyholder(#[from] keyholder::Error),
#[diagnostic(code(arbiter::integrity::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("KeyHolder mailbox error")]
#[diagnostic(code(arbiter::integrity::keyholder_send))]
KeyholderSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
#[diagnostic(code(arbiter::integrity::missing_envelope))]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
#[diagnostic(code(arbiter::integrity::payload_version_mismatch))]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
@@ -42,26 +52,8 @@ pub enum Error {
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
#[diagnostic(code(arbiter::integrity::mac_mismatch))]
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] {
@@ -87,34 +79,17 @@ fn build_mac_input(
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>(
pub async fn sign_entity(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
entity: &impl IntegrityEntity,
) -> Result<(), Error> {
let payload = postcard::to_stdvec(entity)?;
let entity_kind = entity.entity_kind();
let entity_id = entity.entity_id_bytes();
let payload_version = entity.payload_version();
let payload = entity.canonical_payload_bytes();
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 mac_input = build_mac_input(entity_kind, &entity_id, payload_version, &payload_hash);
let (key_version, mac) = keyholder
.ask(SignIntegrity { mac_input })
@@ -124,24 +99,21 @@ pub async fn sign_entity<E: Integrable>(
_ => Error::KeyholderSend,
})?;
diesel::delete(integrity_envelope::table)
.filter(integrity_envelope::entity_kind.eq(entity_kind))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id: entity_id,
payload_version: E::VERSION ,
entity_kind: entity_kind.to_string(),
entity_id,
payload_version,
key_version,
mac: mac.to_vec(),
mac,
})
.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)?;
@@ -149,55 +121,59 @@ pub async fn sign_entity<E: Integrable>(
Ok(())
}
pub async fn verify_entity<E: Integrable>(
pub async fn verify_entity(
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();
entity: &impl IntegrityEntity,
) -> Result<(), Error> {
let entity_kind = entity.entity_kind();
let entity_id = entity.entity_id_bytes();
let expected_payload_version = entity.payload_version();
let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_kind.eq(entity_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 },
diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind },
other => Error::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != E::VERSION {
if envelope.payload_version != expected_payload_version {
return Err(Error::PayloadVersionMismatch {
entity_kind: E::KIND,
expected: E::VERSION,
entity_kind,
expected: expected_payload_version,
found: envelope.payload_version,
});
}
let payload = postcard::to_stdvec(entity)?;
let payload = entity.canonical_payload_bytes();
let payload_hash = payload_hash(&payload);
let mac_input = build_mac_input(
E::KIND,
entity_kind,
&entity_id,
envelope.payload_version,
&payload_hash,
);
let result = keyholder
let ok = keyholder
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await
;
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
_ => Error::KeyholderSend,
})?;
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),
if !ok {
return Err(Error::MacMismatch { entity_kind });
}
Ok(())
}
#[cfg(test)]
@@ -212,16 +188,31 @@ mod tests {
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::{Error, Integrable, sign_entity, verify_entity};
use super::{Error, IntegrityEntity, sign_entity, verify_entity};
#[derive(Clone, serde::Serialize)]
#[derive(Clone)]
struct DummyEntity {
id: i32,
payload_version: i32,
payload: Vec<u8>,
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
impl IntegrityEntity for DummyEntity {
fn entity_kind(&self) -> &'static str {
"dummy_entity"
}
fn entity_id_bytes(&self) -> Vec<u8> {
self.id.to_be_bytes().to_vec()
}
fn payload_version(&self) -> i32 {
self.payload_version
}
fn canonical_payload_bytes(&self) -> Vec<u8> {
self.payload.clone()
}
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
@@ -241,25 +232,24 @@ mod tests {
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
id: 7,
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
sign_entity(&mut conn, &keyholder, &entity).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))
.filter(schema::integrity_envelope::entity_id.eq(entity.entity_id_bytes()))
.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();
verify_entity(&mut conn, &keyholder, &entity).await.unwrap();
}
#[tokio::test]
@@ -268,24 +258,23 @@ mod tests {
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
id: 11,
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
sign_entity(&mut conn, &keyholder, &entity).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))
.filter(schema::integrity_envelope::entity_id.eq(entity.entity_id_bytes()))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
let err = verify_entity(&mut conn, &keyholder, &entity)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
@@ -297,21 +286,20 @@ mod tests {
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
id: 21,
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap();
sign_entity(&mut conn, &keyholder, &entity).await.unwrap();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID)
let err = verify_entity(&mut conn, &keyholder, &tampered)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));

View File

@@ -3,10 +3,10 @@ use crate::context::ServerContext;
pub mod actors;
pub mod context;
pub mod crypto;
pub mod db;
pub mod evm;
pub mod grpc;
pub mod integrity;
pub mod safe_cell;
pub mod utils;

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() -> anyhow::Result<()> {
async fn main() -> miette::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();
tracing_subscriber::fmt()
@@ -46,11 +46,11 @@ async fn main() -> anyhow::Result<()> {
tonic::transport::Server::builder()
.tls_config(tls)
.map_err(|err| anyhow!("Failed to setup TLS: {err}"))?
.map_err(|err| miette!("Faild to setup TLS: {err}"))?
.add_service(ArbiterServiceServer::new(Server::new(context)))
.serve(addr)
.await
.map_err(|e| anyhow!("gRPC server error: {e}"))?;
.map_err(|e| miette::miette!("gRPC server error: {e}"))?;
unreachable!("gRPC server should run indefinitely");
}

View File

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

View File

@@ -1,8 +1,7 @@
use std::collections::HashSet;
use arbiter_server::{
actors::keyholder::Error,
crypto::encryption::v1::Nonce,
actors::keyholder::{Error, encryption::v1},
db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
@@ -103,7 +102,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 = Nonce::default();
let mut expected = v1::Nonce::default();
for _ in 0..=i {
expected.increment();
}

View File

@@ -3,12 +3,9 @@ use arbiter_server::{
actors::{
GlobalActors,
bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
user_agent::{AuthPublicKey, UserAgentConnection, auth},
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl;
@@ -21,13 +18,6 @@ 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();
@@ -93,6 +83,7 @@ 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()
@@ -107,39 +98,21 @@ 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();
let id: i32 = insert_into(schema::useragent_client::table)
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)
.execute(&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();
@@ -149,6 +122,7 @@ 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()),
@@ -157,6 +131,7 @@ pub async fn test_challenge_auth() {
.await
.unwrap();
// Read the challenge response
let response = test_transport
.recv()
.await
@@ -193,21 +168,14 @@ pub async fn test_challenge_auth() {
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
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();
// Pre-register key with key_type
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
@@ -227,67 +195,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
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()),
@@ -308,6 +215,7 @@ 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);
@@ -318,10 +226,8 @@ 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!(
expected_err,
task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution)
));
}

View File

@@ -2,17 +2,14 @@ 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};
@@ -151,4 +148,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,6 +30,7 @@ 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 [];
@@ -43,6 +44,7 @@ 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,
};
@@ -92,6 +94,7 @@ class GrantCard extends ConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
@@ -101,6 +104,7 @@ class GrantCard extends ConsumerWidget {
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
@@ -110,6 +114,7 @@ class GrantCard extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
@@ -179,6 +184,7 @@ class GrantCard extends ConsumerWidget {
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(