2 Commits

Author SHA1 Message Date
CleverWild
89e2daf05a ci: fix step name
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-compile Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 15:22:09 +02:00
CleverWild
c62feda198 ci: add server compile configuration for CI checks on all features
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-compile Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 15:15:03 +02:00
211 changed files with 3111 additions and 19074 deletions

View File

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

View File

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

View File

@@ -66,7 +66,7 @@ cargo insta review
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. - **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.

View File

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

View File

@@ -66,7 +66,7 @@ cargo insta review
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. - **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.

View File

@@ -67,14 +67,18 @@ The `program_client.nonce` column stores the **next usable nonce** — i.e. it i
## Cryptography ## Cryptography
### Authentication ### Authentication
- **Client protocol:** ML-DSA - **Client protocol:** ed25519
### User-Agent Authentication ### User-Agent Authentication
User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware. User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
- **Supported schemes:** ML-DSA - **Supported schemes:** RSA, Ed25519, ECDSA (secp256k1)
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out - **Why:** the user agent authenticates with keys backed by platform facilities, and those facilities differ by platform
- **Apple Silicon Secure Enclave / Secure Element:** ECDSA-only in practice
- **Windows Hello / TPM 2.0:** currently RSA-backed in our integration
This is why the user-agent auth protocol carries an explicit `KeyType`, while the SDK client protocol remains fixed to ed25519.
### Encryption at Rest ### Encryption at Rest
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305** - **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
@@ -124,52 +128,6 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations. 4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume). 5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
The detailed branch structure is shown below:
```mermaid
flowchart TD
A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
B --> C{Wallet exists?}
C -- No --> Z1[Return wallet not found error]
C -- Yes --> D[Check SDK client wallet visibility]
D --> E{Wallet visible to SDK client?}
E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?}
G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility]
E -- Yes --> I[Classify transaction meaning]
H --> I
I --> J{Meaning supported?}
J -- No --> Z3[Return unsupported transaction error]
J -- Yes --> K[Find matching grant]
K --> L{Grant exists?}
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{User-agent decision}
O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]
P --> Q[Persist grant]
Q --> M
M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error]
R -- No --> S[Record transaction in logs]
S --> T[Produce signature]
T --> U[Return signature to SDK client]
note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
K -. uses .-> note2
M -. checks .-> note1
```
### Policy Trait ### Policy Trait
| Method | Purpose | | Method | Purpose |

File diff suppressed because it is too large Load Diff

View File

@@ -1,821 +0,0 @@
# Grant Grid View Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support.
**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`.
**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `useragent/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `useragent/lib/theme/palette.dart` with:
```dart
import 'package:flutter/material.dart';
class Palette {
static const ink = Color(0xFF15263C);
static const coral = Color(0xFFE26254);
static const cream = Color(0xFFFFFAF4);
static const line = Color(0x1A15263C);
static const token = Color(0xFF5C6BC0);
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/theme/palette.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(theme): add Palette.token for token-transfer grant cards"
jj new
```
---
## Task 2: Add `listAllWalletAccesses` feature function
**Files:**
- Modify: `useragent/lib/features/connection/evm/wallet_access.dart`
`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set<int>`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client.
- [ ] **Step 1: Append function**
Add at the bottom of `useragent/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
UserAgentRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return response.listWalletAccessResponse.accesses.toList(growable: false);
}
```
Each returned `SdkClientWalletAccess` has:
- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`)
- `.access.walletId` — the EVM wallet DB ID
- `.access.sdkClientId` — the SDK client DB ID
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(evm): add listAllWalletAccesses feature function"
jj new
```
---
## Task 3: Create `WalletAccessListProvider`
**Files:**
- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart`
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
- [ ] **Step 1: Write the provider**
Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access_list.g.dart';
@riverpod
class WalletAccessList extends _$WalletAccessList {
@override
Future<List<SdkClientWalletAccess>?> build() async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
return await listAllWalletAccesses(connection);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}
Future<void> refresh() async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
state = const AsyncData(null);
return;
}
state = const AsyncLoading();
state = await AsyncValue.guard(() => listAllWalletAccesses(connection));
}
}
```
- [ ] **Step 2: Run code generation**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd useragent && flutter analyze lib/providers/sdk_clients/
```
Expected: no issues.
- [ ] **Step 4: Commit**
```sh
jj describe -m "feat(providers): add WalletAccessListProvider"
jj new
```
---
## Task 4: Create `GrantCard` widget
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself.
**Key types:**
- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()`
- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof
- `SdkClientWalletAccess` (from `proto/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name`
- `revokeEvmGrantMutation``Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
- `executeRevokeEvmGrant(ref, grantId: int)``Future<void>`
- [ ] **Step 1: Write the widget**
Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Enrichment lookups — each watch scopes rebuilds to this card only
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
// Resolve wallet_access_id → wallet address + client name
final accessById = <int, SdkClientWalletAccess>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{
for (final w in wallets) w.id: w,
};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment"
jj new
```
---
## Task 5: Create `EvmGrantsScreen`
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart`
The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions.
- [ ] **Step 1: Write the screen**
Create `useragent/lib/screens/dashboard/evm/grants/grants.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
// ─── State panel ──────────────────────────────────────────────────────────────
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
// ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
);
}
}
// ─── Screen ───────────────────────────────────────────────────────────────────
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
await Future.wait([
ref.read(evmGrantsProvider.notifier).refresh(),
ref.read(walletAccessListProvider.notifier).refresh(),
]);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => _StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => _StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => _StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Refresh'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add EvmGrantsScreen"
jj new
```
---
## Task 6: Wire router and dashboard tab
**Files:**
- Modify: `useragent/lib/router.dart`
- Modify: `useragent/lib/screens/dashboard.dart`
- Regenerated: `useragent/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `useragent/lib/router.dart` with:
```dart
import 'package:auto_route/auto_route.dart';
import 'router.gr.dart';
@AutoRouterConfig(generateForDir: ['lib/screens'])
class Router extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute(
page: DashboardRouter.page,
path: '/dashboard',
children: [
AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: ClientsRoute.page, path: 'clients'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'),
],
),
];
}
```
- [ ] **Step 2: Update `dashboard.dart`**
In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant:
```dart
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
```
And replace the `destinations` list inside `AdaptiveScaffold`:
```dart
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Wallets',
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: 'About',
),
],
```
- [ ] **Step 3: Regenerate router**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
- [ ] **Step 4: Full project verify**
```sh
cd useragent && flutter analyze
```
Expected: no issues.
- [ ] **Step 5: Commit**
```sh
jj describe -m "feat(nav): add Grants dashboard tab"
jj new
```

View File

