2 Commits

Author SHA1 Message Date
hdbg
992accc6fa docs: add recovery operators and multi-operator details 2026-04-05 07:57:52 +00:00
hdbg
69117c3bc4 docs: add multi-operator governance section 2026-04-05 07:57:52 +00:00
55 changed files with 614 additions and 5609 deletions

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 |

View File

@@ -48,10 +48,6 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3"
backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]]
version = "0.9.126"
backend = "cargo:cargo-nextest"
@@ -115,37 +111,30 @@ backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]]
version = "1.93.0"

View File

@@ -12,7 +12,6 @@ protoc = "29.6"
python = "3.14.3"
ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9"
"cargo:cargo-mutants" = "27.0.0"
[tasks.codegen]
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']

View File

@@ -36,10 +36,6 @@ message GasLimitExceededViolation {
}
message EvalViolation {
message ChainIdMismatch {
uint64 expected = 1;
uint64 actual = 2;
}
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
@@ -47,8 +43,6 @@ message EvalViolation {
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7;
}
}

View File

@@ -1 +0,0 @@
test_tool = "nextest"

2
server/.gitignore vendored
View File

@@ -1,2 +0,0 @@
mutants.out/
mutants.out.old/

49
server/Cargo.lock generated
View File

@@ -738,31 +738,22 @@ dependencies = [
"ed25519-dalek",
"fatality",
"futures",
"hmac",
"insta",
"k256",
"kameo",
"macro_rules_attribute",
"memsafe",
"mutants",
"paste",
"pem",
"proptest",
"prost",
"prost-types",
"rand 0.10.0",
"rcgen",
"restructed",
"rsa",
"rstest",
"rustls",
"secrecy",
"serde_with",
"sha2 0.10.9",
"smlang",
"spki",
"strum 0.28.0",
"subtle",
"test-log",
"thiserror 2.0.18",
"tokio",
@@ -1963,7 +1954,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",
]
@@ -1976,7 +1966,6 @@ dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"rand_core 0.10.0",
"serde",
"sha2 0.11.0-rc.5",
"subtle",
"zeroize",
@@ -2062,7 +2051,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]]
@@ -3059,22 +3048,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "macro_rules_attribute"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520"
dependencies = [
"macro_rules_attribute-proc_macro",
"paste",
]
[[package]]
name = "macro_rules_attribute-proc_macro"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3197,12 +3170,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "mutants"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56"
[[package]]
name = "nom"
version = "7.1.3"
@@ -3219,7 +3186,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]]
@@ -3244,7 +3211,6 @@ dependencies = [
"num-iter",
"num-traits",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
]
@@ -3667,9 +3633,9 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.11.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
@@ -4184,7 +4150,6 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"serde",
"sha2 0.10.9",
"signature 2.2.0",
"spki",
@@ -4320,7 +4285,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4736,7 +4701,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]]
@@ -4930,7 +4895,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]

View File

@@ -42,6 +42,4 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"
miette = { version = "7.6.0", features = ["fancy", "serde"] }

View File

@@ -59,10 +59,6 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
#[expect(
dead_code,
reason = "constructor may be used in future extensions, e.g. to support wallet listing"
)]
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,

View File

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

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"] }
@@ -47,25 +46,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"
spki.workspace = true
alloy.workspace = true
prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
macro_rules_attribute = "0.2.2"
paste = "1.0.15"
[dev-dependencies]
insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] }

View File

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

View File

@@ -4,7 +4,6 @@ use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use crate::db::{self, DatabasePool, schema};
@@ -45,14 +44,14 @@ pub struct Bootstrapper {
impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let row_count: i64 = {
let mut conn = db.get().await?;
let mut conn = db.get().await?;
schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?
};
let row_count: i64 = schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?;
drop(conn);
let token = if row_count == 0 {
let token = generate_token().await?;
@@ -70,13 +69,7 @@ impl Bootstrapper {
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
match &self.token {
Some(expected) => {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}
Some(expected) => *expected == token,
None => false,
}
}

View File

@@ -9,16 +9,14 @@ use diesel::{
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
use kameo::{actor::ActorRef, error::SendError};
use kameo::error::SendError;
use tracing::error;
use crate::{
actors::{
client::{ClientConnection, ClientCredentials, ClientProfile},
client::{ClientConnection, ClientProfile},
flow_coordinator::{self, RequestClientApproval},
keyholder::KeyHolder,
},
crypto::integrity::{self, Verified, verified::VerifiedFieldsAccessor},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
@@ -32,8 +30,6 @@ pub enum Error {
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")]
@@ -42,13 +38,6 @@ pub enum Error {
Transport,
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::DatabaseOperationFailed
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError {
#[error("Internal error")]
@@ -76,36 +65,17 @@ pub enum Outbound {
AuthSuccess,
}
/// Returns the current nonce and client ID for a registered client.
/// Returns `None` if the pubkey is not registered.
async fn get_current_nonce_and_id(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<Option<(i32, i32)>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
pub struct ClientInfo {
pub id: i32,
pub current_nonce: i32,
}
/// Atomically increments the nonce and re-signs the integrity envelope.
/// Returns the new nonce, which is used as the challenge nonce.
async fn create_nonce(
/// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
) -> Result<i32, Error> {
) -> Result<Option<ClientInfo>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
@@ -114,35 +84,34 @@ async fn create_nonce(
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(program_client::table)
let Some((client_id, current_nonce)) = program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(program_client::nonce + 1))
.returning((program_client::id, program_client::nonce))
.get_result(conn)
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(conn)
.await
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity sign failed after nonce update");
Error::DatabaseOperationFailed
})?
.drop_verification_provenance();
Ok(new_nonce)
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn approve_new_client(
@@ -170,64 +139,45 @@ async fn approve_new_client(
async fn insert_client(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
metadata: &ClientMetadata,
) -> Result<Verified<i32>, Error> {
) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client};
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
const NONCE_START: i32 = 1;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(NONCE_START),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(conn)
.await?;
let verified_id = integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce: NONCE_START,
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?
.unqualify_origin();
Ok(verified_id)
})
})
.await
Ok(client_id)
}
async fn sync_client_metadata(
@@ -337,10 +287,7 @@ where
Ok(())
}
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<Verified<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,
{
@@ -348,29 +295,8 @@ where
return Err(Error::Transport);
};
// fixme! triage needed: probable regretion since in match->Some get_current_nonce_and_id called only once instead of twice
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
Some((nonce, id)) => {
let mut db_conn = props.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
integrity::verify_entity(
&mut db_conn,
&props.actors.key_holder,
ClientCredentials { pubkey, nonce },
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?
.inherit()
.entity_id
.unqualify_origin()
}
let info = match get_client_and_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => {
approve_new_client(
&props.actors,
@@ -380,13 +306,16 @@ where
},
)
.await?;
insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
ClientInfo {
id: client_id,
current_nonce: 0,
}
}
};
sync_client_metadata(&props.db, *client_id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
@@ -396,5 +325,5 @@ where
Error::Transport
})?;
Ok(client_id)
Ok(info.id)
}

View File

@@ -4,7 +4,6 @@ use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::{Integrable, hashing::Hashable},
db,
};
@@ -14,22 +13,6 @@ pub struct ClientProfile {
pub metadata: ClientMetadata,
}
pub struct ClientCredentials {
pub pubkey: ed25519_dalek::VerifyingKey,
pub nonce: i32,
}
impl Integrable for ClientCredentials {
const KIND: &'static str = "client_credentials";
}
impl Hashable for ClientCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.pubkey.as_bytes());
self.nonce.hash(hasher);
}
}
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,

View File

@@ -5,25 +5,23 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{
actors::{
GlobalActors,
client::ClientConnection,
evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient,
keyholder::KeyHolderState,
},
crypto::integrity::Verified,
db,
evm::VetError,
};
#[cfg(test)]
use crate::{actors::GlobalActors, db};
pub struct ClientSession {
props: ClientConnection,
client_id: Verified<i32>,
client_id: i32,
}
impl ClientSession {
pub(crate) fn new(props: ClientConnection, client_id: Verified<i32>) -> Self {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props, client_id }
}
}
@@ -56,7 +54,7 @@ impl ClientSession {
.actors
.evm
.ask(ClientSignTransaction {
client_id: *self.client_id,
client_id: self.client_id,
wallet_address,
transaction,
})
@@ -94,12 +92,11 @@ impl Actor for ClientSession {
}
impl ClientSession {
#[cfg(test)]
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self {
props,
client_id: Verified::new_unchecked(0),
client_id: 0,
}
}
}