@@ -1,170 +0,0 @@
# Grant Grid View — Design Spec
**Date:** 2026-03-28
## Overview
Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
## Scope
- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs
- New `EvmGrantsScreen` as a dashboard tab
- Grant card widget with enriched display (type, chain, wallet, client)
- Revoke action wired to existing `executeRevokeEvmGrant` mutation
- Dashboard tab bar and router updated
- New token-transfer accent color added to `Palette`
**Out of scope:** Fixing grant creation (separate task).
---
## Data Layer
### `walletAccessListProvider`
**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- `@riverpod` class, watches `connectionManagerProvider.future`
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId`
- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()`
### Enrichment at render time (Approach A)
The `EvmGrantsScreen` watches four providers:
1. `evmGrantsProvider` — the grant list
2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id)
3. `evmProvider` — to resolve wallet_id → wallet address
4. `sdkClientsProvider` — to resolve sdk_client_id → client name
All lookups are in-memory Maps built inside the build method; no extra model class needed.
Fallbacks:
- Wallet address not found → `"Access #N"` where N is the wallet_access_id
- Client name not found → `"Client #N"` where N is the sdk_client_id
---
## Route Structure
```
/dashboard
/evm ← existing (Wallets tab)
/clients ← existing (Clients tab)
/grants ← NEW (Grants tab)
/about ← existing
/evm-grants/create ← existing push route (unchanged)
```
### Changes to `router.dart`
Add inside dashboard children:
```dart
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
```
### Changes to `dashboard.dart`
Add to `routes` list:
```dart
const EvmGrantsRoute()
```
Add `NavigationDestination`:
```dart
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
```
---
## Screen: `EvmGrantsScreen`
**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart`
```
Scaffold
└─ SafeArea
└─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider)
└─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics)
├─ PageHeader
│ title: 'EVM Grants'
│ isBusy: evmGrantsProvider.isLoading
│ actions: [CreateGrantButton, RefreshButton]
├─ SizedBox(height: 1.8.h)
└─ <content>
```
### State handling
Matches the pattern from `EvmScreen` and `ClientsScreen`:
| State | Display |
|---|---|
| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" |
| Error | `_StatePanel` with coral icon, error message, Retry button |
| No connection | `_StatePanel`, "No active server connection" |
| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut |
| Data | Column of `_GrantCard` widgets |
### Header actions
**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`.
**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`.
---
## Grant Card: `_GrantCard`
**Layout:**
```
Container (rounded 24, Palette.cream bg, Palette.line border)
└─ IntrinsicHeight > Row
├─ Accent strip (0.8.w wide, full height, rounded left)
└─ Padding > Column
├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton
└─ Row 2: WalletText + "·" + ClientText
```
**Accent color by grant type:**
- Ether transfer → `Palette.coral`
- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`)
**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`.
**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color.
**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family.
**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink.
**RevokeButton:**
- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'`
- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))`
- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight
- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error
---
## Adaptive Sizing
All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values.
---
## Files to Create / Modify
| File | Action |
|---|---|
| `lib/theme/palette.dart` | Modify — add `Palette.token` color |
| `lib/providers/sdk_clients/wallet_access_list.dart` | Create |
| `lib/screens/dashboard/evm/grants/grants.dart` | Create |
| `lib/router.dart` | Modify — add grants route to dashboard children |
| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations |

View File

@@ -48,10 +48,6 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3" version = "1.46.3"
backend = "cargo:cargo-insta" backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]] [[tools."cargo:cargo-nextest"]]
version = "0.9.126" version = "0.9.126"
backend = "cargo:cargo-nextest" backend = "cargo:cargo-nextest"
@@ -72,10 +68,6 @@ backend = "cargo:diesel_cli"
default-features = "false" default-features = "false"
features = "sqlite,sqlite-bundled" features = "sqlite,sqlite-bundled"
[[tools."cargo:flutter_rust_bridge_codegen"]]
version = "2.12.0"
backend = "cargo:flutter_rust_bridge_codegen"
[[tools.flutter]] [[tools.flutter]]
version = "3.38.9-stable" version = "3.38.9-stable"
backend = "asdf:flutter" backend = "asdf:flutter"
@@ -119,37 +111,30 @@ backend = "core:python"
[tools.python."platforms.linux-arm64"] [tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" 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" 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"] [tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" 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" 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"] [tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" 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" 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"] [tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" 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" 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"] [tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9" 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" 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"] [tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0" 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" 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"] [tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09" 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" 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]] [[tools.rust]]
version = "1.93.0" version = "1.93.0"

View File

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

View File

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

View File

@@ -2,9 +2,17 @@ syntax = "proto3";
package arbiter.user_agent.auth; package arbiter.user_agent.auth;
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;
optional string bootstrap_token = 2; optional string bootstrap_token = 2;
KeyType key_type = 3;
} }
message AuthChallenge { message AuthChallenge {

View File

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

2
server/.gitignore vendored
View File

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

1069
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,12 +20,14 @@ tokio = { version = "1.50.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0" rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false } rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
smlang = "0.8.0" smlang = "0.8.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
thiserror = "2.0.18" thiserror = "2.0.18"
async-trait = "0.1.89" async-trait = "0.1.89"
futures = "0.3.32" futures = "0.3.32"
tokio-stream = { version = "0.1.18", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] }
kameo = "0.19.2"
prost-types = { version = "0.14.3", features = ["chrono"] } prost-types = { version = "0.14.3", features = ["chrono"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] } x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1" rstest = "0.26.1"
@@ -41,11 +43,3 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] } rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10" sha2 = "0.10"
spki = "0.7" spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] }
base64 = "0.22.1"
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}

View File

@@ -13,12 +13,12 @@ evm = ["dep:alloy"]
[dependencies] [dependencies]
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto"
alloy = { workspace = true, optional = true } alloy = { workspace = true, optional = true }
tonic.workspace = true tonic.workspace = true
tonic.features = ["tls-aws-lc"] tonic.features = ["tls-aws-lc"]
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
ed25519-dalek.workspace = true
thiserror.workspace = true thiserror.workspace = true
http = "1.4.0" http = "1.4.0"
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] } rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }

View File

@@ -1,6 +1,5 @@
use arbiter_crypto::authn::{CLIENT_CONTEXT, SigningKey, format_challenge};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata, format_challenge,
proto::{ proto::{
client::{ client::{
ClientRequest, ClientRequest,
@@ -15,6 +14,7 @@ use arbiter_proto::{
shared::ClientInfo as ProtoClientInfo, shared::ClientInfo as ProtoClientInfo,
}, },
}; };
use ed25519_dalek::Signer as _;
use crate::{ use crate::{
storage::StorageError, storage::StorageError,
@@ -54,14 +54,14 @@ fn map_auth_result(code: i32) -> AuthError {
async fn send_auth_challenge_request( async fn send_auth_challenge_request(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &SigningKey, key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), AuthError> { ) -> std::result::Result<(), AuthError> {
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id: next_request_id(), request_id: next_request_id(),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request { payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest { payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
pubkey: key.public_key().to_bytes(), pubkey: key.verifying_key().to_bytes().to_vec(),
client_info: Some(ProtoClientInfo { client_info: Some(ProtoClientInfo {
name: metadata.name, name: metadata.name,
description: metadata.description, description: metadata.description,
@@ -95,14 +95,11 @@ async fn receive_auth_challenge(
async fn send_auth_challenge_solution( async fn send_auth_challenge_solution(
transport: &mut ClientTransport, transport: &mut ClientTransport,
key: &SigningKey, key: &ed25519_dalek::SigningKey,
challenge: AuthChallenge, challenge: AuthChallenge,
) -> std::result::Result<(), AuthError> { ) -> std::result::Result<(), AuthError> {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
let signature = key let signature = key.sign(&challenge_payload).to_bytes().to_vec();
.sign_message(&challenge_payload, CLIENT_CONTEXT)
.map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes();
transport transport
.send(ClientRequest { .send(ClientRequest {
@@ -125,7 +122,9 @@ async fn receive_auth_confirmation(
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse)?; .map_err(|_| AuthError::UnexpectedAuthResponse)?;
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?; let payload = response
.payload
.ok_or(AuthError::UnexpectedAuthResponse)?;
match payload { match payload {
ClientResponsePayload::Auth(response) => match response.payload { ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Result(result)) Some(AuthResponsePayload::Result(result))
@@ -143,7 +142,7 @@ async fn receive_auth_confirmation(
pub(crate) async fn authenticate( pub(crate) async fn authenticate(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &SigningKey, key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), AuthError> { ) -> std::result::Result<(), AuthError> {
send_auth_challenge_request(transport, metadata, key).await?; send_auth_challenge_request(transport, metadata, key).await?;
let challenge = receive_auth_challenge(transport).await?; let challenge = receive_auth_challenge(transport).await?;

View File

@@ -1,3 +1,4 @@
use std::io::{self, Write}; use std::io::{self, Write};
use arbiter_client::ArbiterClient; use arbiter_client::ArbiterClient;
@@ -21,6 +22,8 @@ async fn main() {
return; return;
} }
let url = match ArbiterUrl::try_from(input) { let url = match ArbiterUrl::try_from(input) {
Ok(url) => url, Ok(url) => url,
Err(err) => { Err(err) => {

View File

@@ -1,17 +1,11 @@
use arbiter_crypto::authn::SigningKey; use arbiter_proto::{ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl};
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig; use tonic::transport::ClientTlsConfig;
use crate::{ use crate::{
StorageError, StorageError, auth::{AuthError, authenticate}, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport}
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
}; };
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
@@ -36,6 +30,7 @@ pub enum Error {
#[error("Storage error")] #[error("Storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
} }
pub struct ArbiterClient { pub struct ArbiterClient {
@@ -61,13 +56,12 @@ impl ArbiterClient {
pub async fn connect_with_key( pub async fn connect_with_key(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
key: SigningKey, key: ed25519_dalek::SigningKey,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor); let tls = ClientTlsConfig::new().trust_anchor(anchor);
let channel = let channel = tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.tls_config(tls)? .tls_config(tls)?
.connect() .connect()
.await?; .await?;

View File

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

View File

@@ -1,4 +1,3 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::home_path; use arbiter_proto::home_path;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -12,7 +11,7 @@ pub enum StorageError {
} }
pub trait SigningKeyStorage { pub trait SigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<SigningKey, StorageError>; fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -21,7 +20,7 @@ pub struct FileSigningKeyStorage {
} }
impl FileSigningKeyStorage { impl FileSigningKeyStorage {
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key"; pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
pub fn new(path: impl Into<PathBuf>) -> Self { pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() } Self { path: path.into() }
@@ -31,7 +30,7 @@ impl FileSigningKeyStorage {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
} }
fn read_key(path: &Path) -> std::result::Result<SigningKey, StorageError> { fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
let raw: [u8; 32] = let raw: [u8; 32] =
bytes bytes
@@ -40,12 +39,12 @@ impl FileSigningKeyStorage {
expected: 32, expected: 32,
actual: v.len(), actual: v.len(),
})?; })?;
Ok(SigningKey::from_seed(raw)) Ok(ed25519_dalek::SigningKey::from_bytes(&raw))
} }
} }
impl SigningKeyStorage for FileSigningKeyStorage { impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<SigningKey, StorageError> { fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -54,8 +53,8 @@ impl SigningKeyStorage for FileSigningKeyStorage {
return Self::read_key(&self.path); return Self::read_key(&self.path);
} }
let key = SigningKey::generate(); let key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let raw_key = key.to_seed(); let raw_key = key.to_bytes();
// Use create_new to prevent accidental overwrite if another process creates the key first. // Use create_new to prevent accidental overwrite if another process creates the key first.
match std::fs::OpenOptions::new() match std::fs::OpenOptions::new()
@@ -104,7 +103,7 @@ mod tests {
.load_or_create() .load_or_create()
.expect("second load_or_create should read same key"); .expect("second load_or_create should read same key");
assert_eq!(key_a.to_seed(), key_b.to_seed()); assert_eq!(key_a.to_bytes(), key_b.to_bytes());
assert!(path.exists()); assert!(path.exists());
std::fs::remove_file(path).expect("temp key file should be removable"); std::fs::remove_file(path).expect("temp key file should be removable");

View File

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

View File

@@ -1 +0,0 @@
/target

View File

@@ -1,19 +0,0 @@
[package]
name = "arbiter-crypto"
version = "0.1.0"
edition = "2024"
[dependencies]
ml-dsa = {workspace = true, optional = true }
rand = {workspace = true, optional = true}
base64 = {workspace = true, optional = true }
memsafe = {version = "0.4.0", optional = true}
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
[lints]
workspace = true
[features]
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand", "dep:base64"]
safecell = ["dep:memsafe"]

View File

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

View File

@@ -1,194 +0,0 @@
use std::hash::Hash;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use ml_dsa::{
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
};
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
concat_form.into_bytes()
}
pub type KeyParams = MlDsa87;
#[derive(Clone, Debug, PartialEq)]
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
impl Hash for PublicKey {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.to_bytes().hash(state);
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Signature(Box<MlDsaSignature<KeyParams>>);
#[derive(Debug)]
pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().to_vec()
}
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
self.0.verify_with_context(
&format_challenge(nonce, &self.to_bytes()),
context,
&signature.0,
)
}
}
impl Signature {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().to_vec()
}
}
impl SigningKey {
pub fn generate() -> Self {
Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
}
pub fn from_seed(seed: [u8; 32]) -> Self {
Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
}
pub fn to_seed(&self) -> [u8; 32] {
self.0.to_seed().into()
}
pub fn public_key(&self) -> PublicKey {
self.0.verifying_key().into()
}
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
self.0
.signing_key()
.sign_deterministic(message, context)
.map(Into::into)
}
pub fn sign_challenge(&self, nonce: i32, context: &[u8]) -> Result<Signature, Error> {
self.sign_message(
&format_challenge(nonce, &self.public_key().to_bytes()),
context,
)
}
}
impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey {
fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl From<MlDsaSignature<KeyParams>> for Signature {
fn from(value: MlDsaSignature<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl TryFrom<Vec<u8>> for PublicKey {
type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice())
}
}
impl TryFrom<&'_ [u8]> for PublicKey {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?;
Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded))))
}
}
impl TryFrom<Vec<u8>> for Signature {
type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice())
}
}
impl TryFrom<&'_ [u8]> for Signature {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
MlDsaSignature::try_from(value)
.map(|sig| Self(Box::new(sig)))
.map_err(|_| ())
}
}
#[cfg(test)]
mod tests {
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
#[test]
fn public_key_round_trip_decodes() {
let key = MlDsa87::key_gen(&mut rand::rng());
let encoded = PublicKey::from(key.verifying_key()).to_bytes();
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
assert_eq!(decoded, PublicKey::from(key.verifying_key()));
}
#[test]
fn signature_round_trip_decodes() {
let key = SigningKey::generate();
let signature = key
.sign_message(b"challenge", CLIENT_CONTEXT)
.expect("signature should be created");
let decoded =
Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode");
assert_eq!(decoded, signature);
}
#[test]
fn challenge_verification_uses_context_and_canonical_key_bytes() {
let key = SigningKey::generate();
let public_key = key.public_key();
let nonce = 17;
let signature = key
.sign_challenge(nonce, CLIENT_CONTEXT)
.expect("signature should be created");
assert!(public_key.verify(nonce, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(nonce, USERAGENT_CONTEXT, &signature));
}
#[test]
fn signing_key_round_trip_seed_preserves_public_key_and_signing() {
let original = SigningKey::generate();
let restored = SigningKey::from_seed(original.to_seed());
assert_eq!(restored.public_key(), original.public_key());
let signature = restored
.sign_challenge(9, CLIENT_CONTEXT)
.expect("signature should be created");
assert!(restored.public_key().verify(9, CLIENT_CONTEXT, &signature));
}
}

View File

@@ -1,7 +0,0 @@
#[cfg(feature = "authn")]
pub mod authn;
#[cfg(feature = "safecell")]
pub mod safecell;
pub use x_wing;

View File

@@ -11,13 +11,13 @@ tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3" hex = "0.4.3"
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost.workspace = true prost = "0.14.3"
kameo.workspace = true kameo.workspace = true
url = "2.5.8" url = "2.5.8"
miette.workspace = true miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
base64.workspace = true base64 = "0.22.1"
prost-types.workspace = true prost-types.workspace = true
tracing.workspace = true tracing.workspace = true
async-trait.workspace = true async-trait.workspace = true

View File

@@ -1,6 +1,8 @@
pub mod transport; pub mod transport;
pub mod url; pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD};
pub mod proto { pub mod proto {
tonic::include_proto!("arbiter"); tonic::include_proto!("arbiter");
@@ -82,3 +84,8 @@ pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
Ok(arbiter_home) Ok(arbiter_home)
} }
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
concat_form.into_bytes()
}

View File

@@ -58,7 +58,6 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use async_trait::async_trait; use async_trait::async_trait;
use kameo::{error::Infallible, prelude::*};
/// Errors returned by transport adapters implementing [`Bi`]. /// Errors returned by transport adapters implementing [`Bi`].
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@@ -107,36 +106,6 @@ pub trait Receiver<Inbound>: Send + Sync {
/// any built-in correlation mechanism between inbound and outbound items. /// any built-in correlation mechanism between inbound and outbound items.
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {} pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
#[async_trait]
impl<T, Outbound> Sender<Outbound> for &mut T
where
T: Sender<Outbound> + ?Sized,
Outbound: Send + 'static,
{
async fn send(&mut self, item: Outbound) -> Result<(), Error> {
(**self).send(item).await
}
}
#[async_trait]
impl<T, Inbound> Receiver<Inbound> for &mut T
where
T: Receiver<Inbound> + ?Sized,
Inbound: Send + 'static,
{
async fn recv(&mut self) -> Option<Inbound> {
(**self).recv().await
}
}
impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
where
T: Bi<Inbound, Outbound> + ?Sized,
Inbound: Send + 'static,
Outbound: Send + 'static,
{
}
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> { pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
type Sender: Sender<Outbound>; type Sender: Sender<Outbound>;
type Receiver: Receiver<Inbound>; type Receiver: Receiver<Inbound>;
@@ -192,29 +161,3 @@ where
} }
pub mod grpc; pub mod grpc;
#[derive(thiserror::Error, Debug)]
pub enum ForwardError<I> {
#[error("Transport error: {0}")]
Transport(#[from] Error),
#[error("Actor delivery error: {0}")]
Actor(SendError<I>),
}
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
transport: &mut Transport,
actor: &ActorRef<Handler>,
) -> Result<(), ForwardError<Inbound>>
where
Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
Handler: Actor + Message<Inbound, Reply = Outbound>,
Inbound: Send + 'static,
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
{
while let Some(request) = transport.recv().await {
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
transport.send(resp).await?
}
Err(Error::ChannelClosed.into())
}

View File

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

View File

@@ -16,8 +16,8 @@ diesel-async = { version = "0.8.0", features = [
"sqlite", "sqlite",
"tokio", "tokio",
] } ] }
ed25519-dalek.workspace = true
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto"
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tonic.workspace = true tonic.workspace = true
@@ -25,6 +25,7 @@ tonic.features = ["tls-aws-lc"]
tokio.workspace = true tokio.workspace = true
rustls.workspace = true rustls.workspace = true
smlang.workspace = true smlang.workspace = true
miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
fatality = "0.1.1" fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] } diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
@@ -36,32 +37,23 @@ dashmap = "6.1.0"
rand.workspace = true rand.workspace = true
rcgen.workspace = true rcgen.workspace = true
chrono.workspace = true chrono.workspace = true
memsafe = "0.4.0"
zeroize = { version = "1.8.2", features = ["std", "simd"] } zeroize = { version = "1.8.2", features = ["std", "simd"] }
kameo.workspace = true kameo.workspace = true
x25519-dalek.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] } argon2 = { version = "0.5.3", features = ["zeroize"] }
restructed = "0.2.2" restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"
k256.workspace = true
rsa.workspace = true
sha2.workspace = true sha2.workspace = true
hmac = "0.12"
spki.workspace = true spki.workspace = true
alloy.workspace = true alloy.workspace = true
prost-types.workspace = true prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
ml-dsa.workspace = true
ed25519-dalek.workspace = true
x25519-dalek.workspace = true
k256.workspace = true
kameo_actors.workspace = true
[dev-dependencies] [dev-dependencies]
insta = "1.46.3" insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] } test-log = { version = "0.2", default-features = false, features = ["trace"] }

View File

@@ -47,7 +47,7 @@ create table if not exists useragent_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
key_type integer not null default(1), key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
@@ -191,19 +191,3 @@ create table if not exists evm_ether_transfer_grant_target (
) STRICT; ) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
-- ===============================
-- Integrity Envelopes
-- ===============================
create table if not exists integrity_envelope (
id integer not null primary key,
entity_kind text not null,
entity_id blob not null,
payload_version integer not null,
key_version integer not null,
mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);

View File

@@ -2,9 +2,8 @@ use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use miette::Diagnostic;
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
@@ -26,15 +25,18 @@ pub async fn generate_token() -> Result<String, std::io::Error> {
Ok(token) Ok(token)
} }
#[derive(Error, Debug)] #[derive(Error, Debug, Diagnostic)]
pub enum Error { pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database))]
Database(#[from] db::PoolError), Database(#[from] db::PoolError),
#[error("Database query error: {0}")] #[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database_query))]
Query(#[from] diesel::result::Error), Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::io))]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
@@ -45,14 +47,14 @@ pub struct Bootstrapper {
impl Bootstrapper { impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> { 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 let row_count: i64 = schema::useragent_client::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await? .await?;
};
drop(conn);
let token = if row_count == 0 { let token = if row_count == 0 {
let token = generate_token().await?; let token = generate_token().await?;
@@ -70,13 +72,7 @@ impl Bootstrapper {
#[message] #[message]
pub fn is_correct_token(&self, token: String) -> bool { pub fn is_correct_token(&self, token: String) -> bool {
match &self.token { match &self.token {
Some(expected) => { Some(expected) => *expected == token,
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}
None => false, None => false,
} }
} }

View File

@@ -1,6 +1,5 @@
use arbiter_crypto::authn::{self, CLIENT_CONTEXT};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata, format_challenge,
transport::{Bi, expect_message}, transport::{Bi, expect_message},
}; };
use chrono::Utc; use chrono::Utc;
@@ -9,16 +8,15 @@ use diesel::{
dsl::insert_into, update, dsl::insert_into, update,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use kameo::{actor::ActorRef, error::SendError}; use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use tracing::error; use tracing::error;
use crate::{ use crate::{
actors::{ actors::{
GlobalActors, client::{ClientConnection, ClientProfile},
flow_coordinator::{self, RequestClientApproval}, flow_coordinator::{self, RequestClientApproval},
vault::Vault,
}, },
crypto::integrity::{self, Nested, Verified},
db::{ db::{
self, self,
models::{ProgramClientMetadata, SqliteTimestamp}, models::{ProgramClientMetadata, SqliteTimestamp},
@@ -26,16 +24,12 @@ use crate::{
}, },
}; };
use super::{ClientConnection, ClientCredentials, ClientProfile};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
#[error("Database pool unavailable")] #[error("Database pool unavailable")]
DatabasePoolUnavailable, DatabasePoolUnavailable,
#[error("Database operation failed")] #[error("Database operation failed")]
DatabaseOperationFailed, DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")] #[error("Invalid challenge solution")]
InvalidChallengeSolution, InvalidChallengeSolution,
#[error("Client approval request failed")] #[error("Client approval request failed")]
@@ -44,13 +38,6 @@ pub enum Error {
Transport, 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)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError { pub enum ApproveError {
#[error("Internal error")] #[error("Internal error")]
@@ -64,55 +51,32 @@ pub enum ApproveError {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: authn::PublicKey, pubkey: VerifyingKey,
metadata: ClientMetadata, metadata: ClientMetadata,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: authn::Signature, signature: Signature,
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { AuthChallenge { pubkey: VerifyingKey, nonce: i32 },
pubkey: authn::PublicKey,
nonce: i32,
},
AuthSuccess, AuthSuccess,
} }
/// Returns the current nonce and client ID for a registered client. pub struct ClientInfo {
/// Returns `None` if the pubkey is not registered. pub id: i32,
async fn get_current_nonce_and_id( pub current_nonce: i32,
db: &db::DatabasePool,
pubkey: &authn::PublicKey,
) -> Result<Option<(i32, i32)>, Error> {
let pubkey_bytes = pubkey.to_bytes();
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
})
} }
/// Atomically increments the nonce and re-signs the integrity envelope. /// Atomically reads and increments the nonce for a known client.
/// Returns the new nonce, which is used as the challenge nonce. /// Returns `None` if the pubkey is not registered.
async fn create_nonce( async fn get_client_and_nonce(
db: &db::DatabasePool, db: &db::DatabasePool,
vault: &ActorRef<Vault>, pubkey: &VerifyingKey,
pubkey: &authn::PublicKey, ) -> Result<Option<ClientInfo>, Error> {
) -> Result<i32, Error> { let pubkey_bytes = pubkey.as_bytes().to_vec();
let pubkey_bytes = pubkey.to_bytes();
let pubkey = pubkey.clone();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
@@ -120,38 +84,40 @@ async fn create_nonce(
})?; })?;
conn.exclusive_transaction(|conn| { conn.exclusive_transaction(|conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move { 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)) .filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(program_client::nonce + 1)) .select((program_client::id, program_client::nonce))
.returning((program_client::id, program_client::nonce)) .first::<(i32, i32)>(conn)
.get_result(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?; .await?;
integrity::sign_entity( Ok(Some(ClientInfo {
conn, id: client_id,
&vault, current_nonce,
&ClientCredentials { }))
pubkey: pubkey.clone(), })
nonce: new_nonce, })
},
id,
)
.await .await
.map_err(|e| { .map_err(|e| {
error!(?e, "Integrity sign failed after nonce update"); error!(error = ?e, "Database error");
Error::DatabaseOperationFailed Error::DatabaseOperationFailed
})?;
Ok(new_nonce)
}) })
})
.await
} }
async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Result<(), Error> { async fn approve_new_client(
actors: &crate::actors::GlobalActors,
profile: ClientProfile,
) -> Result<(), Error> {
let result = actors let result = actors
.flow_coordinator .flow_coordinator
.ask(RequestClientApproval { client: profile }) .ask(RequestClientApproval { client: profile })
@@ -173,25 +139,15 @@ async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Re
async fn insert_client( async fn insert_client(
db: &db::DatabasePool, db: &db::DatabasePool,
vault: &ActorRef<Vault>, pubkey: &VerifyingKey,
pubkey: &authn::PublicKey,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<Verified<i32, Nested<ClientCredentials>>, Error> { ) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client}; use crate::db::schema::{client_metadata, program_client};
let pubkey = pubkey.clone();
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(|conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
const NONCE_START: i32 = 1;
let metadata_id = insert_into(client_metadata::table) let metadata_id = insert_into(client_metadata::table)
.values(( .values((
client_metadata::name.eq(&metadata.name), client_metadata::name.eq(&metadata.name),
@@ -199,44 +155,34 @@ async fn insert_client(
client_metadata::version.eq(&metadata.version), client_metadata::version.eq(&metadata.version),
)) ))
.returning(client_metadata::id) .returning(client_metadata::id)
.get_result::<i32>(conn) .get_result::<i32>(&mut conn)
.await?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.to_bytes()),
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,
&vault,
&ClientCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
},
client_id,
)
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key"); error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed Error::DatabaseOperationFailed
})?; })?;
Ok(verified_id) 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 .await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
Ok(client_id)
} }
async fn sync_client_metadata( async fn sync_client_metadata(
db: &db::DatabasePool, db: &db::DatabasePool,
client_id: &Verified<i32, Nested<ClientCredentials>>, client_id: i32,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<(), Error> { ) -> Result<(), Error> {
use crate::db::schema::{client_metadata, client_metadata_history}; use crate::db::schema::{client_metadata, client_metadata_history};
@@ -253,7 +199,7 @@ async fn sync_client_metadata(
Box::pin(async move { Box::pin(async move {
let (current_metadata_id, current): (i32, ProgramClientMetadata) = let (current_metadata_id, current): (i32, ProgramClientMetadata) =
program_client::table program_client::table
.find(client_id.as_ref()) .find(client_id)
.inner_join(client_metadata::table) .inner_join(client_metadata::table)
.select(( .select((
program_client::metadata_id, program_client::metadata_id,
@@ -272,7 +218,7 @@ async fn sync_client_metadata(
insert_into(client_metadata_history::table) insert_into(client_metadata_history::table)
.values(( .values((
client_metadata_history::metadata_id.eq(current_metadata_id), client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id.as_ref()), client_metadata_history::client_id.eq(client_id),
)) ))
.execute(conn) .execute(conn)
.await?; .await?;
@@ -287,7 +233,7 @@ async fn sync_client_metadata(
.get_result::<i32>(conn) .get_result::<i32>(conn)
.await?; .await?;
update(program_client::table.find(client_id.as_ref())) update(program_client::table.find(client_id))
.set(( .set((
program_client::metadata_id.eq(metadata_id), program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now), program_client::updated_at.eq(now),
@@ -307,17 +253,14 @@ async fn sync_client_metadata(
async fn challenge_client<T>( async fn challenge_client<T>(
transport: &mut T, transport: &mut T,
pubkey: authn::PublicKey, pubkey: VerifyingKey,
nonce: i32, nonce: i32,
) -> Result<(), Error> ) -> Result<(), Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized, T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
{ {
transport transport
.send(Ok(Outbound::AuthChallenge { .send(Ok(Outbound::AuthChallenge { pubkey, nonce }))
pubkey: pubkey.clone(),
nonce,
}))
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to send auth challenge"); error!(error = ?e, "Failed to send auth challenge");
@@ -334,10 +277,12 @@ where
Error::Transport Error::Transport
})?; })?;
if !pubkey.verify(nonce, CLIENT_CONTEXT, &signature) { let formatted = format_challenge(nonce, pubkey.as_bytes());
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
error!("Challenge solution verification failed"); error!("Challenge solution verification failed");
return Err(Error::InvalidChallengeSolution); Error::InvalidChallengeSolution
} })?;
Ok(()) Ok(())
} }
@@ -345,7 +290,7 @@ where
pub async fn authenticate<T>( pub async fn authenticate<T>(
props: &mut ClientConnection, props: &mut ClientConnection,
transport: &mut T, transport: &mut T,
) -> Result<Verified<i32, Nested<ClientCredentials>>, Error> ) -> Result<i32, Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized, T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{ {
@@ -353,45 +298,27 @@ where
return Err(Error::Transport); return Err(Error::Transport);
}; };
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? { let info = match get_client_and_nonce(&props.db, &pubkey).await? {
Some((id, nonce)) => { Some(nonce) => nonce,
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.vault,
ClientCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?
.entity_id
}
None => { None => {
approve_new_client( approve_new_client(
&props.actors, &props.actors,
ClientProfile { ClientProfile {
pubkey: pubkey.clone(), pubkey,
metadata: metadata.clone(), metadata: metadata.clone(),
}, },
) )
.await?; .await?;
insert_client(&props.db, &props.actors.vault, &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?; sync_client_metadata(&props.db, info.id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.vault, &pubkey).await?; challenge_client(transport, pubkey, info.current_nonce).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
transport transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
@@ -401,5 +328,5 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(client_id) Ok(info.id)
} }

View File

@@ -1,37 +1,18 @@
use arbiter_crypto::authn;
use arbiter_proto::{ClientMetadata, transport::Bi}; use arbiter_proto::{ClientMetadata, transport::Bi};
use kameo::actor::Spawn; use kameo::actor::Spawn;
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
actors::GlobalActors, actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::{Integrable, hashing::Hashable},
db, db,
peers::client::session::ClientSession,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClientProfile { pub struct ClientProfile {
pub pubkey: authn::PublicKey, pub pubkey: ed25519_dalek::VerifyingKey,
pub metadata: ClientMetadata, pub metadata: ClientMetadata,
} }
pub struct ClientCredentials {
pub pubkey: authn::PublicKey,
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.to_bytes());
self.nonce.hash(hasher);
}
}
pub struct ClientConnection { pub struct ClientConnection {
pub(crate) db: db::DatabasePool, pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors, pub(crate) actors: GlobalActors,
@@ -39,7 +20,10 @@ pub struct ClientConnection {
impl ClientConnection { impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self { pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors } Self {
db,
actors,
}
} }
} }
@@ -50,9 +34,7 @@ pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
where where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized, T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{ {
let fut = auth::authenticate(&mut props, transport); match auth::authenticate(&mut props, transport).await {
println!("authenticate future size: {}", std::mem::size_of_val(&fut));
match fut.await {
Ok(client_id) => { Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id)); ClientSession::spawn(ClientSession::new(props, client_id));
info!("Client authenticated, session started"); info!("Client authenticated, session started");

View File

@@ -6,28 +6,23 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{ use crate::{
actors::{ actors::{
GlobalActors, GlobalActors,
client::ClientConnection, flow_coordinator::RegisterClient,
evm::{ClientSignTransaction, SignTransactionError}, evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient, keyholder::KeyHolderState,
vault::VaultState,
}, },
crypto::integrity::{Nested, Verified},
db, db,
evm::VetError, evm::VetError,
}; };
use super::ClientConnection;
use super::ClientCredentials;
pub struct ClientSession { pub struct ClientSession {
props: ClientConnection, props: ClientConnection,
client_id: Verified<i32, Nested<ClientCredentials>>, client_id: i32,
} }
impl ClientSession { impl ClientSession {
pub(crate) fn new( pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
props: ClientConnection,
client_id: Verified<i32, Nested<ClientCredentials>>,
) -> Self {
Self { props, client_id } Self { props, client_id }
} }
} }
@@ -35,13 +30,13 @@ impl ClientSession {
#[messages] #[messages]
impl ClientSession { impl ClientSession {
#[message] #[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> { pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::vault::GetState; use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await { let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state, Ok(state) => state,
Err(err) => { Err(err) => {
error!(?err, actor = "client", "vault.query.failed"); error!(?err, actor = "client", "keyholder.query.failed");
return Err(Error::Internal); return Err(Error::Internal);
} }
}; };
@@ -60,7 +55,7 @@ impl ClientSession {
.actors .actors
.evm .evm
.ask(ClientSignTransaction { .ask(ClientSignTransaction {
client_id: *self.client_id.as_ref(), client_id: self.client_id,
wallet_address, wallet_address,
transaction, transaction,
}) })
@@ -98,13 +93,9 @@ impl Actor for ClientSession {
} }
impl ClientSession { impl ClientSession {
#[cfg(test)]
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self { pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors); let props = ClientConnection::new(db, actors);
Self { Self { props, client_id: 0 }
props,
client_id: Verified::new(0),
}
} }
} }

View File

@@ -7,77 +7,82 @@ use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{ use crate::{
actors::vault::{CreateNew, Decrypt, Vault}, actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self}, models::{self, SqliteTimestamp},
schema, schema,
}, },
evm::{ evm::{
self, ListError, RunKind, self, RunKind,
policies::{ policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, },
}, },
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
pub use crate::evm::safe_signer; pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum SignTransactionError { pub enum SignTransactionError {
#[error("Wallet not found")] #[error("Wallet not found")]
#[diagnostic(code(arbiter::evm::sign::wallet_not_found))]
WalletNotFound, WalletNotFound,
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Vault error: {0}")] #[error("Keyholder error: {0}")]
Vault(#[from] crate::actors::vault::Error), #[diagnostic(code(arbiter::evm::sign::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Vault mailbox error")] #[error("Keyholder mailbox error")]
VaultSend, #[diagnostic(code(arbiter::evm::sign::keyholder_send))]
KeyholderSend,
#[error("Signing error: {0}")] #[error("Signing error: {0}")]
#[diagnostic(code(arbiter::evm::sign::signing))]
Signing(#[from] alloy::signers::Error), Signing(#[from] alloy::signers::Error),
#[error("Policy error: {0}")] #[error("Policy error: {0}")]
#[diagnostic(code(arbiter::evm::sign::vet))]
Vet(#[from] evm::VetError), Vet(#[from] evm::VetError),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error { pub enum Error {
#[error("Vault error: {0}")] #[error("Keyholder error: {0}")]
Vault(#[from] crate::actors::vault::Error), #[diagnostic(code(arbiter::evm::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Vault mailbox error")] #[error("Keyholder mailbox error")]
VaultSend, #[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend,
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
} }
#[derive(Actor)] #[derive(Actor)]
pub struct EvmActor { pub struct EvmActor {
pub vault: ActorRef<Vault>, pub keyholder: ActorRef<KeyHolder>,
pub db: DatabasePool, pub db: DatabasePool,
pub rng: StdRng, pub rng: StdRng,
pub engine: evm::Engine, pub engine: evm::Engine,
} }
impl EvmActor { impl EvmActor {
pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self { pub fn new(keyholder: ActorRef<KeyHolder>, db: DatabasePool) -> Self {
// is it safe to seed rng from system once? // is it safe to seed rng from system once?
// todo: audit // todo: audit
let rng = StdRng::from_rng(&mut rng()); let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone(), vault.clone()); let engine = evm::Engine::new(db.clone());
Self { Self {
vault, keyholder,
db, db,
rng, rng,
engine, engine,
@@ -94,10 +99,10 @@ impl EvmActor {
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
let aead_id: i32 = self let aead_id: i32 = self
.vault .keyholder
.ask(CreateNew { plaintext }) .ask(CreateNew { plaintext })
.await .await
.map_err(|_| Error::VaultSend)?; .map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table) let wallet_id = insert_into(schema::evm_wallet::table)
@@ -136,59 +141,46 @@ impl EvmActor {
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
) -> Result<i32, Error> { ) -> Result<i32, DatabaseError> {
match grant { match grant {
SpecificGrant::EtherTransfer(settings) => self SpecificGrant::EtherTransfer(settings) => {
.engine self.engine
.create_grant::<EtherTransfer>(CombinedSettings { .create_grant::<EtherTransfer>(FullGrant {
shared: basic, basic,
specific: settings, specific: settings,
}) })
.await .await
.map_err(Error::from), }
SpecificGrant::TokenTransfer(settings) => self SpecificGrant::TokenTransfer(settings) => {
.engine self.engine
.create_grant::<TokenTransfer>(CombinedSettings { .create_grant::<TokenTransfer>(FullGrant {
shared: basic, basic,
specific: settings, specific: settings,
}) })
.await .await
.map_err(Error::from), }
} }
} }
#[message] #[message]
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> { 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 mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let vault = self.vault.clone(); diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| { .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// Box::pin(async move { .execute(&mut conn)
// diesel::update(schema::evm_basic_grant::table) .await
// .filter(schema::evm_basic_grant::id.eq(grant_id)) .map_err(DatabaseError::from)?;
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) Ok(())
// .execute(conn)
// .await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
} }
#[message] #[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await { Ok(self
Ok(grants) => Ok(grants), .engine
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)), .list_all_grants()
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)), .await
} .map_err(DatabaseError::from)?)
} }
#[message] #[message]
@@ -254,12 +246,12 @@ impl EvmActor {
drop(conn); drop(conn);
let raw_key: SafeCell<Vec<u8>> = self let raw_key: SafeCell<Vec<u8>> = self
.vault .keyholder
.ask(Decrypt { .ask(Decrypt {
aead_id: wallet.aead_encrypted_id, aead_id: wallet.aead_encrypted_id,
}) })
.await .await
.map_err(|_| SignTransactionError::VaultSend)?; .map_err(|_| SignTransactionError::KeyholderSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?; let signer = safe_signer::SafeSigner::from_cell(raw_key)?;

View File

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

View File

@@ -9,12 +9,10 @@ use kameo::{
}; };
use tracing::info; use tracing::info;
use crate::{ use crate::actors::{
actors::flow_coordinator::client_connect_approval::ClientApprovalController,
peers::{
client::{ClientProfile, session::ClientSession}, client::{ClientProfile, session::ClientSession},
user_agent::UserAgentSession, flow_coordinator::client_connect_approval::ClientApprovalController,
}, user_agent::session::UserAgentSession,
}; };
pub mod client_connect_approval; pub mod client_connect_approval;

View File

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

View File

@@ -1,21 +1,52 @@
use std::ops::Deref as _; use std::ops::Deref as _;
use argon2::{Algorithm, Argon2}; use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt};
use chacha20poly1305::{ use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce, AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload}, aead::{AeadMut, Error, Payload},
}; };
use rand::{ use rand::{
Rng as _, SeedableRng as _, Rng as _, SeedableRng,
rngs::{StdRng, SysRng}, rngs::{StdRng, SysRng},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub mod encryption; pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub mod integrity; 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>); pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell { 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. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell { pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
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()
}
};
#[allow(clippy::unwrap_used)] #[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 hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default()); let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| { password.read_inline(|password_source| {
@@ -137,11 +171,37 @@ pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::*;
derive_key, use crate::safe_cell::SafeCell;
encryption::v1::{Nonce, generate_salt},
}; #[test]
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; 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] #[test]
pub fn encrypt_decrypt() { pub fn encrypt_decrypt() {
@@ -149,7 +209,7 @@ mod tests {
let password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); 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 nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data"; let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec(); let mut buffer = b"secret data".to_vec();
@@ -166,4 +226,18 @@ mod tests {
let buffer = buffer.read(); let buffer = buffer.read();
assert_eq!(*buffer, b"secret data"); 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,92 +4,82 @@ use diesel::{
dsl::{insert_into, update}, dsl::{insert_into, update},
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _; use kameo::{Actor, Reply, messages};
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
use crate::crypto::{ use crate::safe_cell::SafeCell;
KeyCell, derive_key, use crate::{
encryption::v1::{self, Nonce}, db::{
integrity::v1::HmacSha256,
};
use crate::db::{
self, self,
models::{self, RootKeyHistory}, models::{self, RootKeyHistory},
schema::{self}, schema::{self},
},
safe_cell::SafeCellHandle as _,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use encryption::v1::{self, KeyCell, Nonce};
pub mod events { pub mod encryption;
#[derive(Clone, Copy)]
pub struct Bootstrapped;
#[derive(Clone, Copy)]
pub struct Unsealed;
#[derive(Clone, Copy)]
pub struct VaultResealed;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Vault is not bootstrapped")]
NotBootstrapped,
#[error("Vault is sealed")]
Sealed,
#[error("Invalid key provided")]
InvalidKey,
#[error("Requested aead entry not found")]
NotFound,
#[error("Encryption error: {0}")]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
BrokenDatabase,
}
struct Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
}
#[derive(Default, EnumDiscriminants)] #[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(VaultState))] #[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
enum State { enum State {
#[default] #[default]
Unbootstrapped, Unbootstrapped,
Sealed { Sealed {
root_key_history_id: i32, root_key_history_id: i32,
}, },
Unsealed(Unsealed), Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
},
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("Keyholder is already bootstrapped")]
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))]
AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))]
NotBootstrapped,
#[error("Invalid key provided")]
#[diagnostic(code(arbiter::keyholder::invalid_key))]
InvalidKey,
#[error("Requested aead entry not found")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))]
NotFound,
#[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::broken_database))]
BrokenDatabase,
} }
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor. /// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)] #[derive(Actor)]
pub struct Vault { pub struct KeyHolder {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>,
} }
#[messages] #[messages]
impl Vault { impl KeyHolder {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> { pub async fn new(db: db::DatabasePool) -> Result<Self, Error> {
let state = { let state = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
@@ -107,10 +97,10 @@ impl Vault {
} }
}; };
Ok(Self { db, state, events }) Ok(Self { db, state })
} }
// Exclusive transaction to avoid race condtions if multiple vaults write // Exclusive transaction to avoid race condtions if multiple keyholders write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> { async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
let mut conn = pool.get().await?; let mut conn = pool.get().await?;
@@ -124,7 +114,8 @@ impl Vault {
.first(conn) .first(conn)
.await?; .await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| { let mut nonce =
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!( error!(
"Broken database: invalid nonce for root key history id={}", "Broken database: invalid nonce for root key history id={}",
root_key_id root_key_id
@@ -147,26 +138,18 @@ impl Vault {
Ok(nonce) Ok(nonce)
} }
fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
}
}
#[message] #[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
let salt = v1::generate_salt(); 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(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default(); let root_key_nonce = v1::Nonce::default();
let data_encryption_nonce = Nonce::default(); let data_encryption_nonce = v1::Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice(); let root_key_reader = reader.as_slice();
@@ -207,13 +190,12 @@ impl Vault {
}) })
.await?; .await?;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
}); };
info!("Vault bootstrapped successfully"); info!("Keyholder bootstrapped successfully");
self.events.tell(Publish(events::Bootstrapped)).await;
Ok(()) Ok(())
} }
@@ -242,7 +224,7 @@ impl Vault {
error!("Broken database: invalid salt for root key"); error!("Broken database: invalid salt for root key");
Error::BrokenDatabase 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()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -260,23 +242,25 @@ impl Vault {
Error::InvalidKey Error::InvalidKey
})?; })?;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| { root_key: v1::KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size"); error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase Error::BrokenDatabase
})?, })?,
}); };
info!("Vault unsealed successfully"); info!("Keyholder unsealed successfully");
self.events.tell(Publish(events::Unsealed)).await;
Ok(()) Ok(())
} }
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped);
};
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
@@ -304,10 +288,13 @@ impl Vault {
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let Unsealed { let State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?; } = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire // Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
// Borrow checker note: &mut borrow a few lines above is disjoint from this field // Borrow checker note: &mut borrow a few lines above is disjoint from this field
@@ -337,69 +324,22 @@ impl Vault {
} }
#[message] #[message]
pub fn get_state(&self) -> VaultState { pub fn get_state(&self) -> KeyHolderState {
self.state.discriminant() self.state.discriminant()
} }
#[message] #[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> { pub fn seal(&mut self) -> Result<(), Error> {
let Unsealed { let State::Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
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 Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
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 async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id, root_key_history_id,
.. ..
} = Self::expect_unsealed(&mut self.state)?; } = &self.state
else {
return Err(Error::NotBootstrapped);
};
self.state = State::Sealed { self.state = State::Sealed {
root_key_history_id: *root_key_history_id, root_key_history_id: *root_key_history_id,
}; };
self.events.tell(Publish(events::VaultResealed)).await;
Ok(()) Ok(())
} }
} }
@@ -411,17 +351,14 @@ mod tests {
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use crate::{ use crate::{
actors::GlobalActors,
db::{self}, db::{self},
safe_cell::SafeCell,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::*; use super::*;
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault { async fn bootstrapped_actor(db: &db::DatabasePool) -> KeyHolder {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = KeyHolder::new(db.clone()).await.unwrap();
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
actor actor
@@ -433,17 +370,17 @@ mod tests {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state { let root_key_history_id = match actor.state {
State::Unsealed(Unsealed { State::Unsealed {
root_key_history_id, root_key_history_id,
.. ..
}) => root_key_history_id, } => root_key_history_id,
_ => panic!("expected unsealed state"), _ => panic!("expected unsealed state"),
}; };
let n1 = Vault::get_new_nonce(&db, root_key_history_id) let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id)
.await .await
.unwrap(); .unwrap();
let n2 = Vault::get_new_nonce(&db, root_key_history_id) let n2 = KeyHolder::get_new_nonce(&db, root_key_history_id)
.await .await
.unwrap(); .unwrap();
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");

View File

@@ -1,53 +1,50 @@
use kameo::actor::{ActorRef, Spawn}; use kameo::actor::{ActorRef, Spawn};
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus}; use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, vault::Vault, bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
keyholder::KeyHolder,
}, },
db, db,
}; };
pub mod bootstrap; pub mod bootstrap;
pub mod evm; pub mod client;
mod evm;
pub mod flow_coordinator; pub mod flow_coordinator;
pub mod vault; pub mod keyholder;
pub mod useragent_registry; pub mod user_agent;
#[derive(Error, Debug)] #[derive(Error, Debug, Diagnostic)]
pub enum SpawnError { pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")] #[error("Failed to spawn Bootstrapper actor")]
#[diagnostic(code(SpawnError::Bootstrapper))]
Bootstrapper(#[from] bootstrap::Error), Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn Vault actor")] #[error("Failed to spawn KeyHolder actor")]
Vault(#[from] vault::Error), #[diagnostic(code(SpawnError::KeyHolder))]
KeyHolder(#[from] keyholder::Error),
} }
/// Long-lived actors that are shared across all connections and handle global state and operations /// Long-lived actors that are shared across all connections and handle global state and operations
#[derive(Clone)] #[derive(Clone)]
pub struct GlobalActors { pub struct GlobalActors {
pub vault: ActorRef<Vault>, pub key_holder: ActorRef<KeyHolder>,
pub bootstrapper: ActorRef<Bootstrapper>, pub bootstrapper: ActorRef<Bootstrapper>,
pub flow_coordinator: ActorRef<FlowCoordinator>, pub flow_coordinator: ActorRef<FlowCoordinator>,
pub evm: ActorRef<EvmActor>, pub evm: ActorRef<EvmActor>,
pub events: ActorRef<MessageBus>,
} }
impl GlobalActors { impl GlobalActors {
pub fn spawn_message_bus() -> ActorRef<MessageBus> {
MessageBus::spawn(MessageBus::new(DeliveryStrategy::Guaranteed))
}
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let message_bus = Self::spawn_message_bus(); let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?);
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
Ok(Self { Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
vault: key_holder, key_holder,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()), flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()),
events: message_bus,
}) })
} }
} }

View File

@@ -1,16 +1,18 @@
use arbiter_crypto::authn;
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use tracing::error; use tracing::error;
use crate::actors::user_agent::{
AuthPublicKey, UserAgentConnection,
auth::state::{AuthContext, AuthStateMachine},
};
mod state; mod state;
use state::*; use state::*;
use super::{AuthCredentials, UserAgentConnection};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: authn::PublicKey, pubkey: AuthPublicKey,
bootstrap_token: Option<String>, bootstrap_token: Option<String>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
@@ -35,13 +37,6 @@ impl Error {
} }
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::internal("Database error")
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { nonce: i32 }, AuthChallenge { nonce: i32 },
@@ -68,10 +63,10 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
pub async fn authenticate<T>( pub async fn authenticate<T>(
props: &mut UserAgentConnection, props: &mut UserAgentConnection,
transport: &mut T, transport: T,
) -> Result<AuthCredentials, Error> ) -> Result<AuthPublicKey, Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized, T: Bi<Inbound, Result<Outbound, Error>> + Send,
{ {
let mut state = AuthStateMachine::new(AuthContext::new(props, transport)); let mut state = AuthStateMachine::new(AuthContext::new(props, transport));
@@ -82,7 +77,7 @@ where
}; };
match state.process_event(parse_auth_event(payload)).await { match state.process_event(parse_auth_event(payload)).await {
Ok(AuthStates::AuthOk(result)) => return Ok(result.clone()), Ok(AuthStates::AuthOk(key)) => return Ok(key.clone()),
Err(AuthError::ActionFailed(err)) => { Err(AuthError::ActionFailed(err)) => {
error!(?err, "State machine action failed"); error!(?err, "State machine action failed");
return Err(err); return Err(err);

View File

@@ -0,0 +1,225 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
},
db::schema,
};
pub struct ChallengeRequest {
pub pubkey: AuthPublicKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: AuthPublicKey,
pub token: String,
}
pub struct ChallengeContext {
pub challenge_nonce: i32,
pub key: AuthPublicKey,
}
pub struct ChallengeSolution {
pub solution: Vec<u8>,
}
smlang::statemachine!(
name: Auth,
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey),
}
);
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 {
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!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
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!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
Ok(())
}
pub struct AuthContext<'a, T> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: T,
}
impl<'a, T> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
type Error = Error;
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge_nonce: nonce,
key: pubkey,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
&mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthPublicKey, Self::Error> {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.map_err(|e| {
error!(?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);
}
register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
challenge_nonce,
key,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthPublicKey, Self::Error> {
let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes());
let valid = match key {
AuthPublicKey::Ed25519(vk) => {
let sig = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid Ed25519 signature length");
Error::InvalidChallengeSolution
})?;
vk.verify_strict(&formatted, &sig).is_ok()
}
AuthPublicKey::EcdsaSecp256k1(vk) => {
use k256::ecdsa::signature::Verifier as _;
let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid ECDSA signature bytes");
Error::InvalidChallengeSolution
})?;
vk.verify(&formatted, &sig).is_ok()
}
AuthPublicKey::Rsa(pk) => {
use rsa::signature::Verifier as _;
let verifying_key = rsa::pss::VerifyingKey::<sha2::Sha256>::new(pk.clone());
let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid RSA signature bytes");
Error::InvalidChallengeSolution
})?;
verifying_key.verify(&formatted, &sig).is_ok()
}
};
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

@@ -0,0 +1,94 @@
use crate::{
actors::{GlobalActors, client::ClientProfile},
db::{self, models::KeyType},
};
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug)]
pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey),
}
impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
pub fn to_stored_bytes(&self) -> Vec<u8> {
match self {
AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(),
// SEC1 compressed (33 bytes) is the natural compact format for secp256k1
AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(),
AuthPublicKey::Rsa(k) => {
use rsa::pkcs8::EncodePublicKey as _;
#[allow(clippy::expect_used)]
k.to_public_key_der()
.expect("rsa SPKI encoding is infallible")
.to_vec()
}
}
}
pub fn key_type(&self) -> KeyType {
match self {
AuthPublicKey::Ed25519(_) => KeyType::Ed25519,
AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1,
AuthPublicKey::Rsa(_) => KeyType::Rsa,
}
}
}
impl TryFrom<(KeyType, Vec<u8>)> for AuthPublicKey {
type Error = &'static str;
fn try_from(value: (KeyType, Vec<u8>)) -> Result<Self, Self::Error> {
let (key_type, bytes) = value;
match key_type {
KeyType::Ed25519 => {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| "invalid Ed25519 key length")?;
let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
.map_err(|_e| "invalid Ed25519 key")?;
Ok(AuthPublicKey::Ed25519(key))
}
KeyType::EcdsaSecp256k1 => {
let point =
k256::EncodedPoint::from_bytes(&bytes).map_err(|_e| "invalid ECDSA key")?;
let key = k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_e| "invalid ECDSA key")?;
Ok(AuthPublicKey::EcdsaSecp256k1(key))
}
KeyType::Rsa => {
use rsa::pkcs8::DecodePublicKey as _;
let key = rsa::RsaPublicKey::from_public_key_der(&bytes)
.map_err(|_e| "invalid RSA key")?;
Ok(AuthPublicKey::Rsa(key))
}
}
}
}
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile },
ClientConnectionCancel { pubkey: ed25519_dalek::VerifyingKey },
}
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
pub mod auth;
pub mod session;
pub use auth::authenticate;
pub use session::UserAgentSession;

View File

@@ -1,23 +1,20 @@
use arbiter_crypto::authn;
use diesel::{ExpressionMethods, QueryDsl};
use diesel_async::{RunQueryDsl};
use kameo_actors::message_bus::Register;
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use arbiter_proto::transport::Sender; use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages, prelude::Message}; use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, actor::ActorRef, messages};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
use crate::{ use crate::actors::{
actors::{ client::ClientProfile,
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController}, flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController},
vault::events, user_agent::{OutOfBand, UserAgentConnection},
}, crypto::integrity, db::schema::useragent_client, peers::{client::ClientProfile, user_agent::{AuthCredentials, Credentials}}
}; };
use super::{OutOfBand, UserAgentConnection}; mod state;
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
@@ -50,33 +47,52 @@ impl Error {
} }
pub struct PendingClientApproval { pub struct PendingClientApproval {
pubkey: authn::PublicKey,
controller: ActorRef<ClientApprovalController>, controller: ActorRef<ClientApprovalController>,
} }
pub struct UserAgentSession { pub struct UserAgentSession {
creds: Credentials,
props: UserAgentConnection, props: UserAgentConnection,
state: UserAgentStateMachine<DummyContext>,
sender: Box<dyn Sender<OutOfBand>>, sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>, pending_client_approvals: HashMap<VerifyingKey, PendingClientApproval>,
} }
pub mod handlers; pub mod connection;
impl UserAgentSession { impl UserAgentSession {
pub(crate) fn new( pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
props: UserAgentConnection,
creds: Credentials,
sender: Box<dyn Sender<OutOfBand>>,
) -> Self {
Self { Self {
creds,
props, props,
state: UserAgentStateMachine::new(DummyContext),
sender, sender,
pending_client_approvals: Default::default(), pending_client_approvals: Default::default(),
} }
} }
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
struct DummySender;
#[async_trait]
impl Sender<OutOfBand> for DummySender {
async fn send(
&mut self,
_item: OutOfBand,
) -> Result<(), arbiter_proto::transport::Error> {
Ok(())
}
}
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
Error::State
})?;
Ok(())
}
} }
#[messages] #[messages]
@@ -102,13 +118,8 @@ impl UserAgentSession {
return; return;
} }
self.pending_client_approvals.insert( self.pending_client_approvals
client.pubkey.to_bytes(), .insert(client.pubkey, PendingClientApproval { controller });
PendingClientApproval {
pubkey: client.pubkey,
controller,
},
);
} }
} }
@@ -147,18 +158,14 @@ impl Actor for UserAgentSession {
let cancelled_pubkey = self let cancelled_pubkey = self
.pending_client_approvals .pending_client_approvals
.iter() .iter()
.find_map(|(k, v)| (v.controller.id() == id).then_some(k.clone())); .find_map(|(k, v)| (v.controller.id() == id).then_some(*k));
if let Some(pubkey_bytes) = cancelled_pubkey { if let Some(pubkey) = cancelled_pubkey {
let Some(approval) = self.pending_client_approvals.remove(&pubkey_bytes) else { self.pending_client_approvals.remove(&pubkey);
return Ok(std::ops::ControlFlow::Continue(()));
};
if let Err(e) = self if let Err(e) = self
.sender .sender
.send(OutOfBand::ClientConnectionCancel { .send(OutOfBand::ClientConnectionCancel { pubkey })
pubkey: approval.pubkey,
})
.await .await
{ {
error!( error!(

View File

@@ -0,0 +1,510 @@
use std::sync::Mutex;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::prelude::Context;
use kameo::messages;
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::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
},
safe_cell::SafeCellHandle as _,
};
impl UserAgentSession {
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
return Err(Error::internal("Invalid state for unseal encrypted key"));
};
let ephemeral_secret = {
#[allow(
clippy::unwrap_used,
reason = "Mutex poison is unrecoverable and should panic"
)]
let mut secret_lock = unseal_context.secret.lock().unwrap();
let secret = secret_lock.take();
match secret {
Some(secret) => secret,
None => {
drop(secret_lock);
error!("Ephemeral secret already taken");
return Err(Error::internal("Ephemeral secret already taken"));
}
}
};
Ok((ephemeral_secret, unseal_context.client_public_key))
}
fn decrypt_client_key_material(
ephemeral_secret: EphemeralSecret,
client_public_key: PublicKey,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key);
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
pub struct UnsealStartResponse {
pub server_pubkey: PublicKey,
}
#[derive(Debug, Error)]
pub enum UnsealError {
#[error("Invalid key provided for unsealing")]
InvalidKey,
#[error("Internal error during unsealing process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("Invalid key provided for bootstrapping")]
InvalidKey,
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Internal error during bootstrapping process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn handle_unseal_request(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<UnsealStartResponse, Error> {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key: client_pubkey,
}))?;
Ok(UnsealStartResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), UnsealError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
Err(_err) => {
return Err(Error::internal("Failed to take unseal secret").into());
}
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub(crate) async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), BootstrapError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
Err(err) => return Err(err.into()),
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to bootstrap vault");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::General(Error::internal(
"Vault actor error",
)))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "keyholder.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> {
match self
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.await
{
Ok(()) => Ok(()),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: ed25519_dalek::VerifyingKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -0,0 +1,27 @@
use std::sync::Mutex;
use x25519_dalek::{EphemeralSecret, PublicKey};
pub struct UnsealContext {
pub client_public_key: PublicKey,
pub secret: Mutex<Option<EphemeralSecret>>,
}
smlang::statemachine!(
name: UserAgent,
custom_error: false,
transitions: {
*Idle + UnsealRequest(UnsealContext) / generate_temp_keypair = WaitingForUnsealKey(UnsealContext),
WaitingForUnsealKey(UnsealContext) + ReceivedValidKey = Unsealed,
WaitingForUnsealKey(UnsealContext) + ReceivedInvalidKey = Idle,
}
);
pub struct DummyContext;
impl UserAgentStateMachineContext for DummyContext {
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> {
Ok(event_data)
}
}

View File

@@ -1,57 +0,0 @@
use alloy::primitives::map::HashMap;
use arbiter_crypto::authn;
use kameo::{error::Infallible, prelude::*};
use crate::{db::DatabasePool, peers::user_agent::{Credentials, UserAgentSession}};
use super::vault::{Vault, events as vault_events};
pub struct Args {
pub vault: ActorRef<Vault>,
pub pool: DatabasePool,
}
pub struct UserAgentRegistry {
vault: ActorRef<Vault>,
pool: DatabasePool,
connected: HashMap<Credentials, ActorRef<UserAgentSession>>,
}
impl Message<vault_events::Bootstrapped> for UserAgentRegistry {
type Reply = ();
async fn handle(
&mut self,
msg: vault_events::Bootstrapped,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
todo!()
}
}
impl Message<vault_events::Unsealed> for UserAgentRegistry {
type Reply = ();
async fn handle(
&mut self,
msg: vault_events::Unsealed,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
todo!()
}
}
impl Actor for UserAgentRegistry {
type Args = Args;
type Error = Infallible;
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(Self {
vault: args.vault,
pool: args.pool,
connected: HashMap::default(),
})
}
}

View File

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

View File

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

View File

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

View File

@@ -1,107 +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;
use arbiter_crypto::safecell::{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,438 +0,0 @@
use crate::{actors::vault::{self, GetState}, crypto::integrity::hashing::Hashable};
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 crate::{
actors::vault::{SignIntegrity, Vault, 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, VerificationOrigin, Verified};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::DatabaseError),
#[error("Vault error: {0}")]
Vault(#[from] vault::Error),
#[error("Vault mailbox error")]
VaultSend,
#[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,
vault: &ActorRef<Vault>,
entity_id: Id,
load: F,
) -> Result<VerifiedEntity<E, Id>, 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, vault, entity, entity_id).await
}
pub async fn lookup_verified_from_query<E, Id, C, F>(
conn: &mut C,
vault: &ActorRef<Vault>,
load: F,
) -> Result<VerifiedEntity<E, Id>, 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, vault, entity, entity_id).await
}
pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
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) =
vault
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend,
})?;
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>,
vault: &ActorRef<Vault>,
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 = vault
.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(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend),
}
}
#[derive(Debug, Clone)]
#[repr(C)]
pub struct VerifiedEntity<E, Id> {
pub entity: Verified<E>,
pub entity_id: Verified<Id, Nested<E>>,
}
impl<E, Id> Deref for VerifiedEntity<E, Id> {
type Target = Verified<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>,
vault: &ActorRef<Vault>,
entity: E,
entity_id: Id,
) -> Result<VerifiedEntity<E, Id>, Error> {
match check_entity_attestation(conn, vault, &entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(VerifiedEntity {
entity: Verified::new(entity),
entity_id: Verified::new(entity_id),
}),
AttestationStatus::Unavailable => Err(Error::Vault(vault::Error::Sealed)),
}
}
pub async fn verify_entity_ref<'e, E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: &'e E,
entity_id: Id,
) -> Result<Verified<VerifiedEntity<&'e E, Id>, Nested<E>>, Error> {
match check_entity_attestation(conn, vault, entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(Verified::<VerifiedEntity<&'e E, Id>, Nested<E>>::new(
VerifiedEntity {
entity: Verified::new(entity),
entity_id: Verified::new(entity_id),
},
)),
AttestationStatus::Unavailable => Err(Error::Vault(vault::Error::Sealed)),
}
}
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)
}
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
Ok(matches!(state, vault::VaultState::Unsealed))
}
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 {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use sha2::Digest;
use crate::{
actors::{
GlobalActors,
vault::{Bootstrap, Vault},
},
db::{self, schema},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::hashing::Hashable;
use super::{Error, Integrable, check_entity_attestation, sign_entity};
#[derive(Clone)]
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_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&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, &vault, &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 status = check_entity_attestation(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
assert!(matches!(status, super::AttestationStatus::Attested));
}
#[tokio::test]
async fn tampered_mac_fails_verification() {
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&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, &vault, &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, &vault, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
}

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,151 +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;
impl private::Sealed for Root {}
impl VerificationOrigin for Root {
type Origin = Self;
}
/// 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<T, P: VerificationOrigin> private::Sealed for Nested<T, P> {}
impl<T, P: VerificationOrigin> VerificationOrigin for Nested<T, P> {
type Origin = P::Origin;
}
#[derive(Debug, Clone, PartialEq, Eq)]
// #[derive(Copy)] // fixme!: soundness: Unimplemented Copy helps to avoid accidentally origin-unqualifying due to Deref impl.
#[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<T> for Verified<T, O> {
fn as_ref(&self) -> &T {
&self.inner
}
}
impl<T, N: Integrable, O: VerificationOrigin> Deref for Verified<T, Nested<N, O>> {
type Target = Verified<T, O::Origin>;
fn deref(&self) -> &Self::Target {
// SAFETY: `Verified<T, _>` is `#[repr(transparent)]` over `T`, so
// `&Verified<T, Nested<U, O>>` and `&Verified<T, O::Origin>` have identical layout.
unsafe { &*(self as *const Self as *const Verified<T, O::Origin>) }
}
}
impl<T> Deref for Verified<T, Root> {
type Target = T;
fn deref(&self) -> &Self::Target {
AsRef::as_ref(self)
}
}
impl<T, O: VerificationOrigin> Verified<T, O> {
/// Unwraps the verified value, discarding the integrity provenance.
pub fn drop_verification_provenance(self) -> T {
self.inner
}
/// Downgrades the origin provenance by recursively resolving the terminal
/// origin of the verification chain.
pub fn unqualify_origin(self) -> Verified<T, O::Origin> {
Verified {
inner: self.inner,
origin: core::marker::PhantomData,
}
}
/// Constructs a `Verified<T>` by wrapping a `T`.
#[cfg(not(test))]
pub(super) const fn new(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
#[cfg(test)]
pub(crate) const fn new(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::integrity::v1::hashing::Hashable;
use hmac::digest::Digest;
use std::mem::{align_of, size_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Marker;
impl Hashable for Marker {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(b"marker");
}
}
impl Integrable for Marker {
const KIND: &'static str = "marker";
}
#[test]
fn verified_root_exposes_inner_value() {
let verified = Verified::<_, Root>::new("root-value");
assert_eq!(verified.as_ref(), &"root-value");
assert_eq!(*verified, "root-value");
assert_eq!(verified.drop_verification_provenance(), "root-value");
assert_eq!(size_of::<Verified<&str>>(), size_of::<&str>());
assert_eq!(align_of::<Verified<&str>>(), align_of::<&str>());
}
#[test]
fn nested_verified_derefs_back_to_root() {
let verified: Verified<_, Nested<Marker, Nested<Marker>>> = Verified::new("nested-value");
let _: &Verified<&str, Root> = &verified;
let root_view: Verified<&str, Root> = verified.unqualify_origin();
assert_eq!(root_view.as_ref(), &"nested-value");
}
#[test]
fn nested_verified_can_be_unqualified_to_root() {
let verified: Verified<_, Nested<Marker>> = Verified::new("nested-value");
let downgraded = verified.unqualify_origin();
assert_eq!(downgraded.as_ref(), &"nested-value");
assert_eq!(downgraded.drop_verification_provenance(), "nested-value");
}
}

View File

@@ -5,7 +5,7 @@ use diesel_async::{
sync_connection_wrapper::SyncConnectionWrapper, sync_connection_wrapper::SyncConnectionWrapper,
}; };
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::info;
@@ -21,21 +21,26 @@ static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Debug)] #[derive(Error, Diagnostic, Debug)]
pub enum DatabaseSetupError { pub enum DatabaseSetupError {
#[error("Failed to determine home directory")] #[error("Failed to determine home directory")]
#[diagnostic(code(arbiter::db::home_dir))]
HomeDir(std::io::Error), HomeDir(std::io::Error),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::connection))]
Connection(diesel::ConnectionError), Connection(diesel::ConnectionError),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::concurrency))]
ConcurrencySetup(diesel::result::Error), ConcurrencySetup(diesel::result::Error),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::migration))]
Migration(Box<dyn std::error::Error + Send + Sync>), Migration(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::pool))]
Pool(#[from] PoolInitError), Pool(#[from] PoolInitError),
} }
@@ -133,7 +138,6 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool) Ok(pool)
} }
#[mutants::skip]
pub async fn create_test_pool() -> DatabasePool { pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _}; use rand::distr::{Alphanumeric, SampleString as _};

View File

@@ -5,7 +5,7 @@ use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, 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 chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
@@ -72,6 +72,40 @@ pub mod types {
Ok(SqliteTimestamp(datetime)) Ok(SqliteTimestamp(datetime))
} }
} }
/// Key algorithm stored in the `useragent_client.key_type` column.
/// Values must stay stable — they are persisted in the database.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, strum::FromRepr)]
#[diesel(sql_type = Integer)]
#[repr(i32)]
pub enum KeyType {
Ed25519 = 1,
EcdsaSecp256k1 = 2,
Rsa = 3,
}
impl ToSql<Integer, Sqlite> for KeyType {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
out.set_value(*self as i32);
Ok(IsNull::No)
}
}
impl FromSql<Integer, Sqlite> for KeyType {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err("Expected Integer for KeyType".into());
};
let discriminant = bytes.read_long();
KeyType::from_repr(discriminant as i32)
.ok_or_else(|| format!("Unknown KeyType discriminant: {discriminant}").into())
}
}
} }
pub use types::*; pub use types::*;
@@ -210,6 +244,7 @@ pub struct UseragentClient {
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
@@ -341,22 +376,3 @@ pub struct EvmTokenTransferLog {
pub value: Vec<u8>, pub value: Vec<u8>,
pub created_at: SqliteTimestamp, 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! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
@@ -232,7 +219,6 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_transaction_log, evm_transaction_log,
evm_wallet, evm_wallet,
evm_wallet_access, evm_wallet_access,
integrity_envelope,
program_client, program_client,
root_key_history, root_key_history,
tls_history, tls_history,

View File

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

@@ -6,11 +6,10 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite, ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess}, db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils, evm::utils,
}; };
@@ -34,31 +33,34 @@ pub struct EvalContext {
pub max_priority_fee_per_gas: u128, pub max_priority_fee_per_gas: u128,
} }
#[derive(Debug, Error)] #[derive(Debug, Error, Diagnostic)]
pub enum EvalViolation { pub enum EvalViolation {
#[error("This grant doesn't allow transactions to the target address {target}")] #[error("This grant doesn't allow transactions to the target address {target}")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_target))]
InvalidTarget { target: Address }, InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")] #[error("Gas limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::gas_limit_exceeded))]
GasLimitExceeded { GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>, max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>, max_priority_fee_per_gas: Option<U256>,
}, },
#[error("Rate limit exceeded for this grant")] #[error("Rate limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::rate_limit_exceeded))]
RateLimitExceeded, RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")] #[error("Transaction exceeds volumetric limits of the grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::volumetric_limit_exceeded))]
VolumetricLimitExceeded, VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")] #[error("Transaction is outside of the grant's validity period")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_time))]
InvalidTime, InvalidTime,
#[error("Transaction type is not allowed by this grant")] #[error("Transaction type is not allowed by this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_transaction_type))]
InvalidTransactionType, InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
} }
pub type DatabaseID = i32; pub type DatabaseID = i32;
@@ -66,12 +68,13 @@ pub type DatabaseID = i32;
#[derive(Debug)] #[derive(Debug)]
pub struct Grant<PolicySettings> { pub struct Grant<PolicySettings> {
pub id: DatabaseID, pub id: DatabaseID,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub settings: CombinedSettings<PolicySettings>, pub shared: SharedGrantSettings,
pub settings: PolicySettings,
} }
pub trait Policy: Sized { 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>; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>; fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -127,13 +130,13 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning), TokenTransfer(token_transfers::Meaning),
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TransactionRateLimit { pub struct TransactionRateLimit {
pub count: u32, pub count: u32,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VolumeRateLimit { pub struct VolumeRateLimit {
pub max_volume: U256, pub max_volume: U256,
pub window: Duration, pub window: Duration,
@@ -154,7 +157,7 @@ pub struct SharedGrantSettings {
} }
impl SharedGrantSettings { impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> { fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self { Ok(Self {
wallet_access_id: model.wallet_access_id, wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants 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 +203,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings), TokenTransfer(token_transfers::Settings),
} }
#[derive(Debug)] pub struct FullGrant<PolicyGrant> {
pub struct CombinedSettings<PolicyGrant> { pub basic: SharedGrantSettings,
pub shared: SharedGrantSettings,
pub specific: PolicyGrant, 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::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{ use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit, EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp, NewEvmEtherTransferLimit, SqliteTimestamp,
}; };
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log}; use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{ use crate::evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
}; };
use crate::{ use crate::{
db::{ db::{
@@ -57,18 +56,6 @@ pub struct Settings {
pub target: Vec<Address>, pub target: Vec<Address>,
pub limit: VolumeRateLimit, 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 { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> SpecificGrant {
@@ -108,17 +95,17 @@ async fn check_rate_limits(
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let window = grant.settings.specific.limit.window; let window = grant.settings.limit.window;
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?; let 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 let prospective_cumulative_volume: U256 = past_transaction
.iter() .iter()
.filter(|(_, timestamp)| timestamp >= &window_start) .filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value); .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); violations.push(EvalViolation::VolumetricLimitExceeded);
} }
@@ -151,7 +138,7 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new(); let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets // 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 }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
} }
@@ -260,11 +247,9 @@ impl Policy for EtherTransfer {
Ok(Some(Grant { Ok(Some(Grant {
id: grant.id, id: grant.id,
common_settings_id: grant.basic_grant_id, shared_grant_id: grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?, shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings, settings,
},
})) }))
} }
@@ -342,10 +327,9 @@ impl Policy for EtherTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
common_settings_id: specific.basic_grant_id, shared_grant_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?, shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings { settings: Settings {
target: targets, target: targets,
limit: VolumeRateLimit { limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
@@ -354,7 +338,6 @@ impl Policy for EtherTransfer {
window: Duration::seconds(limit.window_secs as i64), window: Duration::seconds(limit.window_secs as i64),
}, },
}, },
},
}) })
}) })
.collect() .collect()