View File

@@ -8,16 +8,15 @@ use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity::{self, Integrable, Verified, hashing::Hashable},
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,
},
},
@@ -26,37 +25,11 @@ use crate::{
pub use crate::evm::safe_signer;
/// Hashable structure for wallet integrity protection.
/// Binds the encrypted private key to the wallet address using HMAC.
pub struct EvmWalletIntegrity {
pub address: Vec<u8>, // 20-byte Ethereum address
pub aead_encrypted_id: i32, // Reference to encrypted key material
}
impl Hashable for EvmWalletIntegrity {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(&self.address);
hasher.update(self.aead_encrypted_id.to_be_bytes());
}
}
impl Integrable for EvmWalletIntegrity {
const KIND: &'static str = "evm_wallet";
}
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionError {
#[error("Wallet not found")]
WalletNotFound,
#[error("Wallet integrity check failed")]
WalletIntegrityCheckFailed,
#[error(
"Decrypted key does not correspond to wallet address (CRITICAL: possible key substitution attack)"
)]
KeyAddressMismatch,
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
@@ -71,9 +44,6 @@ pub enum SignTransactionError {
#[error("Policy error: {0}")]
Vet(#[from] evm::VetError),
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error)]
@@ -86,9 +56,6 @@ pub enum Error {
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Actor)]
@@ -104,7 +71,7 @@ impl EvmActor {
// is it safe to seed rng from system once?
// todo: audit
let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone(), keyholder.clone());
let engine = evm::Engine::new(db.clone());
Self {
keyholder,
db,
@@ -117,7 +84,7 @@ impl EvmActor {
#[messages]
impl EvmActor {
#[message]
pub async fn generate(&mut self) -> Result<(Verified<i32>, Address), Error> {
pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
@@ -129,7 +96,7 @@ impl EvmActor {
.map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id: i32 = insert_into(schema::evm_wallet::table)
let wallet_id = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
@@ -139,17 +106,7 @@ impl EvmActor {
.await
.map_err(DatabaseError::from)?;
// Sign integrity envelope to bind encrypted key to wallet address
let wallet_integrity = EvmWalletIntegrity {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
};
let verified_wallet_id =
integrity::sign_entity(&mut conn, &self.keyholder, &wallet_integrity, wallet_id)
.await?
.unqualify_origin();
Ok((verified_wallet_id, address))
Ok((wallet_id, address))
}
#[message]
@@ -175,59 +132,46 @@ impl EvmActor {
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<integrity::Verified<i32>, Error> {
) -> Result<i32, DatabaseError> {
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
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
}
}
#[message]
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let keyholder = self.keyholder.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok(())
}
#[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
.map_err(DatabaseError::from)?)
}
#[message]
@@ -246,23 +190,9 @@ impl EvmActor {
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
// Verify wallet integrity envelope
let wallet = integrity::verify_entity(
&mut conn,
&self.keyholder,
EvmWalletIntegrity {
address: wallet.address.clone(),
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id,
)
.await
.map_err(|_| SignTransactionError::WalletIntegrityCheckFailed)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.entity_id))
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn)
.await
@@ -295,23 +225,9 @@ impl EvmActor {
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
// Verify wallet integrity envelope to ensure encrypted key is bound to address
let wallet = integrity::verify_entity(
&mut conn,
&self.keyholder,
EvmWalletIntegrity {
address: wallet.address.clone(),
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id,
)
.await
.map_err(|_| SignTransactionError::WalletIntegrityCheckFailed)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.entity_id))
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn)
.await
@@ -330,12 +246,6 @@ impl EvmActor {
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
// Verify that the decrypted key's derived address matches the wallet address
// This prevents an attacker from substituting one wallet's key with another's even if they compromised the DB
if signer.address() != wallet_address {
return Err(SignTransactionError::KeyAddressMismatch);
}
self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;

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,21 +133,24 @@ 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 {
let params = {
#[cfg(debug_assertions)]
{
argon2::Params::new(8, 1, 1, None).unwrap()
}
#[cfg(not(debug_assertions))]
{
argon2::Params::new(262_144, 3, 4, None).unwrap()
}
};
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);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
@@ -137,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() {
@@ -149,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();
@@ -166,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,11 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
use crate::{
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 +17,9 @@ use crate::{
},
safe_cell::SafeCellHandle as _,
};
use encryption::v1::{self, KeyCell, Nonce};
pub mod encryption;
#[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -111,13 +106,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)
@@ -140,12 +136,12 @@ impl KeyHolder {
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 +216,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 +236,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 +247,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 +283,6 @@ impl KeyHolder {
let State::Unsealed {
root_key,
root_key_history_id,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
@@ -324,60 +320,6 @@ impl KeyHolder {
self.state.discriminant()
}
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
Ok((*root_key_history_id, mac))
}
#[message]
pub fn verify_integrity(
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
) -> Result<bool, Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
if *root_key_history_id != key_version {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message]
pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed {

View File

@@ -30,26 +30,10 @@ pub enum Error {
}
impl Error {
#[track_caller]
pub(super) fn internal(details: impl Into<String>, err: &impl std::fmt::Debug) -> Self {
let details = details.into();
let caller = std::panic::Location::caller();
error!(
caller_file = %caller.file(),
caller_line = caller.line(),
caller_column = caller.column(),
details = %details,
error = ?err,
"Internal error"
);
Self::Internal { details }
}
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
Self::internal("Database error", &e)
fn internal(details: impl Into<String>) -> Self {
Self::Internal {
details: details.into(),
}
}
}

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;
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,
db::{DatabasePool, schema::useragent_client},
db::schema,
};
pub struct ChallengeRequest {
@@ -43,167 +40,61 @@ 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> {
let mut db_conn = db
.get()
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
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")
})?;
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
.optional()
.map_err(|e| Error::internal("Database operation failed", &e))?
.map_err(|e| {
error!(error = ?e, "Database error");
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::internal("Database unavailable", &e))?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let attestation_status = integrity::check_entity_attestation(
&mut db_conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| Error::internal("Integrity verification failed", &e))?;
use integrity::AttestationStatus as AS;
// SAFETY (policy): challenge auth must work in both vault states.
// While sealed, integrity checks can only report `Unavailable` because key material is not
// accessible. While unsealed, the same check can report `Attested`.
// This path intentionally accepts both outcomes to keep challenge auth available across state
// transitions; stricter verification is enforced in sensitive post-auth flows.
match attestation_status {
AS::Attested | AS::Unavailable => Ok(()),
}
}
async fn create_nonce(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<i32, Error> {
let mut db_conn = db
.get()
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
let new_nonce = db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_stored_bytes()))
.filter(useragent_client::key_type.eq(pubkey.key_type()))
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
.returning((useragent_client::id, useragent_client::nonce))
.get_result(conn)
.await
.map_err(|e| Error::internal("Database operation failed", &e))?;
integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| Error::internal("Database error", &e))?
.drop_verification_provenance();
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()
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type),
))
.execute(&mut conn)
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
conn.transaction(|conn| {
Box::pin(async move {
const NONCE_START: i32 = 1;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((
useragent_client::public_key.eq(pubkey_bytes),
useragent_client::nonce.eq(NONCE_START),
useragent_client::key_type.eq(key_type),
))
.returning(useragent_client::id)
.get_result(conn)
.await
.map_err(|e| Error::internal("Database operation failed", &e))?;
if let Err(e) = integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
},
id,
)
.await
{
match e {
integrity::Error::Keyholder(
crate::actors::keyholder::Error::NotBootstrapped,
) => {
// IMPORTANT: bootstrap-token auth must work before the vault has a root key.
// We intentionally allow creating the DB row first and backfill envelopes
// after bootstrap/unseal to keep the bootstrap flow possible.
}
other => {
return Err(Error::internal("Failed to register public key", &other));
}
}
}
Result::<_, Error>::Ok(())
})
})
.await?;
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
Ok(())
}
@@ -229,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 }))
@@ -261,31 +151,24 @@ where
token: token.clone(),
})
.await
.map_err(|e| Error::internal("Failed to consume bootstrap token", &e))?;
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
match token_ok {
true => {
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
#[allow(missing_docs)]
@@ -327,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,6 +1,5 @@
use crate::{
actors::{GlobalActors, client::ClientProfile},
crypto::integrity::Integrable,
db::{self, models::KeyType},
};
@@ -14,16 +13,6 @@ pub enum AuthPublicKey {
Rsa(rsa::RsaPublicKey),
}
#[derive(Debug)]
pub struct UserAgentCredentials {
pub pubkey: AuthPublicKey,
pub nonce: i32,
}
impl Integrable for UserAgentCredentials {
const KIND: &'static str = "useragent_credentials";
}
impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
@@ -103,18 +92,3 @@ pub mod session;
pub use auth::authenticate;
pub use session::UserAgentSession;
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for AuthPublicKey {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.to_stored_bytes());
}
}
impl Hashable for UserAgentCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.pubkey.hash(hasher);
self.nonce.hash(hasher);
}
}