View File

@@ -11,10 +11,7 @@ use crate::db::{
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}; };
use crate::evm::{ use crate::evm::{
policies::{ policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils, utils,
}; };
@@ -84,6 +81,8 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_matches_empty_calldata() { fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap(); 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()); assert!(EtherTransfer::analyze(&context).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_passes_for_allowed_target() { async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -107,11 +108,9 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000), settings: make_settings(vec![ALLOWED], 1_000_000),
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -128,11 +127,9 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000), settings: make_settings(vec![ALLOWED], 1_000_000),
},
}; };
let context = ctx(OTHER, U256::from(100u64)); let context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -170,11 +167,9 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -212,11 +207,9 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, settings,
},
}; };
let context = ctx(ALLOWED, U256::from(1u64)); let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -255,11 +248,9 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -272,6 +263,8 @@ async fn evaluate_passes_at_exactly_volume_limit() {
); );
} }
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn try_find_grant_roundtrip() { async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -289,11 +282,8 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.specific.target, vec![ALLOWED]); assert_eq!(g.settings.target, vec![ALLOWED]);
assert_eq!( assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64));
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -330,36 +320,7 @@ async fn try_find_grant_wrong_target_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
proptest::proptest! { // ── find_all_grants ──────────────────────────────────────────────────────
#[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());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { 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(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.target, vec![ALLOWED]); assert_eq!(all[0].settings.target, vec![ALLOWED]);
} }
#[tokio::test] #[tokio::test]
@@ -402,11 +363,8 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.target.len(), 2); assert_eq!(all[0].settings.target.len(), 2);
assert_eq!( assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64));
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -1,5 +1,20 @@
use std::collections::HashMap; 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::{ use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit, evm_token_transfer_volume_limit,
@@ -11,25 +26,6 @@ use crate::evm::{
}, },
utils, 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}; use super::{DatabaseID, EvalContext, EvalViolation};
@@ -42,9 +38,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
pub token: &'static TokenInfo, pub(crate) token: &'static TokenInfo,
pub to: Address, pub(crate) to: Address,
pub value: U256, pub(crate) value: U256,
} }
impl std::fmt::Display for Meaning { impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -68,20 +64,6 @@ pub struct Settings {
pub target: Option<Address>, pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>, 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 { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val) SpecificGrant::TokenTransfer(val)
@@ -124,20 +106,13 @@ async fn check_volume_rate_limits(
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let Some(longest_window) = grant let Some(longest_window) = grant.settings.volume_limits.iter().map(|l| l.window).max() else {
.settings
.specific
.volume_limits
.iter()
.map(|l| l.window)
.max()
else {
return Ok(violations); return Ok(violations);
}; };
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.specific.volume_limits { for limit in &grant.settings.volume_limits {
let window_start = chrono::Utc::now() - limit.window; let window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers let prospective_cumulative_volume: U256 = past_transfers
.iter() .iter()
@@ -183,7 +158,7 @@ impl Policy for TokenTransfer {
return Ok(violations); return Ok(violations);
} }
if let Some(allowed) = grant.settings.specific.target if let Some(allowed) = grant.settings.target
&& allowed != meaning.to && allowed != meaning.to
{ {
violations.push(EvalViolation::InvalidTarget { target: meaning.to }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
@@ -294,11 +269,9 @@ impl Policy for TokenTransfer {
Ok(Some(Grant { Ok(Some(Grant {
id: token_grant.id, id: token_grant.id,
common_settings_id: token_grant.basic_grant_id, shared_grant_id: token_grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?, shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings, settings,
},
})) }))
} }
@@ -396,15 +369,13 @@ impl Policy for TokenTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
common_settings_id: specific.basic_grant_id, shared_grant_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?, shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings { settings: Settings {
token_contract: Address::from(token_contract), token_contract: Address::from(token_contract),
target, target,
volume_limits, volume_limits,
}, },
},
}) })
}) })
.collect() .collect()