View File

@@ -10,6 +10,7 @@ use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::db::models::{
@@ -17,10 +18,6 @@ use crate::db::models::{
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
crypto::integrity::{self, Verified},
};
use crate::{
actors::{
evm::{
@@ -32,67 +29,11 @@ use crate::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
user_agent::{AuthPublicKey, UserAgentCredentials},
},
db::schema::useragent_client,
safe_cell::SafeCellHandle as _,
};
fn is_vault_sealed_from_evm<M>(err: &SendError<M, crate::actors::evm::Error>) -> bool {
matches!(
err,
SendError::HandlerError(crate::actors::evm::Error::Keyholder(
keyholder::Error::NotBootstrapped
)) | SendError::HandlerError(crate::actors::evm::Error::Integrity(
crate::crypto::integrity::Error::Keyholder(keyholder::Error::NotBootstrapped)
))
)
}
impl UserAgentSession {
async fn backfill_useragent_integrity(&self) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
let keyholder = self.props.actors.key_holder.clone();
conn.transaction(|conn| {
Box::pin(async move {
let rows: Vec<(i32, i32, Vec<u8>, crate::db::models::KeyType)> =
useragent_client::table
.select((
useragent_client::id,
useragent_client::nonce,
useragent_client::public_key,
useragent_client::key_type,
))
.load(conn)
.await?;
for (id, nonce, public_key, key_type) in rows {
let pubkey = AuthPublicKey::try_from((key_type, public_key)).map_err(|e| {
Error::internal(format!("Invalid user-agent key in db: {e}"))
})?;
integrity::sign_entity(
conn,
&keyholder,
&UserAgentCredentials { pubkey, nonce },
id,
)
.await
.map_err(|e| {
Error::internal(format!("Failed to backfill user-agent integrity: {e}"))
})?
.drop_verification_provenance();
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
@@ -179,15 +120,6 @@ pub enum SignTransactionError {
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
@@ -250,7 +182,6 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_useragent_integrity().await?;
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -312,7 +243,6 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_useragent_integrity().await?;
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -358,9 +288,7 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(
&mut self,
) -> Result<(Verified<i32>, Address), Error> {
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
@@ -388,15 +316,12 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(
&mut self,
) -> Result<Vec<Grant<SpecificGrant>>, GrantMutationError> {
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(GrantMutationError::Internal)
Err(Error::internal("Failed to list EVM grants"))
}
}
}
@@ -406,7 +331,7 @@ impl UserAgentSession {
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<Verified<i32>, GrantMutationError> {
) -> Result<i32, Error> {
match self
.props
.actors
@@ -415,33 +340,26 @@ impl UserAgentSession {
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
Err(Error::internal("Failed to create EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> {
match self
.props
.actors
.evm
.ask(UseragentDeleteGrant {
_grant_id: grant_id,
})
.ask(UseragentDeleteGrant { grant_id })
.await
{
Ok(()) => Ok(()),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(GrantMutationError::Internal)
Err(Error::internal("Failed to delete EVM grant"))
}
}
}

View File

@@ -1,4 +1,4 @@
use std::{net::Ipv4Addr, string::FromUtf8Error};
use std::{net::{IpAddr, Ipv4Addr}, string::FromUtf8Error};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};

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

@@ -1,319 +0,0 @@
use crate::actors::keyholder;
use hmac::Hmac;
use sha2::Sha256;
use std::future::Future;
use std::ops::Deref;
use std::pin::Pin;
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 _;
pub mod hashing;
pub mod verified;
use self::hashing::Hashable;
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope as IntegrityEnvelopeRow, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
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 use self::verified::{Nested, Root, VerificationOrigin, Verified};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")]
Keyholder(#[from] keyholder::Error),
#[error("KeyHolder mailbox error")]
KeyholderSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
found: i32,
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum AttestationStatus {
Attested,
Unavailable,
}
pub trait Integrable: Hashable {
const KIND: &'static str;
const VERSION: i32 = 1;
}
impl<T: Integrable> Integrable for &T {
const KIND: &'static str = T::KIND;
const VERSION: i32 = T::VERSION;
}
#[derive(Debug, Clone)]
pub struct EntityId(Vec<u8>);
impl Deref for EntityId {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<i32> for EntityId {
fn from(value: i32) -> Self {
Self(value.to_be_bytes().to_vec())
}
}
impl From<&'_ [u8]> for EntityId {
fn from(bytes: &'_ [u8]) -> Self {
Self(bytes.to_vec())
}
}
pub async fn lookup_verified<E, Id, C, F, Fut>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
entity_id: Id,
load: F,
) -> Result<Verified<Entity<E, Id>, Nested<E>>, Error>
where
C: AsyncConnection<Backend = Sqlite>,
E: Integrable,
Id: Into<EntityId> + Clone,
F: FnOnce(&mut C) -> Fut,
Fut: Future<Output = Result<E, db::DatabaseError>>,
{
let entity = load(conn).await?;
verify_entity(conn, keyholder, entity, entity_id).await
}
pub async fn lookup_verified_from_query<E, Id, C, F>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
load: F,
) -> Result<Verified<Entity<E, Id>, Nested<E>>, Error>
where
C: AsyncConnection<Backend = Sqlite> + Send,
E: Integrable,
Id: Into<EntityId> + Clone,
F: for<'a> FnOnce(
&'a mut C,
) -> Pin<
Box<dyn Future<Output = Result<(Id, E), db::DatabaseError>> + Send + 'a>,
>,
{
let (entity_id, entity) = load(conn).await?;
verify_entity(conn, keyholder, entity, entity_id).await
}
pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
as_entity_id: Id,
) -> Result<Verified<Id, Nested<E>>, Error> {
let payload_hash = payload_hash(entity);
let entity_id = as_entity_id.clone().into();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) = keyholder
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
_ => Error::KeyholderSend,
})?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id: entity_id.to_vec(),
payload_version: E::VERSION,
key_version,
mac: mac.to_vec(),
})
.on_conflict((
integrity_envelope::entity_id,
integrity_envelope::entity_kind,
))
.do_update()
.set((
integrity_envelope::payload_version.eq(E::VERSION),
integrity_envelope::key_version.eq(key_version),
integrity_envelope::mac.eq(mac),
))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(Verified::<Id, Nested<E>>::new(as_entity_id))
}
pub async fn check_entity_attestation<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl Into<EntityId>,
) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into();
let envelope: IntegrityEnvelopeRow = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&*entity_id))
.first(conn)
.await
.map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope {
entity_kind: E::KIND,
},
other => Error::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != E::VERSION {
return Err(Error::PayloadVersionMismatch {
entity_kind: E::KIND,
expected: E::VERSION,
found: envelope.payload_version,
});
}
let payload_hash = payload_hash(entity);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let result = keyholder
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await;
match result {
Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => {
Ok(AttestationStatus::Unavailable)
}
Err(_) => Err(Error::KeyholderSend),
}
}
#[derive(Debug, Clone, crate::VerifiedFields!)]
#[repr(C)]
pub struct Entity<E, Id> {
pub entity: E,
pub entity_id: Id,
}
impl<E, Id> Deref for Entity<E, Id> {
type Target = E;
fn deref(&self) -> &Self::Target {
&self.entity
}
}
pub async fn verify_entity<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: E,
entity_id: Id,
) -> Result<Verified<Entity<E, Id>, Nested<E>>, Error> {
match check_entity_attestation(conn, keyholder, &entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(Verified::<Entity<E, Id>, Nested<E>>::new(Entity {
entity,
entity_id,
})),
AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)),
}
}
pub async fn verify_entity_ref<'e, E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &'e E,
entity_id: Id,
) -> Result<Verified<Entity<&'e E, Id>, Nested<E>>, Error> {
match check_entity_attestation(conn, keyholder, entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(Verified::<Entity<&'e E, Id>, Nested<E>>::new(Entity {
entity,
entity_id,
})),
AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)),
}
}
pub async fn delete_envelope<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
entity_id: impl Into<EntityId>,
) -> Result<usize, Error> {
let entity_id = entity_id.into();
let affected = diesel::delete(
integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&*entity_id)),
)
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(affected)
}
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
#[cfg(test)]
mod tests;

View File

@@ -1,107 +0,0 @@
use hmac::digest::Digest;
use std::collections::HashSet;
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
pub trait Hashable {
fn hash<H: Digest>(&self, hasher: &mut H);
}
macro_rules! impl_numeric {
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes());
}
}
)*
};
}
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
impl Hashable for &[u8] {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self);
}
}
impl Hashable for String {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_bytes());
}
}
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable + PartialOrd> Hashable for HashSet<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
match self {
Some(value) => {
hasher.update([1]);
value.hash(hasher);
}
None => hasher.update([0]),
}
}
}
impl<T: Hashable> Hashable for Box<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.as_ref().hash(hasher);
}
}
impl<T: Hashable> Hashable for &T {
fn hash<H: Digest>(&self, hasher: &mut H) {
(*self).hash(hasher);
}
}
impl Hashable for alloy::primitives::Address {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_slice());
}
}
impl Hashable for alloy::primitives::U256 {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_be_bytes::<32>());
}
}
impl Hashable for chrono::Duration {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.num_seconds().to_be_bytes());
}
}
impl Hashable for chrono::DateTime<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.timestamp_millis().to_be_bytes());
}
}

View File

@@ -1,298 +0,0 @@
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use sha2::Digest;
use crate::{
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::hashing::Hashable;
use super::{
Error, Integrable, check_entity_attestation, lookup_verified, lookup_verified_from_query,
sign_entity, verify_entity,
};
#[derive(Clone, Debug)]
struct DummyEntity {
payload_version: i32,
payload: Vec<u8>,
}
impl Hashable for DummyEntity {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.payload_version.hash(hasher);
self.payload.hash(hasher);
}
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
let _ = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap();
}
#[tokio::test]
async fn tampered_mac_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn changed_payload_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = check_entity_attestation(&mut conn, &keyholder, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn strict_verify_fails_closed_while_sealed() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-41";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
drop(keyholder);
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let err = verify_entity(&mut conn, &sealed_keyholder, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
));
let err = lookup_verified(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async {
Ok::<_, db::DatabaseError>(DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
})
})
.await
.unwrap_err();
assert!(matches!(
err,
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
));
}
#[tokio::test]
async fn lookup_verified_supports_loaded_aggregate() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: i32 = 77;
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| async {
Ok::<_, db::DatabaseError>(DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
})
})
.await
.unwrap();
assert_eq!(verified.entity.payload, b"payload-v1".to_vec());
}
#[tokio::test]
async fn extension_trait_lookup_verified_required_works() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: i32 = 79;
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| {
Box::pin(async {
Ok::<_, db::DatabaseError>(DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
})
})
})
.await
.unwrap();
assert_eq!(verified.entity.payload, b"payload-v1".to_vec());
}
#[tokio::test]
async fn lookup_verified_from_query_helpers_work() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: i32 = 80;
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let verified = lookup_verified_from_query(&mut conn, &keyholder, |_| {
Box::pin(async {
Ok::<_, db::DatabaseError>((
ENTITY_ID,
DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
},
))
})
})
.await
.unwrap();
assert_eq!(verified.entity.payload, b"payload-v1".to_vec());
drop(keyholder);
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let err = lookup_verified_from_query(&mut conn, &sealed_keyholder, |_| {
Box::pin(async {
Ok::<_, db::DatabaseError>((
ENTITY_ID,
DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
},
))
})
})
.await
.unwrap_err();
assert!(matches!(
err,
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
));
}

View File