View File

@@ -11,10 +11,7 @@ use crate::db::{
}; };
use crate::evm::{ use crate::evm::{
abi::IERC20::transferCall, abi::IERC20::transferCall,
policies::{ policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils, utils,
}; };
@@ -101,6 +98,8 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_known_token_valid_calldata() { fn analyze_known_token_valid_calldata() {
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); 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()); assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() { async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -133,11 +134,9 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(None, None), settings: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata); let mut context = ctx(DAI, calldata);
@@ -164,11 +163,9 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(None, None), settings: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -186,11 +183,9 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(Some(RECIPIENT), None), settings: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -208,11 +203,9 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(Some(RECIPIENT), None), settings: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(OTHER, U256::from(100u64)); let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -254,11 +247,9 @@ async fn evaluate_passes_volume_at_exact_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -299,11 +290,9 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -324,11 +313,9 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(None, None), // no volume limits settings: make_settings(None, None), // no volume limits
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX)); let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -362,13 +349,10 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.specific.token_contract, DAI); assert_eq!(g.settings.token_contract, DAI);
assert_eq!(g.settings.specific.target, Some(RECIPIENT)); assert_eq!(g.settings.target, Some(RECIPIENT));
assert_eq!(g.settings.specific.volume_limits.len(), 1); assert_eq!(g.settings.volume_limits.len(), 1);
assert_eq!( assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64));
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -408,39 +392,7 @@ async fn try_find_grant_unknown_token_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
proptest::proptest! { // ── find_all_grants ──────────────────────────────────────────────────────
#[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());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { 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(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); 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!( assert_eq!(
all[0].settings.specific.volume_limits[0].max_volume, all[0].settings.volume_limits[0].max_volume,
U256::from(9_999u64) U256::from(9_999u64)
); );
} }

View File

@@ -1,12 +1,12 @@
use std::sync::Mutex; use std::sync::Mutex;
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
use alloy::{ use alloy::{
consensus::SignableTransaction, consensus::SignableTransaction,
network::{TxSigner, TxSignerSync}, network::{TxSigner, TxSignerSync},
primitives::{Address, B256, ChainId, Signature}, primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address}, signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use async_trait::async_trait; use async_trait::async_trait;
use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner}; use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner};

View File

@@ -10,8 +10,8 @@ use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
actors::client::{ClientConnection, session::ClientSession},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::client::{ClientConnection, session::ClientSession},
}; };
mod auth; mod auth;

View File

@@ -1,4 +1,3 @@
use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata,
proto::{ proto::{
@@ -22,9 +21,8 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
crypto::integrity::{Nested, Verified}, actors::client::{self, ClientConnection, auth},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::client::{self, ClientConnection, ClientCredentials, auth},
}; };
pub struct AuthTransportAdapter<'a> { pub struct AuthTransportAdapter<'a> {
@@ -47,7 +45,7 @@ impl<'a> AuthTransportAdapter<'a> {
match response { match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => { auth::Outbound::AuthChallenge { pubkey, nonce } => {
AuthResponsePayload::Challenge(ProtoAuthChallenge { AuthResponsePayload::Challenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes(), pubkey: pubkey.to_bytes().to_vec(),
nonce, nonce,
}) })
} }
@@ -70,7 +68,6 @@ impl<'a> AuthTransportAdapter<'a> {
auth::Error::ApproveError(auth::ApproveError::Internal) auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable | auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed | auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal, | auth::Error::Transport => ProtoAuthResult::Internal,
} }
.into(), .into(),
@@ -143,9 +140,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let Some(payload) = auth_request.payload else { let Some(payload) = auth_request.payload else {
let _ = self let _ = self
.bi .bi
.send(Err(Status::invalid_argument( .send(Err(Status::invalid_argument("Missing client auth request payload")))
"Missing client auth request payload",
)))
.await; .await;
return None; return None;
}; };
@@ -162,7 +157,11 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
.await; .await;
return None; return None;
}; };
let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else { let Ok(pubkey) = <[u8; 32]>::try_from(pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
};
let Ok(pubkey) = ed25519_dalek::VerifyingKey::from_bytes(&pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await; let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None; return None;
}; };
@@ -171,8 +170,10 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
metadata: client_metadata_from_proto(client_info), metadata: client_metadata_from_proto(client_info),
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution {
let Ok(signature) = authn::Signature::try_from(signature.as_slice()) else { signature,
}) => {
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else {
let _ = self let _ = self
.send_auth_result(ProtoAuthResult::InvalidSignature) .send_auth_result(ProtoAuthResult::InvalidSignature)
.await; .await;
@@ -198,7 +199,7 @@ pub async fn start(
conn: &mut ClientConnection, conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>, bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker, request_tracker: &mut RequestTracker,
) -> Result<Verified<i32, Nested<ClientCredentials>>, auth::Error> { ) -> Result<i32, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker); let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await client::auth::authenticate(conn, &mut transport).await
} }

View File

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

View File

@@ -13,8 +13,10 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::vault::VaultState, actors::{
peers::client::session::{ClientSession, Error, HandleQueryVaultState}, client::session::{ClientSession, Error, HandleQueryVaultState},
keyholder::KeyHolderState,
},
}; };
pub(super) async fn dispatch( pub(super) async fn dispatch(
@@ -22,17 +24,15 @@ pub(super) async fn dispatch(
req: proto_vault::Request, req: proto_vault::Request,
) -> Result<ClientResponsePayload, Status> { ) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else { let Some(payload) = req.payload else {
return Err(Status::invalid_argument( return Err(Status::invalid_argument("Missing client vault request payload"));
"Missing client vault request payload",
));
}; };
match payload { match payload {
VaultRequestPayload::QueryState(_) => { VaultRequestPayload::QueryState(_) => {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to query vault state"); warn!(error = ?err, "Failed to query vault state");

View File

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

View File

@@ -1,14 +1,11 @@
use alloy::primitives::U256; use alloy::primitives::U256;
use arbiter_proto::proto::{ use arbiter_proto::proto::{
evm::{ evm::{EvmError as ProtoEvmError, evm_sign_transaction_response::Result as EvmSignTransactionResult},
EvmError as ProtoEvmError,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::{ shared::evm::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError, EvalViolation as ProtoEvalViolation, GasLimitExceededViolation,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning, NoMatchingGrantError, PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError, 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, specific_meaning::Meaning as ProtoSpecificMeaningKind,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind, transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
}, },
@@ -79,12 +76,6 @@ impl Convert for EvalViolation {
EvalViolation::InvalidTransactionType => { EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(()) ProtoEvalViolationKind::InvalidTransactionType(())
} }
EvalViolation::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
})
}
}; };
ProtoEvalViolation { kind: Some(kind) } ProtoEvalViolation { kind: Some(kind) }
@@ -114,12 +105,12 @@ impl Convert for VetError {
violations: violations.into_iter().map(Convert::convert).collect(), violations: violations.into_iter().map(Convert::convert).collect(),
}) })
} }
PolicyError::Database(_) | PolicyError::Integrity(_) => { PolicyError::Database(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into()); return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
} }
}, },
}; };
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }) EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
} }
} }

View File