@@ -1,593 +0,0 @@
use std::ops::Deref;
use super::Integrable;
mod private {
pub trait Sealed {}
}
/// Marker trait for type-level verification provenance.
///
/// This trait is intentionally sealed so external code cannot invent arbitrary
/// provenance tags and bypass the intended type-level guarantees.
pub trait VerificationOrigin: private::Sealed {
type Origin: VerificationOrigin;
}
/// Root provenance marker for values directly produced by integrity APIs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Root;
/// Nested provenance marker carrying the source integrable type and previous
/// provenance marker in the chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Nested<From, P: VerificationOrigin = Root>(core::marker::PhantomData<(From, P)>);
impl private::Sealed for Root {}
impl VerificationOrigin for Root {
type Origin = Self;
}
impl<T, P: VerificationOrigin> private::Sealed for Nested<T, P> {}
impl<T, P: VerificationOrigin> VerificationOrigin for Nested<T, P> {
type Origin = P;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
#[must_use = "Verified<T> is a proof-bearing wrapper; use self.drop_verification_provenance() to explicitly discard integrity provenance when needed"]
pub struct Verified<T, O: VerificationOrigin = Root> {
inner: T,
origin: core::marker::PhantomData<O>,
}
impl<T, O: VerificationOrigin> AsRef<Verified<T, O>> for Verified<&T, O> {
fn as_ref(&self) -> &Verified<T, O> {
// SAFETY: `Verified<T>` is `#[repr(transparent)]` over `T`, so `&T`
// and `&Verified<T>` have identical layout.
unsafe { reinterpret_layout_ref::<T, Verified<T, O>>(self.inner) }
}
}
impl<T, U: Integrable, O: VerificationOrigin> Deref for Verified<T, Nested<U, O>> {
type Target = Verified<T, O::Origin>;
fn deref(&self) -> &Self::Target {
// SAFETY: `Verified<T, Nested<U, O>>` is `#[repr(transparent)]` over `T`, so `&Verified<T, Nested<U, O>>`
// and `&Nested<U, O>` have identical layout.
unsafe { reinterpret_layout_ref::<Self, Verified<T, O::Origin>>(self) }
}
}
impl<T> Deref for Verified<T, Root> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T, O: VerificationOrigin> Verified<T, O> {
/// Unwraps the verified value, discarding the integrity provenance.
///
/// The name is intentionally verbose — call sites where provenance is
/// dropped should be easy to find and audit.
pub fn drop_verification_provenance(self) -> T {
self.inner
}
/// Downgrades the origin provenance to any lower nestedness level,
/// e.g. `Verified<T, Nested<Other>>` to `Verified<T, Root>`.
pub fn unqualify_origin<Target: VerificationOrigin>(self) -> Verified<T, Target>
where
O: VerificationOrigin<Origin = Target>,
{
Verified {
inner: self.inner,
origin: core::marker::PhantomData,
}
}
/// Constructs a `Verified<T>` by wrapping a `T`.
pub(super) fn new(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
/// Constructs a `Verified<T>` from a raw value without performing any
/// integrity check. Only available in test builds; use the integrity
/// module's functions to obtain a `Verified<T>` in production code.
#[cfg(test)]
pub(crate) fn new_unchecked(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
/// Reinterprets `&T` as `&Verified<T>`.
#[allow(dead_code)]
pub(super) fn from_ref(from: &T) -> &Self {
// SAFETY: `Self` is `#[repr(transparent)]` over `T`.
unsafe { reinterpret_layout_ref::<T, Self>(from) }
}
}
/// Bit-copies `value: From` into a `To`, suppressing the source destructor so
/// the destination owns the bytes.
///
/// # Safety
///
/// The caller must guarantee that `From` and `To` have identical in-memory
/// layout — the raw bytes that encode a valid `From` must also encode a valid
/// `To`.
///
/// A `union` is used instead of [`std::mem::transmute`] because `transmute`
/// rejects generic source/destination types at the call site even when their
/// sizes are provably equal at monomorphization time.
#[allow(dead_code)]
#[inline]
pub const unsafe fn reinterpret_layout<From, To>(value: From) -> To {
const {
assert!(
::std::mem::size_of::<From>() == ::std::mem::size_of::<To>(),
"reinterpret_layout: source and destination must have identical size"
);
assert!(
::std::mem::align_of::<From>() == ::std::mem::align_of::<To>(),
"reinterpret_layout: source and destination must have identical alignment"
);
}
union Reinterpret<A, B> {
from: ::std::mem::ManuallyDrop<A>,
to: ::std::mem::ManuallyDrop<B>,
}
// SAFETY: caller guarantees layout equivalence (see fn docs). The union
// write-read copies the raw bytes of `value` into a `To` slot, and
// `ManuallyDrop` on the source side suppresses its destructor so the
// destination owns the bytes unambiguously — no double-drop is possible.
unsafe {
::std::mem::ManuallyDrop::into_inner(
Reinterpret {
from: ::std::mem::ManuallyDrop::new(value),
}
.to,
)
}
}
/// Reinterprets `&From` as `&To` via a layout-preserving pointer cast.
///
/// # Safety
///
/// Same invariants as [`reinterpret_layout`].
#[inline]
pub const unsafe fn reinterpret_layout_ref<From, To>(value: &From) -> &To {
const {
assert!(
::std::mem::size_of::<From>() == ::std::mem::size_of::<To>(),
"reinterpret_layout_ref: source and destination must have identical size"
);
assert!(
::std::mem::align_of::<From>() == ::std::mem::align_of::<To>(),
"reinterpret_layout_ref: source and destination must have identical alignment"
);
}
// SAFETY: caller guarantees layout equivalence (see fn docs). A reference
// cast between identically-laid-out types produces a reference with the
// same address and lifetime, which is sound.
unsafe { &*(value as *const From as *const To) }
}
/// Implemented on `Verified<T>` by [`VerifiedFields!`], exposing the field-wise counterpart.
///
/// ## Disclaimer
/// Do not implement this trait manually. It is intended to be implemented only
/// by the `VerifiedFields!` macro, which generates the necessary layout
/// guarantees for sound pointer casts.
///
/// ## Soundness
/// When [`verify_entity`][crate::crypto::integrity::verify_entity] attests an
/// entity, it returns `Verified<T>` — an aggregate proof over the whole value.
/// This trait converts that wrapper into `Counterpart` (e.g.
/// `VerifiedMyStruct`), where every field is individually wrapped in
/// [`Verified`], allowing verified data to flow into functions that require
/// `Verified<FieldType>` without re-verifying.
///
/// ## Safety
/// The conversion is a zero-cost reinterpretation — no copying (beyond a
/// bitwise move in the owned variant) or HMAC work occurs. Soundness rests on
/// identical memory layout between `Verified<T>` and `Counterpart`:
///
/// - `T` carries `#[repr(C)]` (enforced by `@require_repr` in the macro).
/// - `T` does **not** carry `packed` (enforced by `@reject_packed`).
/// - `Counterpart` also carries `#[repr(C)]`, with the same fields in the same
/// order.
/// - Each `Verified<F>` field is `#[repr(transparent)]` over `F`, so its size
/// and alignment match `F` exactly.
/// - `Verified<T>` itself is `#[repr(transparent)]` over `T`.
///
/// As an additional machine-checked guard, [`reinterpret_layout`] and
/// [`reinterpret_layout_ref`] assert size/align equality of the two types at
/// monomorphization time.
///
/// The trait is implemented directly on `Verified<T>` (not on `T`), so no
/// `Deref`-coercion or auto-ref stripping is needed at call sites — the impl
/// is unambiguous.
pub trait VerifiedFieldsAccessor {
/// The field-wise verified counterpart, e.g. `VerifiedMyStruct`.
type Counterpart;
/// Reinterprets `&self` as `&Counterpart` via a layout-preserving pointer cast.
///
/// No data is copied and no re-verification occurs. The returned reference
/// borrows from `self` and has the same lifetime.
fn inherit_ref(&self) -> &Self::Counterpart;
/// Consumes `self` and returns `Counterpart` via a layout-preserving
/// bitwise move.
///
/// The original `Verified<T>` is moved without running its destructor
/// (there is none — `Verified` is a transparent wrapper with no heap
/// allocation), and the returned counterpart owns the original bytes. No
/// re-verification occurs.
fn inherit(self) -> Self::Counterpart;
}
// todo! rewrite macro_rules to derive crate
#[macro_export]
macro_rules! VerifiedFields {
// --- Entry point (no source generics) ---
(
$(#$attr:tt)*
$vis:vis struct $name:ident
{
$(
$field_vis:vis $field_name:ident : $field_ty:ty
),* $(,)?
}
) => {
// Attribute-list checks run in isolation — they only receive the attrs,
// not the struct body.
$crate::VerifiedFields!(@require_repr [$(#$attr)*]);
$crate::VerifiedFields!(@reject_packed [$(#$attr)*]);
paste::paste! {
#[doc = concat!(
"Field-wise verified counterpart of [`", stringify!($name), "`]."
)]
//
// `#[repr(C)]` is required for the pointer casts in `inherit_ref`
// and `inherit` to be sound. Both the source struct (enforced by
// `@require_repr`) and this counterpart carry `#[repr(C)]`, which
// guarantees matching field offsets. Combined with each
// `Verified<F>` being `#[repr(transparent)]` over `F`, the two
// structs have identical memory layout.
//
// `#[repr(transparent)]` is not usable here because it only permits
// a single non-ZST field; multi-field structs would fail to compile.
#[repr(C)]
$vis struct [<Verified $name>]<P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
{
$(
$field_vis $field_name : $crate::crypto::integrity::Verified<$field_ty, P>
),*
}
impl<P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
$crate::crypto::integrity::v1::verified::VerifiedFieldsAccessor
for $crate::crypto::integrity::Verified<$name, P>
{
type Counterpart = [<Verified $name>]<P>;
fn inherit_ref(&self) -> &Self::Counterpart {
// SAFETY: `Self` is `Verified<T>` (transparent over
// `T #[repr(C)]`) and `Self::Counterpart` is `#[repr(C)]`
// with the same fields in the same order, each wrapped in
// a `#[repr(transparent)]` `Verified<F>`. The two types
// therefore have identical memory layout, which
// `reinterpret_layout_ref` re-checks as size/align
// equality at monomorphization.
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout_ref::<
Self,
Self::Counterpart,
>(self)
}
}
fn inherit(self) -> Self::Counterpart {
// SAFETY: identical layout — see `inherit_ref`. The owned
// helper additionally suppresses the source destructor so
// the returned counterpart owns the original bytes (no
// double-drop is possible).
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout::<
Self,
Self::Counterpart,
>(self)
}
}
}
}
};
// --- Entry point (source has generics) ---
(
$(#$attr:tt)*
$vis:vis struct $name:ident <$($gen:tt),*>
{
$(
$field_vis:vis $field_name:ident : $field_ty:ty
),* $(,)?
}
) => {
// Attribute-list checks run in isolation — they only receive the attrs,
// not the struct body.
$crate::VerifiedFields!(@require_repr [$(#$attr)*]);
$crate::VerifiedFields!(@reject_packed [$(#$attr)*]);
paste::paste! {
#[doc = concat!(
"Field-wise verified counterpart of [`", stringify!($name), "`]."
)]
//
// `#[repr(C)]` is required for the pointer casts in `inherit_ref`
// and `inherit` to be sound. Both the source struct (enforced by
// `@require_repr`) and this counterpart carry `#[repr(C)]`, which
// guarantees matching field offsets. Combined with each
// `Verified<F>` being `#[repr(transparent)]` over `F`, the two
// structs have identical memory layout.
//
// `#[repr(transparent)]` is not usable here because it only permits
// a single non-ZST field; multi-field structs would fail to compile.
#[repr(C)]
$vis struct [<Verified $name>]<$($gen),*, P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
{
$(
$field_vis $field_name : $crate::crypto::integrity::Verified<$field_ty, P>
),*
}
impl<$($gen),*, P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
$crate::crypto::integrity::v1::verified::VerifiedFieldsAccessor
for $crate::crypto::integrity::Verified<$name<$($gen),*>, P>
{
type Counterpart = [<Verified $name>]<$($gen),*, P>;
fn inherit_ref(&self) -> &Self::Counterpart {
// SAFETY: `Self` is `Verified<T>` (transparent over
// `T #[repr(C)]`) and `Self::Counterpart` is `#[repr(C)]`
// with the same fields in the same order, each wrapped in
// a `#[repr(transparent)]` `Verified<F>`. The two types
// therefore have identical memory layout, which
// `reinterpret_layout_ref` re-checks as size/align
// equality at monomorphization.
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout_ref::<
Self,
Self::Counterpart,
>(self)
}
}
fn inherit(self) -> Self::Counterpart {
// SAFETY: identical layout — see `inherit_ref`. The owned
// helper additionally suppresses the source destructor so
// the returned counterpart owns the original bytes (no
// double-drop is possible).
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout::<
Self,
Self::Counterpart,
>(self)
}
}
}
}
};
// --- @require_repr: ensure `#[repr(C)]` appears in the attribute list ---
(@require_repr [#[repr(C)] $($rest:tt)*]) => {};
(@require_repr [#$other:tt $($rest:tt)*]) => {
$crate::VerifiedFields!(@require_repr [$($rest)*]);
};
(@require_repr []) => {
::std::compile_error!(
"VerifiedFields requires `#[repr(C)]` on the struct to guarantee field layout"
);
};
// --- @reject_packed: walk attrs and reject any `#[repr(..., packed, ...)]`.
//
// Without this, a packed struct would still fail at monomorphization via
// the const assertions inside the `reinterpret_layout*` helpers, but the
// diagnostic would be much harder to read. `align(N)` is *not* rejected
// here because const assertions catch alignment mismatches cleanly, and
// forbidding it would be unnecessarily restrictive.
(@reject_packed [#[repr($($inner:tt)*)] $($rest:tt)*]) => {
$crate::VerifiedFields!(@reject_packed_inner [$($inner)*]);
$crate::VerifiedFields!(@reject_packed [$($rest)*]);
};
(@reject_packed [#$other:tt $($rest:tt)*]) => {
$crate::VerifiedFields!(@reject_packed [$($rest)*]);
};
(@reject_packed []) => {};
(@reject_packed_inner [packed $($rest:tt)*]) => {
::std::compile_error!(
"VerifiedFields does not support packed layouts; the generated \
counterpart would not share layout with the source struct"
);
};
(@reject_packed_inner [$first:tt $($rest:tt)*]) => {
$crate::VerifiedFields!(@reject_packed_inner [$($rest)*]);
};
(@reject_packed_inner []) => {};
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(VerifiedFields!)]
#[repr(C)]
#[derive(Default, Clone)]
pub struct MyStruct<T> {
pub field1: String,
pub field2: T,
}
fn verify<T>(t: T) -> Verified<T> {
Verified {
inner: t,
origin: core::marker::PhantomData,
}
}
// --- inherit_ref ---
// Verifies that `inherit_ref` returns a reference to the same memory
// address, confirming that no copy is made and the cast is purely a
// reinterpretation.
#[test]
fn inherit_ref_is_same_address() {
let v = verify(MyStruct {
field1: "hello".into(),
field2: 42u32,
});
let fields = v.inherit_ref();
assert_eq!(
&v as *const _ as *const u8, fields as *const _ as *const u8,
"inherit_ref must return a pointer to the same memory, not a copy"
);
}
// Verifies that field values are correctly accessible after `inherit_ref`.
#[test]
fn inherit_ref_field_values() {
let v = verify(MyStruct {
field1: "hello".into(),
field2: 99u32,
});
let fields = v.inherit_ref();
assert_eq!(*fields.field1, "hello");
assert_eq!(*fields.field2, 99u32);
}
// Verifies that casting the counterpart back to `Verified<T>` via a raw
// pointer lands on the original address — confirms the round-trip is a
// pure reinterpretation.
#[test]
fn inherit_ref_cast_roundtrip() {
let v = verify(MyStruct {
field1: "x".into(),
field2: 7u32,
});
let fields: &VerifiedMyStruct<u32, Root> = v.inherit_ref();
let back_ptr =
fields as *const VerifiedMyStruct<u32, Root> as *const Verified<MyStruct<u32>>;
assert_eq!(
back_ptr as *const u8, &v as *const _ as *const u8,
"cast of counterpart must point back to the same Verified<T>"
);
}
// ZST fields must still produce a counterpart with identical layout — the
// const asserts in `reinterpret_layout_ref` guard this at monomorphization.
#[test]
fn inherit_ref_with_zst_field() {
#[derive(VerifiedFields!)]
#[repr(C)]
struct WithZst {
pub unit: (),
pub val: u64,
}
let v = Verified::<WithZst>::new_unchecked(WithZst { unit: (), val: 777 });
let fields = v.inherit_ref();
assert_eq!(*fields.val, 777);
assert_eq!(*fields.unit, ());
}
// --- inherit ---
// Verifies that `inherit` preserves field values in the owned counterpart.
#[test]
fn inherit_field_values() {
let v = verify(MyStruct {
field1: "world".into(),
field2: 1234u64,
});
let VerifiedMyStruct { field1, field2 } = v.inherit();
assert_eq!(*field1, "world");
assert_eq!(*field2, 1234u64);
}
// Verifies that `inherit` does not double-drop the inner value.
// If `ManuallyDrop` handling is wrong, running under Miri or with a drop
// counter catches a double-free.
#[test]
fn inherit_no_double_drop() {
use std::sync::atomic::{AtomicUsize, Ordering};
static DROP_COUNT: AtomicUsize = AtomicUsize::new(0);
struct DropCounter;
impl Drop for DropCounter {
fn drop(&mut self) {
DROP_COUNT.fetch_add(1, Ordering::Relaxed);
}
}
#[derive(VerifiedFields!)]
#[repr(C)]
struct WithDrop {
pub val: DropCounter,
}
DROP_COUNT.store(0, Ordering::Relaxed);
{
let v = Verified::<WithDrop>::new_unchecked(WithDrop { val: DropCounter });
let _ = v.inherit();
}
assert_eq!(
DROP_COUNT.load(Ordering::Relaxed),
1,
"DropCounter must be dropped exactly once"
);
}
// --- Verified::from_ref ---
#[test]
fn from_ref_is_same_address() {
let val = 42u32;
let verified: &Verified<u32> = Verified::from_ref(&val);
assert_eq!(
&val as *const u32 as *const u8, verified as *const _ as *const u8,
"from_ref must alias the original reference, not copy the value"
);
}
#[test]
fn from_ref_value_preserved() {
let val = String::from("test");
let verified: &Verified<String> = Verified::from_ref(&val);
assert_eq!(**verified, "test");
}
// --- AsRef<Verified<T>> for Verified<&T> ---
#[test]
fn verified_ref_as_ref_is_same_address() {
let val = 99u32;
let vref: Verified<&u32> = Verified::new_unchecked(&val);
let v: &Verified<u32> = vref.as_ref();
assert_eq!(
&val as *const u32 as *const u8, v as *const _ as *const u8,
"AsRef<Verified<T>> for Verified<&T> must alias the referent, not copy it"
);
}
}

View File

@@ -133,7 +133,6 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool)
}
#[mutants::skip]
pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _};

View File

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

View File

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

View File

@@ -8,11 +8,8 @@ use alloy::{
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::keyholder::KeyHolder,
crypto::integrity::{self, Verified, VerifiedEntity, verified::VerifiedFieldsAccessor},
db::{
self, DatabaseError,
models::{
@@ -21,8 +18,8 @@ use crate::{
schema::{self, evm_transaction_log},
},
evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
};
@@ -39,9 +36,6 @@ pub enum PolicyError {
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
NoMatchingGrant,
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error)]
@@ -63,15 +57,6 @@ pub enum AnalyzeError {
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 {
@@ -90,14 +75,6 @@ async fn check_shared_constraints(
let mut violations = Vec::new();
let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
violations.push(EvalViolation::InvalidTime);
@@ -138,7 +115,6 @@ async fn check_shared_constraints(
// Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine {
db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>,
}
impl Engine {
@@ -147,54 +123,18 @@ impl Engine {
context: EvalContext,
meaning: &P::Meaning,
run_kind: RunKind,
) -> Result<(), PolicyError>
where
P::Settings: Clone,
{
) -> Result<(), PolicyError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let verified_settings =
match integrity::lookup_verified_from_query(&mut conn, &self.keyholder, |conn| {
let context = context.clone();
Box::pin(async move {
let grant = P::try_find_grant(&context, conn)
.await
.map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::from(diesel::result::Error::NotFound))?;
Ok::<_, DatabaseError>((grant.common_settings_id, grant.settings))
})
})
.await
{
Ok(verified) => verified,
Err(integrity::Error::Database(DatabaseError::Connection(
diesel::result::Error::NotFound,
))) => return Err(PolicyError::NoMatchingGrant),
Err(err) => return Err(PolicyError::Integrity(err)),
};
let mut grant = P::try_find_grant(&context, &mut conn)
let grant = P::try_find_grant(&context, &mut conn)
.await
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
// IMPORTANT: policy evaluation uses extra non-integrity fields from Grant
// (e.g., per-policy ids), so we currently reload Grant after the query-native
// integrity check over canonicalized settings.
grant.settings = verified_settings
.inherit()
.entity
.drop_verification_provenance();
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
@@ -203,14 +143,12 @@ impl Engine {
if !violations.is_empty() {
return Err(PolicyError::Violations(violations));
}
if run_kind == RunKind::Execution {
} else 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(),
@@ -234,19 +172,15 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db, keyholder }
pub fn new(db: db::DatabasePool) -> Self {
Self { db }
}
pub async fn create_grant<P: Policy>(
&self,
full_grant: CombinedSettings<P::Settings>,
) -> Result<Verified<i32>, DatabaseError>
where
P::Settings: Clone,
{
full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError> {
let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn
.transaction(|conn| {
@@ -255,25 +189,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),
@@ -283,67 +217,41 @@ impl Engine {
.get_result(conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
let verified_entity_id =
integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(verified_entity_id)
P::create_grant(&basic_grant, &full_grant.specific, conn).await
})
})
.await?;
Ok(id.unqualify_origin())
Ok(id)
}
async fn list_one_kind<Kind: Policy, Y>(
&self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<Vec<Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
let all_grants = Kind::find_all_grants(conn)
.await
.map_err(DatabaseError::from)?;
let mut verified_grants = Vec::with_capacity(all_grants.len());
// Verify integrity of all grants before returning any results.
for grant in all_grants {
let VerifiedEntity {
entity: verified_settings,
entity_id: _,
} = integrity::verify_entity(
conn,
&self.keyholder,
grant.settings,
grant.common_settings_id,
)
.await?
.inherit();
verified_grants.push(Grant {
id: grant.id,
common_settings_id: grant.common_settings_id,
settings: verified_settings
.drop_verification_provenance()
.generalize(),
});
}
Ok(verified_grants)
}
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),
}),
);
Ok(grants)
}
@@ -389,255 +297,3 @@ impl Engine {
Err(VetError::UnsupportedTransactionType)
}
}
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
fn context() -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ZERO,
calldata: Bytes::new(),
max_fee_per_gas: 100,
max_priority_fee_per_gas: 10,
}
}
fn shared_settings() -> SharedGrantSettings {
SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
}
}
async fn insert_basic_grant(
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
rate_limit_window_secs: shared
.rate_limit
.as_ref()
.map(|limit| limit.window.num_seconds() as i32),
revoked_at: None,
})
.returning(EvmBasicGrant::as_select())
.get_result(conn)
.await
.unwrap()
}
#[rstest]
#[case::matching_chain(CHAIN_ID, false)]
#[case::mismatching_chain(CHAIN_ID + 1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_chain_id(
#[case] context_chain: u64,
#[case] expect_mismatch: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
chain: context_chain,
..context()
};
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
expect_mismatch
);
if expect_mismatch {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
#[tokio::test]
async fn check_shared_constraints_enforces_validity_window(
#[case] valid_from: Option<chrono::DateTime<Utc>>,
#[case] valid_until: Option<chrono::DateTime<Utc>>,
#[case] expect_invalid_time: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
valid_from,
valid_until,
..shared_settings()
};
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
expect_invalid_time
);
if expect_invalid_time {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_gas_fee_caps(
#[case] max_gas_fee_per_gas: Option<U256>,
#[case] max_priority_fee_per_gas: Option<U256>,
#[case] actual_max_fee_per_gas: u128,
#[case] actual_max_priority_fee_per_gas: u128,
#[case] expect_gas_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
max_fee_per_gas: actual_max_fee_per_gas,
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
..context()
};
let shared = SharedGrantSettings {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
..shared_settings()
};
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
expect_gas_limit_violation
);
if expect_gas_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::under_rate_limit(2, false)]
#[case::at_rate_limit(1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_rate_limit(
#[case] rate_limit_count: u32,
#[case] expect_rate_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
rate_limit: Some(TransactionRateLimit {
count: rate_limit_count,
window: Duration::hours(1),
}),
..shared_settings()
};
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
.await
.unwrap();
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
expect_rate_limit_violation
);
if expect_rate_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
}