@@ -10,7 +10,8 @@ use tonic::{Request, Response, Status, async_trait};
use tracing::info; use tracing::info;
use crate::{ use crate::{
peers::{client::ClientConnection, user_agent::UserAgentConnection}, actors::{client::ClientConnection, user_agent::UserAgentConnection},
grpc::user_agent::start,
}; };
mod request_tracker; mod request_tracker;
@@ -62,7 +63,7 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Ser
let (bi, rx) = GrpcBi::from_bi_stream(req_stream); let (bi, rx) = GrpcBi::from_bi_stream(req_stream);
tokio::spawn(user_agent::start( tokio::spawn(start(
UserAgentConnection { UserAgentConnection {
db: self.context.db.clone(), db: self.context.db.clone(),
actors: self.context.actors.clone(), actors: self.context.actors.clone(),

View File

@@ -1,30 +1,30 @@
use tokio::sync::mpsc; use tokio::sync::mpsc;
use arbiter_proto::{ use arbiter_proto::{
proto::user_agent::{ proto::{
user_agent::{
UserAgentRequest, UserAgentResponse, UserAgentRequest, UserAgentResponse,
user_agent_request::Payload as UserAgentRequestPayload, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}, },
},
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use kameo::actor::ActorRef; use kameo::actor::{ActorRef, Spawn as _};
use tonic::Status; use tonic::Status;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
}; };
mod auth; mod auth;
mod evm; mod evm;
mod inbound; mod inbound;
mod outbound; mod outbound;
mod sdk_client; mod sdk_client;
mod vault; mod vault;
mod vault_gate;
pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>); pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>);
@@ -125,23 +125,21 @@ pub async fn start(
) { ) {
let mut request_tracker = RequestTracker::default(); let mut request_tracker = RequestTracker::default();
let (oob_sender, oob_receiver) = mpsc::channel(16); let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
let oob_adapter = OutOfBandAdapter(oob_sender); Ok(pubkey) => pubkey,
let actor = {
let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
match crate::peers::user_agent::start(&mut conn, transport, Box::new(oob_adapter)).await {
Ok(actor) => actor,
Err(e) => { Err(e) => {
warn!(error = ?e, "User agent connection failed"); warn!(error = ?e, "Authentication failed");
return; return;
} }
}
}; };
info!("User agent session established"); let (oob_sender, oob_receiver) = mpsc::channel(16);
let oob_adapter = OutOfBandAdapter(oob_sender);
dispatch_loop(bi, actor.clone(), oob_receiver, request_tracker).await; let actor = UserAgentSession::spawn(UserAgentSession::new(conn, Box::new(oob_adapter)));
actor.kill(); let actor_for_cleanup = actor.clone();
info!(?pubkey, "User authenticated successfully");
dispatch_loop(bi, actor, oob_receiver, request_tracker).await;
actor_for_cleanup.kill();
} }

View File

@@ -1,14 +1,12 @@
use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
proto::user_agent::{ proto::user_agent::{
UserAgentRequest, UserAgentResponse, UserAgentRequest, UserAgentResponse, auth::{
auth::{
self as proto_auth, AuthChallenge as ProtoAuthChallenge, self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload, KeyType as ProtoKeyType, request::Payload as AuthRequestPayload,
}, response::Payload as AuthResponsePayload,
user_agent_request::Payload as UserAgentRequestPayload, }, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}, },
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
@@ -18,13 +16,14 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::user_agent::{AuthPublicKey, UserAgentConnection, auth},
db::models::KeyType,
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::user_agent::{AuthCredentials, UserAgentConnection, auth},
}; };
pub struct AuthTransportAdapter<'a> { pub struct AuthTransportAdapter<'a> {
pub(super) bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>, bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
pub(super) request_tracker: &'a mut RequestTracker, request_tracker: &'a mut RequestTracker,
} }
impl<'a> AuthTransportAdapter<'a> { impl<'a> AuthTransportAdapter<'a> {
@@ -38,32 +37,16 @@ impl<'a> AuthTransportAdapter<'a> {
} }
} }
pub(super) fn bi_mut(&mut self) -> &mut GrpcBi<UserAgentRequest, UserAgentResponse> {
self.bi
}
pub(super) fn tracker_mut(&mut self) -> &mut RequestTracker {
self.request_tracker
}
pub(super) async fn send_response_payload(
&mut self,
payload: UserAgentResponsePayload,
) -> Result<(), TransportError> {
self.bi
.send(Ok(UserAgentResponse {
id: Some(self.request_tracker.current_request_id()),
payload: Some(payload),
}))
.await
}
async fn send_user_agent_response( async fn send_user_agent_response(
&mut self, &mut self,
payload: AuthResponsePayload, payload: AuthResponsePayload,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
self.send_response_payload(UserAgentResponsePayload::Auth(proto_auth::Response { self.bi
.send(Ok(UserAgentResponse {
id: Some(self.request_tracker.current_request_id()),
payload: Some(UserAgentResponsePayload::Auth(proto_auth::Response {
payload: Some(payload), payload: Some(payload),
})),
})) }))
.await .await
} }
@@ -80,9 +63,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
Ok(Outbound::AuthChallenge { nonce }) => { Ok(Outbound::AuthChallenge { nonce }) => {
AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce }) AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce })
} }
Ok(Outbound::AuthSuccess) => { Ok(Outbound::AuthSuccess) => AuthResponsePayload::Result(ProtoAuthResult::Success.into()),
AuthResponsePayload::Result(ProtoAuthResult::Success.into())
}
Err(Error::UnregisteredPublicKey) => { Err(Error::UnregisteredPublicKey) => {
AuthResponsePayload::Result(ProtoAuthResult::InvalidKey.into()) AuthResponsePayload::Result(ProtoAuthResult::InvalidKey.into())
} }
@@ -156,8 +137,28 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest { AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
pubkey, pubkey,
bootstrap_token, bootstrap_token,
key_type,
}) => { }) => {
let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else { let Ok(key_type) = ProtoKeyType::try_from(key_type) else {
warn!(
event = "received request with invalid key type",
"grpc.useragent.auth_adapter"
);
return None;
};
let key_type = match key_type {
ProtoKeyType::Ed25519 => KeyType::Ed25519,
ProtoKeyType::EcdsaSecp256k1 => KeyType::EcdsaSecp256k1,
ProtoKeyType::Rsa => KeyType::Rsa,
ProtoKeyType::Unspecified => {
warn!(
event = "received request with unspecified key type",
"grpc.useragent.auth_adapter"
);
return None;
}
};
let Ok(pubkey) = AuthPublicKey::try_from((key_type, pubkey)) else {
warn!( warn!(
event = "received request with invalid public key", event = "received request with invalid public key",
"grpc.useragent.auth_adapter" "grpc.useragent.auth_adapter"
@@ -170,9 +171,9 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
bootstrap_token, bootstrap_token,
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution {
Some(auth::Inbound::AuthChallengeSolution { signature }) signature,
} }) => Some(auth::Inbound::AuthChallengeSolution { signature }),
} }
} }
} }
@@ -183,7 +184,7 @@ pub async fn start(
conn: &mut UserAgentConnection, conn: &mut UserAgentConnection,
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>, bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &mut RequestTracker, request_tracker: &mut RequestTracker,
) -> Result<AuthCredentials, auth::Error> { ) -> Result<AuthPublicKey, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker); let transport = AuthTransportAdapter::new(bi, request_tracker);
auth::authenticate(conn, &mut transport).await auth::authenticate(conn, transport).await
} }

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
use arbiter_crypto::authn;
use arbiter_proto::proto::{ use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{ user_agent::{
sdk_client::{ sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel, self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
@@ -15,21 +13,22 @@ use arbiter_proto::proto::{
}, },
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}, },
shared::ClientInfo as ProtoClientMetadata,
}; };
use kameo::actor::ActorRef; use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
db::models::NewEvmWalletAccess, actors::user_agent::{
grpc::Convert,
peers::user_agent::{
OutOfBand, UserAgentSession, OutOfBand, UserAgentSession,
session::handlers::{ session::connection::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove, HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleRevokeEvmWalletAccess, HandleSdkClientList,
}, },
}, },
db::models::NewEvmWalletAccess,
grpc::Convert,
}; };
fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload { fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
@@ -42,7 +41,7 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
match oob { match oob {
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response( OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest { SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
pubkey: profile.pubkey.to_bytes(), pubkey: profile.pubkey.to_bytes().to_vec(),
info: Some(ProtoClientMetadata { info: Some(ProtoClientMetadata {
name: profile.metadata.name, name: profile.metadata.name,
description: profile.metadata.description, description: profile.metadata.description,
@@ -52,7 +51,7 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
), ),
OutOfBand::ClientConnectionCancel { pubkey } => wrap_sdk_client_response( OutOfBand::ClientConnectionCancel { pubkey } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionCancel(ProtoSdkClientConnectionCancel { SdkClientResponsePayload::ConnectionCancel(ProtoSdkClientConnectionCancel {
pubkey: pubkey.to_bytes(), pubkey: pubkey.to_bytes().to_vec(),
}), }),
), ),
} }
@@ -63,22 +62,18 @@ pub(super) async fn dispatch(
req: proto_sdk_client::Request, req: proto_sdk_client::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else { let Some(payload) = req.payload else {
return Err(Status::invalid_argument( return Err(Status::invalid_argument("Missing SDK client request payload"));
"Missing SDK client request payload",
));
}; };
match payload { match payload {
SdkClientRequestPayload::ConnectionResponse(resp) => { SdkClientRequestPayload::ConnectionResponse(resp) => {
handle_connection_response(actor, resp).await handle_connection_response(actor, resp).await
} }
SdkClientRequestPayload::Revoke(_) => Err(Status::unimplemented( SdkClientRequestPayload::Revoke(_) => {
"SdkClientRevoke is not yet implemented", Err(Status::unimplemented("SdkClientRevoke is not yet implemented"))
)),
SdkClientRequestPayload::List(_) => handle_list(actor).await,
SdkClientRequestPayload::GrantWalletAccess(req) => {
handle_grant_wallet_access(actor, req).await
} }
SdkClientRequestPayload::List(_) => handle_list(actor).await,
SdkClientRequestPayload::GrantWalletAccess(req) => handle_grant_wallet_access(actor, req).await,
SdkClientRequestPayload::RevokeWalletAccess(req) => { SdkClientRequestPayload::RevokeWalletAccess(req) => {
handle_revoke_wallet_access(actor, req).await handle_revoke_wallet_access(actor, req).await
} }
@@ -90,8 +85,10 @@ async fn handle_connection_response(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
resp: ProtoSdkClientConnectionResponse, resp: ProtoSdkClientConnectionResponse,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice()) let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
.map_err(|_| Status::invalid_argument("Invalid ML-DSA public key"))?; .map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?;
actor actor
.ask(HandleNewClientApprove { .ask(HandleNewClientApprove {
@@ -116,7 +113,7 @@ async fn handle_list(
.into_iter() .into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry { .map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id, id: client.id,
pubkey: client.public_key.to_vec(), pubkey: client.public_key,
info: Some(ProtoClientMetadata { info: Some(ProtoClientMetadata {
name: metadata.name, name: metadata.name,
description: metadata.description, description: metadata.description,
@@ -131,11 +128,11 @@ async fn handle_list(
ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into()) ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into())
} }
}; };
Ok(Some(wrap_sdk_client_response( Ok(Some(wrap_sdk_client_response(SdkClientResponsePayload::List(
SdkClientResponsePayload::List(ProtoSdkClientListResponse { ProtoSdkClientListResponse {
result: Some(result), result: Some(result),
}), },
))) ))))
} }
async fn handle_grant_wallet_access( async fn handle_grant_wallet_access(

View File

@@ -1,15 +1,37 @@
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use arbiter_proto::proto::user_agent::{ use arbiter_proto::proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
vault::{self as proto_vault, request::Payload as VaultRequestPayload, response::Payload as VaultResponsePayload}, vault::{
self as proto_vault,
bootstrap::{
self as proto_bootstrap, BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
BootstrapResult as ProtoBootstrapResult,
},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart,
request::Payload as UnsealRequestPayload,
response::Payload as UnsealResponsePayload,
},
},
}; };
use kameo::actor::ActorRef; use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use kameo::{actor::ActorRef, error::SendError};
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::vault::VaultState, actors::{
peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState}, keyholder::KeyHolderState,
user_agent::{
UserAgentSession,
session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleQueryVaultState,
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
},
}; };
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload { fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
@@ -18,6 +40,18 @@ fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayloa
}) })
} }
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
pub(super) async fn dispatch( pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
req: proto_vault::Request, req: proto_vault::Request,
@@ -28,21 +62,114 @@ pub(super) async fn dispatch(
match payload { match payload {
VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await, VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { VaultRequestPayload::Unseal(req) => dispatch_unseal_request(actor, req).await,
Err(Status::permission_denied( VaultRequestPayload::Bootstrap(req) => handle_bootstrap_request(actor, req).await,
"Vault is already unsealed; unseal/bootstrap not permitted in session",
))
} }
}
async fn dispatch_unseal_request(
actor: &ActorRef<UserAgentSession>,
req: proto_unseal::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing unseal request payload"));
};
match payload {
UnsealRequestPayload::Start(req) => handle_unseal_start(actor, req).await,
UnsealRequestPayload::EncryptedKey(req) => handle_unseal_encrypted_key(actor, req).await,
} }
} }
async fn handle_unseal_start(
actor: &ActorRef<UserAgentSession>,
req: UnsealStart,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let client_pubkey = <[u8; 32]>::try_from(req.client_pubkey)
.map(x25519_dalek::PublicKey::from)
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?;
let response = actor
.ask(HandleUnsealRequest { client_pubkey })
.await
.map_err(|err| {
warn!(error = ?err, "Failed to handle unseal start request");
Status::internal("Failed to start unseal flow")
})?;
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
))))
}
async fn handle_unseal_encrypted_key(
actor: &ActorRef<UserAgentSession>,
req: ProtoUnsealEncryptedKey,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleUnsealEncryptedKey {
nonce: req.nonce,
ciphertext: req.ciphertext,
associated_data: req.associated_data,
})
.await
{
Ok(()) => ProtoUnsealResult::Success,
Err(SendError::HandlerError(UnsealError::InvalidKey)) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(error = ?err, "Failed to handle unseal request");
return Err(Status::internal("Failed to unseal vault"));
}
};
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Result(
result.into(),
))))
}
async fn handle_bootstrap_request(
actor: &ActorRef<UserAgentSession>,
req: proto_bootstrap::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let encrypted_key = req
.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?;
handle_bootstrap_encrypted_key(actor, encrypted_key).await
}
async fn handle_bootstrap_encrypted_key(
actor: &ActorRef<UserAgentSession>,
req: ProtoBootstrapEncryptedKey,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleBootstrapEncryptedKey {
nonce: req.nonce,
ciphertext: req.ciphertext,
associated_data: req.associated_data,
})
.await
{
Ok(()) => ProtoBootstrapResult::Success,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => ProtoBootstrapResult::InvalidKey,
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(error = ?err, "Failed to handle bootstrap request");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
Ok(Some(wrap_bootstrap_response(result)))
}
async fn handle_query_vault_state( async fn handle_query_vault_state(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed, Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to query vault state"); warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error ProtoVaultState::Error

View File

@@ -1,238 +0,0 @@
use arbiter_proto::{
proto::user_agent::{
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealResult as ProtoUnsealResult,
request::Payload as UnsealRequestPayload,
response::Payload as UnsealResponsePayload,
},
},
},
transport::{Bi, Error as TransportError, Receiver, Sender},
};
use async_trait::async_trait;
use tonic::Status;
use tracing::warn;
use super::auth::AuthTransportAdapter;
use crate::peers::user_agent::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
};
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
impl AuthTransportAdapter<'_> {
async fn send_query_state(&mut self) -> Result<(), TransportError> {
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
self.send_response_payload(wrap_vault_response(VaultResponsePayload::State(
ProtoVaultState::Sealed.into(),
)))
.await
}
}
#[async_trait]
impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
loop {
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(?error, "Failed to receive user agent request during vault gate");
return None;
}
};
if let Err(err) = self.tracker_mut().request(request.id) {
let _ = self.bi_mut().send(Err(err)).await;
return None;
}
let Some(payload) = request.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing request payload")))
.await;
return None;
};
let vault_req = match payload {
UserAgentRequestPayload::Vault(req) => req,
_ => {
let _ = self
.bi_mut()
.send(Err(Status::permission_denied(
"Only vault operations are permitted before unsealing",
)))
.await;
return None;
}
};
let Some(vault_payload) = vault_req.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing vault request payload")))
.await;
return None;
};
match vault_payload {
VaultRequestPayload::QueryState(_) => {
if self.send_query_state().await.is_err() {
return None;
}
continue;
}
VaultRequestPayload::Unseal(req) => {
let Some(unseal_payload) = req.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing unseal request payload")))
.await;
return None;
};
match unseal_payload {
UnsealRequestPayload::Start(start) => {
let Ok(bytes) = <[u8; 32]>::try_from(start.client_pubkey) else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument(
"Invalid X25519 public key",
)))
.await;
return None;
};
return Some(vault_gate::Inbound::HandleHandshake(HandleHandshake {
client_pubkey: x25519_dalek::PublicKey::from(bytes),
}));
}
UnsealRequestPayload::EncryptedKey(key) => {
return Some(vault_gate::Inbound::HandleUnsealEncryptedKey(
HandleUnsealEncryptedKey {
nonce: key.nonce,
ciphertext: key.ciphertext,
associated_data: key.associated_data,
},
));
}
}
}
VaultRequestPayload::Bootstrap(req) => {
let Some(encrypted_key) = req.encrypted_key else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument(
"Missing bootstrap encrypted key",
)))
.await;
return None;
};
return Some(vault_gate::Inbound::HandleBootstrapEncryptedKey(
HandleBootstrapEncryptedKey {
nonce: encrypted_key.nonce,
ciphertext: encrypted_key.ciphertext,
associated_data: encrypted_key.associated_data,
},
));
}
}
}
}
}
#[async_trait]
impl Sender<Result<vault_gate::Outbound, vault_gate::Error>> for AuthTransportAdapter<'_> {
async fn send(
&mut self,
item: Result<vault_gate::Outbound, vault_gate::Error>,
) -> Result<(), TransportError> {
let outbound = match item {
Ok(outbound) => outbound,
Err(err) => {
warn!(?err, "vault gate produced transport-level error");
return self
.bi_mut()
.send(Err(Status::internal(err.to_string())))
.await;
}
};
let payload = match outbound {
vault_gate::Outbound::HandleHandshake(Ok(response)) => {
wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
))
}
vault_gate::Outbound::HandleHandshake(Err(err)) => {
warn!(?err, "handshake failed");
return self
.bi_mut()
.send(Err(Status::internal("Failed to start unseal flow")))
.await;
}
vault_gate::Outbound::HandleUnsealEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(?err, "unseal failed");
return self
.bi_mut()
.send(Err(Status::internal("Failed to unseal vault")))
.await;
}
};
wrap_unseal_response(UnsealResponsePayload::Result(proto_result.into()))
}
vault_gate::Outbound::HandleBootstrapEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoBootstrapResult::InvalidKey,
Err(vault_gate::Error::AlreadyBootstrapped) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(?err, "bootstrap failed");
return self
.bi_mut()
.send(Err(Status::internal("Failed to bootstrap vault")))
.await;
}
};
wrap_bootstrap_response(proto_result)
}
};
self.send_response_payload(payload).await
}
}
impl Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>>
for AuthTransportAdapter<'_>
{
}

View File

@@ -1,12 +1,12 @@
#![forbid(unsafe_code)]
use crate::context::ServerContext; use crate::context::ServerContext;
pub mod actors; pub mod actors;
pub mod context; pub mod context;
pub mod crypto;
pub mod db; pub mod db;
pub mod evm; pub mod evm;
pub mod grpc; pub mod grpc;
pub mod peers; pub mod safe_cell;
pub mod utils; pub mod utils;
pub struct Server { pub struct Server {

View File

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

View File

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

View File

@@ -1,329 +0,0 @@
use super::super::{AuthCredentials, Credentials, UserAgentConnection};
use arbiter_crypto::authn::{self, USERAGENT_CONTEXT};
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, sqlite::Sqlite, update};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use tracing::error;
use super::Error;
use crate::{crypto::integrity::{Nested, Verified}, peers::user_agent::auth::Outbound};
use crate::{
actors::{bootstrap::ConsumeToken, vault::Vault},
crypto::integrity,
db::{DatabasePool, schema::useragent_client},
};
pub struct ChallengeRequest {
pub pubkey: authn::PublicKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: authn::PublicKey,
pub token: String,
}
pub struct ChallengeContext {
pub id: i32,
pub challenge_nonce: i32,
pub key: authn::PublicKey,
}
pub struct ChallengeSolution {
pub solution: Vec<u8>,
}
smlang::statemachine!(
name: Auth,
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthCredentials),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthCredentials),
}
);
const NONCE_START: i32 = 1;
/// Returns the current nonce, ready to use for the challenge nonce.
async fn get_current_nonce_and_id(
db: &DatabasePool,
key: &authn::PublicKey,
) -> Result<(i32, 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_bytes()))
.select((useragent_client::id, useragent_client::nonce))
.first::<(i32, i32)>(conn)
.await
})
})
.await
.optional()
.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::UnregisteredPublicKey
})
}
async fn verify_integrity(
db: &DatabasePool,
vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let _result = integrity::verify_entity(
&mut db_conn,
vault,
&AuthCredentials {
creds: Credentials {
id,
pubkey: pubkey.clone(),
},
new_nonce: nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::internal("Integrity verification failed")
})?;
Ok(())
}
async fn compute_current_nonce(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
pubkey: &authn::PublicKey,
) -> Result<(i32, i32), Error> {
update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
.returning((useragent_client::id, useragent_client::nonce))
.get_result(conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error incrementing nonce");
Error::internal("Database operation failed")
})
}
async fn resign_credentials(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
id: i32,
pubkey: &authn::PublicKey,
new_nonce: i32,
) -> Result<Verified<i32, Nested<AuthCredentials>>, Error> {
integrity::sign_entity(
conn,
vault,
&AuthCredentials {
creds: Credentials {
id,
pubkey: pubkey.clone(),
},
new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity signature update failed");
Error::internal("Database error")
})
}
async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i32, Error> {
let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((
useragent_client::public_key.eq(pubkey_bytes),
useragent_client::nonce.eq(NONCE_START),
))
.returning(useragent_client::id)
.get_result(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
Ok(id)
}
pub struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: &'a mut T,
}
impl<'a, T: ?Sized> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send + ?Sized,
{
type Error = Error;
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let is_signing = integrity::is_signing_available(&self.conn.actors.vault)
.await
.unwrap_or(false);
if is_signing {
verify_integrity(&self.conn.db, &self.conn.actors.vault, &pubkey).await?;
}
let vault = self.conn.actors.vault.clone();
let mut conn = self.conn.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = conn
.exclusive_transaction(|conn| {
let pubkey = pubkey.clone();
let vault = vault.clone();
Box::pin(async move {
let (id, new_nonce) = compute_current_nonce(conn, &pubkey).await?;
if is_signing {
resign_credentials(conn, &vault, id, &pubkey, new_nonce).await?;
}
Result::<_, Error>::Ok((id, new_nonce))
})
})
.await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
id,
challenge_nonce: nonce,
key: pubkey,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
&mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthCredentials, Self::Error> {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.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 => {
let id = register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(AuthCredentials {
creds: Credentials { id, pubkey },
new_nonce: NONCE_START,
})
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
id,
challenge_nonce,
key,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthCredentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
})?;
let valid = key.verify(*challenge_nonce, USERAGENT_CONTEXT, &signature);
match valid {
true => {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(AuthCredentials {
creds: Credentials {
id: *id,
pubkey: key.clone(),
},
new_nonce: *challenge_nonce,
})
}
false => {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
}
}
}
}

View File