View File

@@ -10,7 +10,6 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
use thiserror::Error;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
@@ -56,9 +55,6 @@ pub enum EvalViolation {
#[error("Transaction type is not allowed by this grant")]
InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
}
pub type DatabaseID = i32;
@@ -66,12 +62,13 @@ pub type DatabaseID = i32;
#[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>;
@@ -127,13 +124,13 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VolumeRateLimit {
pub max_volume: U256,
pub window: Duration,
@@ -154,7 +151,7 @@ pub struct SharedGrantSettings {
}
impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
@@ -200,57 +197,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
#[derive(Debug, Clone)]
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;
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for TransactionRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.count.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for VolumeRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.max_volume.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for SharedGrantSettings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.wallet_access_id.hash(hasher);
self.chain.hash(hasher);
self.valid_from.hash(hasher);
self.valid_until.hash(hasher);
self.max_gas_fee_per_gas.hash(hasher);
self.max_priority_fee_per_gas.hash(hasher);
self.rate_limit.hash(hasher);
}
}
impl<P: Hashable> Hashable for CombinedSettings<P> {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.shared.hash(hasher);
self.specific.hash(hasher);
}
}

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::{
@@ -57,18 +56,6 @@ pub struct Settings {
pub target: Vec<Address>,
pub limit: VolumeRateLimit,
}
impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer";
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.target.hash(hasher);
self.limit.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
@@ -108,18 +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.common_settings_id, window, db).await?;
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);
}
@@ -152,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 });
}
@@ -250,21 +236,20 @@ impl Policy for EtherTransfer {
})
.collect();
let settings = Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
};
Ok(Some(Grant {
id: grant.id,
common_settings_id: grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err))
})?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
},
},
shared_grant_id: grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
}))
}
@@ -342,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,
};
@@ -84,6 +81,8 @@ fn shared() -> SharedGrantSettings {
}
}
// ── analyze ─────────────────────────────────────────────────────────────
#[test]
fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap();
@@ -100,6 +99,8 @@ fn analyze_rejects_nonempty_calldata() {
assert!(EtherTransfer::analyze(&context).is_none());
}
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test]
async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await;
@@ -107,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();
@@ -128,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();
@@ -170,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();
@@ -212,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();
@@ -255,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();
@@ -272,6 +263,8 @@ async fn evaluate_passes_at_exactly_volume_limit() {
);
}
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test]
async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await;
@@ -289,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]
@@ -330,36 +320,7 @@ async fn try_find_grant_wrong_target_returns_none() {
assert!(found.is_none());
}
proptest::proptest! {
#[test]
fn target_order_does_not_affect_hash(
raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8),
seed in proptest::prelude::any::<u64>(),
max_volume in proptest::prelude::any::<u64>(),
window_secs in 1i64..=86400,
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let limit = VolumeRateLimit {
max_volume: U256::from(max_volume),
window: Duration::seconds(window_secs),
};
let mut h1 = sha2::Sha256::new();
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { target: shuffled, limit }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
// ── find_all_grants ──────────────────────────────────────────────────────
#[tokio::test]
async fn find_all_grants_empty_db() {
@@ -386,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]
@@ -402,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

@@ -1,5 +1,20 @@
use std::collections::HashMap;
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
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,
@@ -11,25 +26,6 @@ use crate::evm::{
},
utils,
};
use crate::{
crypto::integrity::Integrable,
db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
evm::policies::CombinedSettings,
};
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use super::{DatabaseID, EvalContext, EvalViolation};
@@ -42,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 {
@@ -68,20 +64,6 @@ pub struct Settings {
pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>,
}
impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer";
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.token_contract.hash(hasher);
self.target.hash(hasher);
self.volume_limits.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
@@ -124,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()
@@ -183,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 });
@@ -286,17 +261,17 @@ impl Policy for TokenTransfer {
}
};
let settings = Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
};
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 {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
},
shared_grant_id: token_grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
}))
}
@@ -394,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,
};
@@ -101,6 +98,8 @@ fn shared() -> SharedGrantSettings {
}
}
// ── analyze ─────────────────────────────────────────────────────────────
#[test]
fn analyze_known_token_valid_calldata() {
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
@@ -126,6 +125,8 @@ fn analyze_empty_calldata_returns_none() {
assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none());
}
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await;
@@ -133,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);
@@ -164,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);
@@ -186,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);
@@ -208,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);
@@ -254,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);
@@ -299,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);
@@ -324,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);
@@ -362,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]
@@ -408,39 +392,7 @@ async fn try_find_grant_unknown_token_returns_none() {
assert!(found.is_none());
}
proptest::proptest! {
#[test]
fn volume_limits_order_does_not_affect_hash(
raw_limits in proptest::collection::vec(
(proptest::prelude::any::<u64>(), 1i64..=86400),
0..8,
),
seed in proptest::prelude::any::<u64>(),
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
let limits: Vec<VolumeRateLimit> = raw_limits
.iter()
.map(|(max_vol, window_secs)| VolumeRateLimit {
max_volume: U256::from(*max_vol),
window: Duration::seconds(*window_secs),
})
.collect();
let mut shuffled = limits.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let mut h1 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
// ── find_all_grants ──────────────────────────────────────────────────────
#[tokio::test]
async fn find_all_grants_empty_db() {
@@ -482,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

@@ -22,7 +22,6 @@ use tracing::warn;
use crate::{
actors::client::{self, ClientConnection, auth},
crypto::integrity::Verified,
grpc::request_tracker::RequestTracker,
};
@@ -69,7 +68,6 @@ impl<'a> AuthTransportAdapter<'a> {
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),
@@ -201,7 +199,7 @@ pub async fn start(
conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker,
) -> Result<Verified<i32>, auth::Error> {
) -> Result<i32, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await
}

View File

@@ -8,7 +8,7 @@ use arbiter_proto::proto::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
eval_violation as proto_eval_violation, eval_violation::Kind as ProtoEvalViolationKind,
eval_violation::Kind as ProtoEvalViolationKind,
specific_meaning::Meaning as ProtoSpecificMeaningKind,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
},
@@ -79,12 +79,6 @@ impl Convert for EvalViolation {
EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(())
}
EvalViolation::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
})
}
};
ProtoEvalViolation { kind: Some(kind) }
@@ -114,12 +108,12 @@ impl Convert for VetError {
violations: violations.into_iter().map(Convert::convert).collect(),
})
}
PolicyError::Database(_) | PolicyError::Integrity(_) => {
PolicyError::Database(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
}
},
};
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) })
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
}
}

View File

@@ -17,7 +17,6 @@ use crate::{
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
grpc::request_tracker::RequestTracker,
};
mod auth;
mod evm;
mod inbound;

View File

@@ -26,8 +26,8 @@ use crate::{
actors::user_agent::{
UserAgentSession,
session::connection::{
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantDelete, HandleGrantList, HandleSignTransaction,
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError,
},
},
@@ -66,7 +66,7 @@ async fn handle_wallet_create(
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleEvmWalletCreate {}).await {
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
id: wallet_id.drop_verification_provenance(),
id: wallet_id,
address: address.to_vec(),
}),
Err(err) => {
@@ -114,16 +114,13 @@ 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.id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
})
.collect(),
}),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantListResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to list EVM grants");
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
@@ -150,10 +147,7 @@ async fn handle_grant_create(
.try_convert()?;
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id.drop_verification_provenance()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into())
}
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(err) => {
warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
@@ -177,9 +171,6 @@ async fn handle_grant_delete(
.await
{
Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())

View File

@@ -1,11 +1,8 @@
#![forbid(unsafe_code)]
use crate::context::ServerContext;
#[macro_use]
extern crate macro_rules_attribute;
pub mod actors;
pub mod context;
pub mod crypto;
pub mod db;
pub mod evm;
pub mod grpc;

View File

@@ -10,7 +10,6 @@ use tracing::info;
const PORT: u16 = 50051;
#[tokio::main]
#[mutants::skip]
async fn main() -> anyhow::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();

View File

@@ -1,5 +1,3 @@
use std::ops::Deref;
struct DeferClosure<F: FnOnce()> {
f: Option<F>,
}
@@ -16,19 +14,3 @@ impl<F: FnOnce()> Drop for DeferClosure<F> {
pub fn defer<F: FnOnce()>(f: F) -> impl Drop + Sized {
DeferClosure { f: Some(f) }
}
/// A trait for casting between two transparently wrapped types with identical memory layouts.
///
/// [`ReinterpretWrapper`] enables zero-cost conversions between two types (`Self` and `Counterpart`)
/// that wrap the same underlying data but differ in how that data is presented. Both types must
/// transparently wrap the same "deref target" and provide bidirectional `AsRef` conversions.
pub trait ReinterpretWrapper<Counterpart>
where
Self: Deref<Target = Self::Inner> + AsRef<Counterpart>,
Counterpart: Deref<Target = Self::Inner> + AsRef<Self>,
{
/// The shared target type that both `Self` and `Counterpart` transparently wrap.
type Inner;
/// Reinterprets `Self` as `Counterpart`.
fn reinterpret(self) -> Counterpart;
}

View File

@@ -1,14 +1,9 @@
use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{
actors::{
GlobalActors,
client::{ClientConnection, ClientCredentials, auth, connect_client},
keyholder::Bootstrap,
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
actors::client::{ClientConnection, auth, connect_client},
db,
};
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
@@ -26,8 +21,7 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
async fn insert_registered_client(
db: &db::DatabasePool,
actors: &GlobalActors,
pubkey: ed25519_dalek::VerifyingKey,
pubkey: Vec<u8>,
metadata: &ClientMetadata,
) {
use arbiter_server::db::schema::{client_metadata, program_client};
@@ -43,64 +37,23 @@ async fn insert_registered_client(
.get_result(&mut conn)
.await
.unwrap();
let client_id: i32 = insert_into(program_client::table)
insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.to_bytes().to_vec()),
program_client::public_key.eq(pubkey),
program_client::metadata_id.eq(metadata_id),
))
.returning(program_client::id)
.get_result(&mut conn)
.await
.unwrap();
let _ = integrity::sign_entity(
&mut conn,
&actors.key_holder,
&ClientCredentials { pubkey, nonce: 1 },
client_id,
)
.await
.unwrap();
}
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = ed25519_dalek::SigningKey::generate(&mut rand::rng())
.verifying_key()
.to_bytes()
.to_vec();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(sentinel_key),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.await
.unwrap();
}
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_useragent(db).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();
actors
}
#[tokio::test]
#[test_log::test]
pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = spawn_test_actors(&db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -125,19 +78,20 @@ pub async fn test_unregistered_pubkey_rejected() {
#[test_log::test]
pub async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
insert_registered_client(
&db,
&actors,
new_key.verifying_key(),
pubkey_bytes.clone(),
&metadata("client", Some("desc"), Some("1.0.0")),
)
.await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -193,13 +147,34 @@ pub async fn test_challenge_auth() {
#[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
let props = ClientConnection::new(db.clone(), actors);
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -250,18 +225,33 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
#[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
insert_registered_client(
&db,
&actors,
new_key.verifying_key(),
&metadata("client", Some("old"), Some("1.0.0")),
)
.await;
let props = ClientConnection::new(db.clone(), actors);
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq("client"),
client_metadata::description.eq(Some("old")),
client_metadata::version.eq(Some("1.0.0")),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -332,59 +322,3 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
);
}
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await;
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: requested,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth rejection");
assert!(matches!(response, Err(auth::Error::IntegrityCheckFailed)));
task.await.unwrap();
}

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,40 +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()
.drop_verification_provenance();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -150,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()),
@@ -158,6 +131,7 @@ pub async fn test_challenge_auth() {
.await
.unwrap();
// Read the challenge response
let response = test_transport
.recv()
.await
@@ -194,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)
@@ -228,68 +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()
.drop_verification_provenance();
}
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()),
@@ -310,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);
@@ -320,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

@@ -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(