@@ -1,177 +0,0 @@
use crate::{
actors::GlobalActors,
crypto::integrity::{self, Integrable},
db::{self, DatabaseError},
peers::client::ClientProfile,
};
use arbiter_crypto::authn;
use arbiter_proto::transport::{Bi, Sender};
pub use auth::authenticate;
use kameo::actor::{ActorRef, Spawn as _};
pub use session::UserAgentSession;
use tokio::sync::oneshot;
use tracing::warn;
use vault_gate::VaultGate;
use crate::crypto::integrity::hashing::Hashable;
pub mod auth;
pub mod session;
pub mod vault_gate;
#[derive(Debug, Clone, Hash)]
pub struct Credentials {
pub id: i32,
pub pubkey: authn::PublicKey,
}
impl Hashable for Credentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.id.hash(hasher);
self.pubkey.hash(hasher);
}
}
#[derive(Debug, Clone)]
pub struct AuthCredentials {
pub creds: Credentials,
// denotes new nonce, not current
pub new_nonce: i32,
}
impl Hashable for authn::PublicKey {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.to_bytes());
}
}
impl Hashable for AuthCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.creds.hash(hasher);
self.new_nonce.hash(hasher);
}
}
impl Integrable for AuthCredentials {
const KIND: &'static str = "useragent_credentials";
}
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile },
ClientConnectionCancel { pubkey: authn::PublicKey },
}
#[derive(Clone)]
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("authentication failed: {0:?}")]
Auth(auth::Error),
#[error("vault gate failed: {0}")]
VaultGate(#[from] vault_gate::Error),
#[error("transport closed unexpectedly")]
Transport,
#[error("database error: {0}")]
Database(DatabaseError),
#[error("internal: {0}")]
Internal(String),
}
impl From<auth::Error> for Error {
fn from(err: auth::Error) -> Self {
Self::Auth(err)
}
}
pub async fn start<T>(
props: &mut UserAgentConnection,
mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<UserAgentSession>, Error>
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
{
let auth_creds = authenticate(props, &mut transport).await?;
let creds = match integrity::is_signing_available(&props.actors.vault)
.await
.map_err(|_| Error::Internal("Integrity verification failed".into()))?
{
// credentials were checked by `auth` stage
true => auth_creds.creds,
false => run_vault_gate(props, &mut transport, auth_creds).await?,
};
Ok(UserAgentSession::spawn(UserAgentSession::new(
props.clone(),
creds,
oob_sender,
)))
}
async fn run_vault_gate<T>(
props: &UserAgentConnection,
transport: &mut T,
auth_creds: AuthCredentials,
) -> Result<Credentials, Error>
where
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send + ?Sized,
{
let (promotion_tx, mut promotion_rx) = oneshot::channel();
let gate = VaultGate::spawn(VaultGate::new(
auth_creds,
props.actors.clone(),
props.db.clone(),
promotion_tx,
));
let result = loop {
tokio::select! {
promotion = &mut promotion_rx => {
break match promotion {
Ok(Ok(creds)) => Ok(creds),
Ok(Err(err)) => Err(Error::VaultGate(err)),
Err(_) => Err(Error::Internal(
"vault gate promotion channel closed".into(),
)),
};
}
inbound = transport.recv() => {
let Some(inbound) = inbound else {
break Err(Error::Transport);
};
match gate.ask(inbound).await {
Ok(outbound) => {
if transport.send(Ok(outbound)).await.is_err() {
break Err(Error::Transport);
}
}
Err(err) => {
warn!(?err, "VaultGate failed to handle message");
break Err(Error::Internal(format!(
"vault gate ask failed: {err:?}"
)));
}
}
}
}
};
gate.kill();
result
}

View File

@@ -1,299 +0,0 @@
use std::sync::Mutex;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
},
vault::{self, Bootstrap, TryUnseal},
};
use crate::db::models::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::{
actors::vault::VaultState,
};
use super::{Error, UserAgentSession};
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(UseragentDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey.to_bytes()) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -1,295 +0,0 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
use kameo::{Actor, error::SendError, messages, prelude::Message};
use kameo_actors::message_bus::Register;
use tokio::sync::oneshot;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub mod state;
use state::*;
use super::{AuthCredentials, Credentials};
use crate::{
actors::{
GlobalActors,
vault::{self, Bootstrap, TryUnseal, events},
},
crypto::integrity::{self, AttestationStatus},
db::DatabasePool,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Invalid key provided")]
InvalidKey,
#[error("State transition failed")]
State,
#[error("Internal error: {0}")]
Internal(String),
}
impl Error {
fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
}
pub struct HandshakeResponse {
pub server_pubkey: PublicKey,
}
pub struct VaultGate {
pub auth_creds: AuthCredentials,
pub promotion_tx: Option<oneshot::Sender<Result<Credentials, Error>>>,
pub state: State,
pub actors: GlobalActors,
pub db: DatabasePool,
}
impl VaultGate {
pub fn new(
auth_creds: AuthCredentials,
actors: GlobalActors,
db: DatabasePool,
promotion_tx: oneshot::Sender<Result<Credentials, Error>>,
) -> Self {
Self {
auth_creds,
state: State::default(),
actors,
db,
promotion_tx: Some(promotion_tx),
}
}
}
impl Actor for VaultGate {
type Args = Self;
type Error = ();
async fn on_start(
args: Self::Args,
actor_ref: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
let _ = args
.actors
.events
.tell(Register(
actor_ref.clone().recipient::<events::Bootstrapped>(),
))
.await;
let _ = args
.actors
.events
.tell(Register(actor_ref.recipient::<events::Unsealed>()))
.await;
Ok(args)
}
}
impl VaultGate {
fn decrypt_key(
secret: &SharedSecret,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
#[messages(messages = Inbound, replies = Outbound)]
impl VaultGate {
#[message]
pub async fn handle_handshake(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret);
let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange {
server_key: public_key.clone(),
secret,
};
Ok(HandshakeResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), Error> {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
{
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey);
}
};
match self
.actors
.vault
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
Ok(())
}
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to vault");
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), Error> {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
{
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey);
}
};
match self
.actors
.vault
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
Ok(())
}
Err(SendError::HandlerError(vault::Error::AlreadyBootstrapped)) => {
Err(Error::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to bootstrap vault");
Err(Error::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to vault");
Err(Error::internal("Vault error"))
}
}
}
}
impl Message<events::Bootstrapped> for VaultGate {
type Reply = ();
async fn handle(
&mut self,
_: events::Bootstrapped,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = async {
let mut conn = self.db.get().await.map_err(|_| Error::internal("DB unavailable"))?;
integrity::sign_entity(&mut conn, &self.actors.vault, &self.auth_creds, self.auth_creds.creds.id)
.await
.map_err(|e| {
error!(?e, "Failed to sign integrity envelope on bootstrap");
Error::internal("Integrity sign failed")
})?;
Ok(self.auth_creds.creds.clone())
}
.await;
if let Some(tx) = self.promotion_tx.take() {
let _ = tx.send(result);
}
ctx.stop();
}
}
impl Message<events::Unsealed> for VaultGate {
type Reply = ();
async fn handle(
&mut self,
_: events::Unsealed,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = async {
let mut conn = self.db.get().await.map_err(|_| Error::internal("DB unavailable"))?;
match integrity::check_entity_attestation(
&mut conn,
&self.actors.vault,
&self.auth_creds,
self.auth_creds.creds.id,
)
.await
{
Ok(AttestationStatus::Attested) => Ok(self.auth_creds.creds.clone()),
Ok(AttestationStatus::Unavailable) => {
Err(Error::internal("Vault sealed during promotion"))
}
Err(e) => {
error!(?e, "Integrity verification failed during unseal promotion");
Err(Error::InvalidKey)
}
}
}
.await;
if let Some(tx) = self.promotion_tx.take() {
let _ = tx.send(result);
}
ctx.stop();
}
}

View File

@@ -1,17 +0,0 @@
use std::sync::Mutex;
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub struct Handshake {
client_pubkey: PublicKey,
}
#[derive(Default)]
pub enum State {
#[default]
Idle,
ReadyForExchange {
server_key: PublicKey,
secret: SharedSecret,
},
}

View File

@@ -105,11 +105,6 @@ impl<T> SafeCellHandle<T> for MemSafeCell<T> {
fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! { fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! {
eprintln!("fatal {action}: {err}"); eprintln!("fatal {action}: {err}");
// SAFETY: Intentionally cause a segmentation fault to prevent further execution in a compromised state.
unsafe {
let unsafe_pointer = std::ptr::null_mut::<u8>();
std::ptr::write_volatile(unsafe_pointer, 0);
}
std::process::abort(); std::process::abort();
} }

View File

@@ -1,18 +1,13 @@
use arbiter_crypto::{
authn::{self, CLIENT_CONTEXT, format_challenge},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::ClientMetadata; use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender}; use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, vault::Bootstrap}, actors::client::{ClientConnection, auth, connect_client},
crypto::integrity, db,
db::{self, schema},
peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
}; };
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into}; use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use ml_dsa::{KeyGen, MlDsa87, SigningKey, VerifyingKey, signature::Keypair as _}; use ed25519_dalek::Signer as _;
use super::common::ChannelTransport; use super::common::ChannelTransport;
@@ -26,8 +21,7 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
async fn insert_registered_client( async fn insert_registered_client(
db: &db::DatabasePool, db: &db::DatabasePool,
actors: &GlobalActors, pubkey: Vec<u8>,
pubkey: VerifyingKey<MlDsa87>,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) { ) {
use arbiter_server::db::schema::{client_metadata, program_client}; use arbiter_server::db::schema::{client_metadata, program_client};
@@ -43,90 +37,34 @@ async fn insert_registered_client(
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();
let client_id: i32 = insert_into(program_client::table) insert_into(program_client::table)
.values(( .values((
program_client::public_key.eq(pubkey.encode().to_vec()), program_client::public_key.eq(pubkey),
program_client::metadata_id.eq(metadata_id), program_client::metadata_id.eq(metadata_id),
)) ))
.returning(program_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.vault,
&ClientCredentials {
pubkey: pubkey.into(),
nonce: 1,
},
client_id,
)
.await
.unwrap();
}
fn sign_client_challenge(
key: &SigningKey<MlDsa87>,
nonce: i32,
pubkey: &authn::PublicKey,
) -> authn::Signature {
let challenge = format_challenge(nonce, &pubkey.to_bytes());
key.signing_key()
.sign_deterministic(&challenge, CLIENT_CONTEXT)
.unwrap()
.into()
}
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = MlDsa87::key_gen(&mut rand::rng())
.verifying_key()
.encode()
.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) .execute(&mut conn)
.await .await
.unwrap(); .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
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
actors
}
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn test_unregistered_pubkey_rejected() { pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new(); 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 props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut server_transport = server_transport; let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await; connect_client(props, &mut server_transport).await;
}); });
let new_key = MlDsa87::key_gen(&mut rand::rng()); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key().into(), pubkey: new_key.verifying_key(),
metadata: metadata("client", Some("desc"), Some("1.0.0")), metadata: metadata("client", Some("desc"), Some("1.0.0")),
}) })
.await .await
@@ -140,19 +78,20 @@ pub async fn test_unregistered_pubkey_rejected() {
#[test_log::test] #[test_log::test]
pub async fn test_challenge_auth() { pub async fn test_challenge_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng()); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
insert_registered_client( insert_registered_client(
&db, &db,
&actors, pubkey_bytes.clone(),
new_key.verifying_key(),
&metadata("client", Some("desc"), Some("1.0.0")), &metadata("client", Some("desc"), Some("1.0.0")),
) )
.await; .await;
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors); let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut server_transport = server_transport; let mut server_transport = server_transport;
@@ -162,7 +101,7 @@ pub async fn test_challenge_auth() {
// Send challenge request // Send challenge request
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key().into(), pubkey: new_key.verifying_key(),
metadata: metadata("client", Some("desc"), Some("1.0.0")), metadata: metadata("client", Some("desc"), Some("1.0.0")),
}) })
.await .await
@@ -182,7 +121,8 @@ pub async fn test_challenge_auth() {
}; };
// Sign the challenge and send solution // Sign the challenge and send solution
let signature = sign_client_challenge(&new_key, challenge.1, &challenge.0); let formatted_challenge = arbiter_proto::format_challenge(challenge.1, challenge.0.as_bytes());
let signature = new_key.sign(&formatted_challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { signature }) .send(auth::Inbound::AuthChallengeSolution { signature })
@@ -207,13 +147,34 @@ pub async fn test_challenge_auth() {
#[test_log::test] #[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() { pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await; let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let new_key = MlDsa87::key_gen(&mut rand::rng()); 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")); let requested = metadata("client", Some("desc"), Some("1.0.0"));
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await; {
use arbiter_server::db::schema::{client_metadata, program_client};
let props = ClientConnection::new(db.clone(), actors); 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 (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
@@ -223,7 +184,7 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key().into(), pubkey: new_key.verifying_key(),
metadata: requested, metadata: requested,
}) })
.await .await
@@ -234,7 +195,7 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}; };
let signature = sign_client_challenge(&new_key, nonce, &pubkey); let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes()));
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { signature }) .send(auth::Inbound::AuthChallengeSolution { signature })
.await .await
@@ -264,19 +225,34 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
#[test_log::test] #[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() { pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await; let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let new_key = MlDsa87::key_gen(&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); let props = ClientConnection::new(db.clone(), actors);
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
{
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 (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut server_transport = server_transport; let mut server_transport = server_transport;
@@ -285,7 +261,7 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key().into(), pubkey: new_key.verifying_key(),
metadata: metadata("client", Some("new"), Some("2.0.0")), metadata: metadata("client", Some("new"), Some("2.0.0")),
}) })
.await .await
@@ -296,7 +272,7 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}; };
let signature = sign_client_challenge(&new_key, nonce, &pubkey); let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes()));
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { signature }) .send(auth::Inbound::AuthChallengeSolution { signature })
.await .await
@@ -346,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 = MlDsa87::key_gen(&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().encode().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().into(),
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,20 +1,17 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::transport::{Bi, Error, Receiver, Sender}; use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, vault::Vault}, actors::keyholder::KeyHolder,
db::{self, schema}, db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[allow(dead_code)] #[allow(dead_code)]
pub async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault { pub async fn bootstrapped_keyholder(db: &db::DatabasePool) -> KeyHolder {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = KeyHolder::new(db.clone()).await.unwrap();
.await
.unwrap();
actor actor
.bootstrap(SafeCell::new(b"test-seal-key".to_vec())) .bootstrap(SafeCell::new(b"test-seal-key".to_vec()))
.await .await

View File

@@ -0,0 +1,8 @@
mod common;
#[path = "keyholder/concurrency.rs"]
mod concurrency;
#[path = "keyholder/lifecycle.rs"]
mod lifecycle;
#[path = "keyholder/storage.rs"]
mod storage;

View File

@@ -1,14 +1,10 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{ use arbiter_server::{
actors::{ actors::keyholder::{CreateNew, Error, KeyHolder},
GlobalActors,
vault::{CreateNew, Error, Vault},
},
db::{self, models, schema}, db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::actor::{ActorRef, Spawn as _}; use kameo::actor::{ActorRef, Spawn as _};
@@ -17,7 +13,7 @@ use tokio::task::JoinSet;
use crate::common; use crate::common;
async fn write_concurrently( async fn write_concurrently(
actor: ActorRef<Vault>, actor: ActorRef<KeyHolder>,
prefix: &'static str, prefix: &'static str,
count: usize, count: usize,
) -> Vec<(i32, Vec<u8>)> { ) -> Vec<(i32, Vec<u8>)> {
@@ -47,7 +43,7 @@ async fn write_concurrently(
#[test_log::test] #[test_log::test]
async fn concurrent_create_new_no_duplicate_nonces_() { async fn concurrent_create_new_no_duplicate_nonces_() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = Vault::spawn(common::bootstrapped_vault(&db).await); let actor = KeyHolder::spawn(common::bootstrapped_keyholder(&db).await);
let writes = write_concurrently(actor, "nonce-unique", 32).await; let writes = write_concurrently(actor, "nonce-unique", 32).await;
assert_eq!(writes.len(), 32); assert_eq!(writes.len(), 32);
@@ -69,7 +65,7 @@ async fn concurrent_create_new_no_duplicate_nonces_() {
#[test_log::test] #[test_log::test]
async fn concurrent_create_new_root_nonce_never_moves_backward() { async fn concurrent_create_new_root_nonce_never_moves_backward() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = Vault::spawn(common::bootstrapped_vault(&db).await); let actor = KeyHolder::spawn(common::bootstrapped_keyholder(&db).await);
write_concurrently(actor, "root-max", 24).await; write_concurrently(actor, "root-max", 24).await;
@@ -97,7 +93,7 @@ async fn concurrent_create_new_root_nonce_never_moves_backward() {
#[test_log::test] #[test_log::test]
async fn insert_failure_does_not_create_partial_row() { async fn insert_failure_does_not_create_partial_row() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_keyholder(&db).await;
let root_key_history_id = common::root_key_history_id(&db).await; let root_key_history_id = common::root_key_history_id(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -159,14 +155,12 @@ async fn insert_failure_does_not_create_partial_row() {
#[test_log::test] #[test_log::test]
async fn decrypt_roundtrip_after_high_concurrency() { async fn decrypt_roundtrip_after_high_concurrency() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = Vault::spawn(common::bootstrapped_vault(&db).await); let actor = KeyHolder::spawn(common::bootstrapped_keyholder(&db).await);
let writes = write_concurrently(actor, "roundtrip", 40).await; let writes = write_concurrently(actor, "roundtrip", 40).await;
let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect(); let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect();
let mut decryptor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut decryptor = KeyHolder::new(db.clone()).await.unwrap();
.await
.unwrap();
decryptor decryptor
.try_unseal(SafeCell::new(b"test-seal-key".to_vec())) .try_unseal(SafeCell::new(b"test-seal-key".to_vec()))
.await .await

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