Compare commits
36 Commits
9ab074170b
...
feat-shami
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab47ec563 | ||
|
|
aff87c13ca | ||
|
|
9f9b6820c2 | ||
|
|
6017ef29ca | ||
|
|
eb16da3a20 | ||
|
|
2fda0484fc | ||
|
|
f8c621b20e | ||
|
|
3b090cd3ce | ||
|
|
99e2b841e9 | ||
|
|
b2b159b16f | ||
|
|
ab767fe158 | ||
|
|
f080a8615f | ||
|
|
514a4cb2d1 | ||
|
|
0b331d90bf | ||
|
|
f981ddeb79 | ||
|
|
8517b981f2 | ||
|
|
af13465c03 | ||
|
|
d7950beb09 | ||
|
|
0cb0de759b | ||
|
|
6f270ef0c4 | ||
|
|
0098c3c08a | ||
|
|
a3b98ca024 | ||
|
|
0d364d1951 | ||
|
|
6f65c907a3 | ||
|
|
9764b0d5ce | ||
|
|
50fe18d6ce | ||
|
|
3e5f0cb3df | ||
|
|
34850137df | ||
|
|
d1b96c8409 | ||
|
|
9dbb18ae82 | ||
|
|
a773255935 | ||
|
|
3f801abdff | ||
|
|
2b44570ab4 | ||
|
|
1f9b253433 | ||
|
|
a1c3ffd2d1 | ||
|
|
fd25de32a1 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,4 +3,4 @@ scripts/__pycache__/
|
||||
.DS_Store
|
||||
.cargo/config.toml
|
||||
.vscode/
|
||||
docs/
|
||||
docs/superpowers
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -6,7 +6,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
|
||||
|
||||
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
|
||||
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
|
||||
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
|
||||
|
||||
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
|
||||
@@ -28,7 +28,7 @@ Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, die
|
||||
|---|---|
|
||||
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
|
||||
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
|
||||
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
|
||||
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
|
||||
| `arbiter-client` | Rust client library for SDK clients |
|
||||
|
||||
### Common Commands
|
||||
@@ -67,10 +67,10 @@ The server is actor-based using the **kameo** crate. All long-lived state lives
|
||||
|
||||
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
|
||||
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
|
||||
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
|
||||
|
||||
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
|
||||
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
|
||||
|
||||
@@ -121,20 +121,20 @@ pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool
|
||||
|
||||
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
|
||||
|
||||
## User Agent (Flutter + Rinf at `useragent/`)
|
||||
## Operator (Flutter + Rinf at `operator/`)
|
||||
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `operator/native/hub/` as a separate crate that uses `arbiter-operator` for the gRPC client.
|
||||
|
||||
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
Communication between Dart and Rust uses typed **signals** defined in `operator/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
|
||||
```sh
|
||||
cd useragent && rinf gen
|
||||
cd operator && rinf gen
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd useragent
|
||||
cd operator
|
||||
|
||||
# Run the app (macOS or Windows)
|
||||
flutter run
|
||||
@@ -146,4 +146,4 @@ rinf gen
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
The Rinf Rust entry point is `operator/native/hub/src/lib.rs`. It spawns actors defined in `operator/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
|
||||
150
CLAUDE.md
150
CLAUDE.md
@@ -1,149 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
|
||||
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
|
||||
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
|
||||
|
||||
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
|
||||
|
||||
## Toolchain Setup
|
||||
|
||||
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
|
||||
```sh
|
||||
mise install
|
||||
```
|
||||
|
||||
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
|
||||
|
||||
## Server (Rust workspace at `server/`)
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
|
||||
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
|
||||
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
|
||||
| `arbiter-client` | Rust client library for SDK clients |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd server
|
||||
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run the server daemon
|
||||
cargo run -p arbiter-server
|
||||
|
||||
# Run all tests (preferred over cargo test)
|
||||
cargo nextest run
|
||||
|
||||
# Run a single test
|
||||
cargo nextest run <test_name>
|
||||
|
||||
# Lint
|
||||
cargo clippy
|
||||
|
||||
# Security audit
|
||||
cargo audit
|
||||
|
||||
# Check unused dependencies
|
||||
cargo shear
|
||||
|
||||
# Run snapshot tests and update snapshots
|
||||
cargo insta review
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
|
||||
|
||||
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
|
||||
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
|
||||
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
|
||||
|
||||
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
|
||||
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
|
||||
|
||||
**Cryptography:**
|
||||
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
|
||||
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
|
||||
- Password KDF: Argon2
|
||||
- Unseal transport: X25519 ephemeral key exchange
|
||||
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
|
||||
|
||||
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
|
||||
|
||||
### Proto Regeneration
|
||||
|
||||
When `.proto` files in `protobufs/` change, rebuild to regenerate:
|
||||
```sh
|
||||
cd server && cargo build -p arbiter-proto
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```sh
|
||||
# Create a new migration
|
||||
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
|
||||
|
||||
# Run migrations manually (server also runs them on startup)
|
||||
diesel migration run --migration-dir crates/arbiter-server/migrations
|
||||
```
|
||||
|
||||
### Code Conventions
|
||||
|
||||
**`#[must_use]` Attribute:**
|
||||
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
|
||||
|
||||
- Methods that return `bool` indicating success/failure or validation state
|
||||
- Any function where ignoring the return value indicates a logic error
|
||||
|
||||
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
|
||||
|
||||
Example:
|
||||
|
||||
```rust
|
||||
#[must_use]
|
||||
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
|
||||
// verification logic
|
||||
}
|
||||
```
|
||||
|
||||
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
|
||||
|
||||
## User Agent (Flutter + Rinf at `useragent/`)
|
||||
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
|
||||
|
||||
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
|
||||
```sh
|
||||
cd useragent && rinf gen
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd useragent
|
||||
|
||||
# Run the app (macOS or Windows)
|
||||
flutter run
|
||||
|
||||
# Regenerate Rust↔Dart signal bindings
|
||||
rinf gen
|
||||
|
||||
# Analyze Dart code
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
Refer to @AGENTS.md for instructions.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
## Security warning
|
||||
Arbiter can't meaningfully protect against host compromise. Potential attack flow:
|
||||
- Attacker steals TLS keys from database
|
||||
- Pretends to be server; just accepts user agent challenge solutions
|
||||
- Pretends to be server; just accepts operator challenge solutions
|
||||
- Pretend to be in sealed state and performing DH with client
|
||||
- Steals user password and derives seal key
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as
|
||||
|
||||
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).
|
||||
- **Operator** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
|
||||
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
|
||||
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
|
||||
|
||||
@@ -22,30 +22,32 @@ Arbiter distinguishes two kinds of peers:
|
||||
All peers authenticate via public-key cryptography using a challenge-response protocol:
|
||||
|
||||
1. The peer sends its public key and requests a challenge.
|
||||
2. The server looks up the key in its database. If found, it increments the nonce and returns a challenge (replay-attack protection).
|
||||
3. The peer signs the challenge with its private key and sends the signature back.
|
||||
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp.
|
||||
3. The peer signs the canonical challenge payload with its private key and sends the signature back.
|
||||
4. The server verifies the signature:
|
||||
- **Pass:** The connection is considered authenticated.
|
||||
- **Fail:** The server closes the connection.
|
||||
|
||||
### 2.2 User Agent Bootstrap
|
||||
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
|
||||
|
||||
On first run — when no User Agents are registered — the server generates a one-time bootstrap token. It is made available in two ways:
|
||||
### 2.2 Operator Bootstrap
|
||||
|
||||
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located User Agent.
|
||||
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways:
|
||||
|
||||
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
|
||||
- **Remote setup:** Printed to the server's console output.
|
||||
|
||||
The first User Agent must present this token alongside the standard challenge-response to complete registration.
|
||||
The first Operator must present this token alongside the standard challenge-response to complete registration.
|
||||
|
||||
### 2.3 SDK Client Registration
|
||||
|
||||
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered User Agent.
|
||||
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Operator Governance
|
||||
|
||||
When more than one User Agent is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
|
||||
When more than one Operator is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
|
||||
|
||||
### 3.1 Voting Rules
|
||||
|
||||
@@ -163,13 +165,13 @@ In both cases, committee formation is a coordinated process. Arbiter does not al
|
||||
|
||||
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
|
||||
|
||||
1. An operator connects to the unbootstrapped vault using a User Agent and the bootstrap token.
|
||||
1. An operator connects to the unbootstrapped vault using an Operator 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 ordinary operator must connect with an Operator 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
|
||||
@@ -191,8 +193,8 @@ The server proves its identity using TLS with a self-signed certificate. The TLS
|
||||
|
||||
Peers verify the server by its **public key fingerprint**:
|
||||
|
||||
- **User Agent (local):** Receives the fingerprint automatically through the bootstrap token.
|
||||
- **User Agent (remote) / SDK Client:** Must receive the fingerprint out-of-band.
|
||||
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
|
||||
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band.
|
||||
|
||||
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
|
||||
|
||||
@@ -229,11 +231,11 @@ On boot, the root key is encrypted and the server cannot perform any signing ope
|
||||
|
||||
### 6.2 Unseal Flow
|
||||
|
||||
To transition to the **Unsealed** state, a User Agent must provide the password:
|
||||
To transition to the **Unsealed** state, an Operator must provide the password:
|
||||
|
||||
1. The User Agent initiates an unseal request.
|
||||
1. The Operator initiates an unseal request.
|
||||
2. The server generates a one-time key pair and returns the public key.
|
||||
3. The User Agent encrypts the user's password with this one-time public key and sends the ciphertext to the server.
|
||||
3. The Operator encrypts the user's password with this one-time public key and sends the ciphertext to the server.
|
||||
4. The server decrypts and verifies the password:
|
||||
- **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.
|
||||
@@ -255,7 +257,7 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
|
||||
### 7.1 Fundamental Rules
|
||||
|
||||
- SDK clients have **no access by default**.
|
||||
- Access is granted **explicitly** by a User Agent.
|
||||
- Access is granted **explicitly** by an Operator.
|
||||
- Grants are scoped to **specific wallets** and governed by **policies**.
|
||||
|
||||
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
|
||||
@@ -275,19 +277,19 @@ sequenceDiagram
|
||||
autonumber
|
||||
actor SDK as SDK Client
|
||||
participant Server
|
||||
participant UA as User Agent
|
||||
participant operator as Operator
|
||||
|
||||
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
|
||||
Server->>operator: Ask for wallet visibility approval
|
||||
operator-->>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
|
||||
Server->>operator: Ask for execution / grant approval
|
||||
operator-->>Server: Vote result
|
||||
opt Create persistent grant
|
||||
Server->>Server: Create and store grant
|
||||
end
|
||||
@@ -8,10 +8,10 @@ This document covers concrete technology choices and dependencies. For the archi
|
||||
|
||||
### Authentication Result Semantics
|
||||
|
||||
Authentication no longer uses an implicit success-only response shape. Both `client` and `user-agent` return explicit auth status enums over the wire.
|
||||
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
|
||||
|
||||
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_USER_AGENTS_ONLINE`, or `INTERNAL`
|
||||
- **User-agent:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
|
||||
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
|
||||
- **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
|
||||
|
||||
This makes transport-level failures and actor/domain-level auth failures distinct:
|
||||
|
||||
@@ -22,7 +22,7 @@ Clients are expected to handle these status codes directly and present the concr
|
||||
|
||||
### New Client Approval
|
||||
|
||||
When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel.
|
||||
When a client whose public key is not yet in the database connects, all connected operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -31,10 +31,10 @@ flowchart TD
|
||||
|
||||
C -- yes --> G[Generate AuthChallenge]
|
||||
|
||||
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
|
||||
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
|
||||
E --> F{First response}
|
||||
F -- denied --> Z([Reject connection])
|
||||
F -- approved --> F2[Cancel remaining\nUserAgent requests]
|
||||
F -- approved --> F2[Cancel remaining\nOperator requests]
|
||||
F2 --> F3[INSERT client]
|
||||
F3 --> G
|
||||
|
||||
@@ -45,7 +45,13 @@ flowchart TD
|
||||
K -- yes --> J([Session started])
|
||||
```
|
||||
|
||||
Auth challenges are generated from fresh random bytes plus a timestamp. They are signed as the canonical challenge payload and are not persisted in `program_client`.
|
||||
Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
|
||||
|
||||
The authentication schema stores peer identity, not replay counters:
|
||||
|
||||
- `program_client` stores the SDK client's public key, metadata binding, and timestamps.
|
||||
- `operator_client` stores the Operator public key and timestamps.
|
||||
- Neither table stores an authentication nonce, and challenge generation does not update either table.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +62,7 @@ Auth challenges are generated from fresh random bytes plus a timestamp. They are
|
||||
|
||||
### 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.
|
||||
Operator 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
|
||||
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
|
||||
@@ -80,7 +86,7 @@ User-agent authentication supports multiple signature schemes because platform-p
|
||||
|
||||
### Request Multiplexing
|
||||
|
||||
Both `client` and `user-agent` connections support multiple in-flight requests over one gRPC bidi stream.
|
||||
Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
|
||||
|
||||
- Every request carries a monotonically increasing request ID
|
||||
- Every normal response echoes the request ID it corresponds to
|
||||
@@ -135,7 +141,7 @@ flowchart TD
|
||||
L -- Yes --> M[Check grant limits]
|
||||
L -- No --> N[Start execution or grant voting flow]
|
||||
|
||||
N --> O{User-agent decision}
|
||||
N --> O{Operator decision}
|
||||
O -- Reject --> Z4[Return no matching grant error]
|
||||
O -- Allow once --> M
|
||||
O -- Create grant --> P[Create grant with user-selected limits]
|
||||
@@ -111,7 +111,7 @@ String shortAddress(List<int> bytes) {
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart
|
||||
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
@@ -168,7 +168,7 @@ class GrantCreation extends _$GrantCreation {
|
||||
- [ ] **Step 2: Run code generator**
|
||||
|
||||
```sh
|
||||
cd useragent && dart run build_runner build --delete-conflicting-outputs
|
||||
cd operator && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors.
|
||||
@@ -176,7 +176,7 @@ Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors.
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart
|
||||
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
@@ -204,7 +204,7 @@ jj describe -m "feat(grants): add GrantCreation provider (client selection + gra
|
||||
|
||||
```dart
|
||||
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:arbiter/proto/operator.pb.dart';
|
||||
import 'package:arbiter/providers/sdk_clients/list.dart';
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -246,7 +246,7 @@ class ClientPickerField extends ConsumerWidget {
|
||||
```dart
|
||||
// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:arbiter/proto/operator.pb.dart';
|
||||
import 'package:arbiter/providers/evm/evm.dart';
|
||||
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
|
||||
@@ -522,7 +522,7 @@ class TransactionRateLimitField extends StatelessWidget {
|
||||
- [ ] **Step 8: Verify all field widgets**
|
||||
|
||||
```sh
|
||||
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/fields/
|
||||
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/fields/
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
@@ -585,7 +585,7 @@ class SharedGrantFields extends StatelessWidget {
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
|
||||
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
@@ -978,7 +978,7 @@ class _TokenVolumeLimitRow extends HookWidget {
|
||||
- [ ] **Step 4: Run code generator for token_transfer_grant.g.dart**
|
||||
|
||||
```sh
|
||||
cd useragent && dart run build_runner build --delete-conflicting-outputs
|
||||
cd operator && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Expected: generates `token_transfer_grant.g.dart`, no errors.
|
||||
@@ -986,7 +986,7 @@ Expected: generates `token_transfer_grant.g.dart`, no errors.
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/grants/
|
||||
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/grants/
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
@@ -1265,7 +1265,7 @@ String _formatError(Object error) {
|
||||
- [ ] **Step 2: Verify the full create/ directory**
|
||||
|
||||
```sh
|
||||
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/
|
||||
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
@@ -14,24 +14,24 @@
|
||||
|
||||
| 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 |
|
||||
| `operator/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
|
||||
| `operator/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
|
||||
| `operator/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
|
||||
| `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
|
||||
| `operator/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
|
||||
| `operator/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
|
||||
| `operator/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`
|
||||
- Modify: `operator/lib/theme/palette.dart`
|
||||
|
||||
- [ ] **Step 1: Add the color**
|
||||
|
||||
Replace the contents of `useragent/lib/theme/palette.dart` with:
|
||||
Replace the contents of `operator/lib/theme/palette.dart` with:
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -48,7 +48,7 @@ class Palette {
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/theme/palette.dart
|
||||
cd operator && flutter analyze lib/theme/palette.dart
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
@@ -65,20 +65,20 @@ jj new
|
||||
## Task 2: Add `listAllWalletAccesses` feature function
|
||||
|
||||
**Files:**
|
||||
- Modify: `useragent/lib/features/connection/evm/wallet_access.dart`
|
||||
- Modify: `operator/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`:
|
||||
Add at the bottom of `operator/lib/features/connection/evm/wallet_access.dart`:
|
||||
|
||||
```dart
|
||||
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
|
||||
Connection connection,
|
||||
) async {
|
||||
final response = await connection.ask(
|
||||
UserAgentRequest(listWalletAccess: Empty()),
|
||||
OperatorRequest(listWalletAccess: Empty()),
|
||||
);
|
||||
if (!response.hasListWalletAccessResponse()) {
|
||||
throw Exception(
|
||||
@@ -97,7 +97,7 @@ Each returned `SdkClientWalletAccess` has:
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart
|
||||
cd operator && flutter analyze lib/features/connection/evm/wallet_access.dart
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
@@ -114,18 +114,18 @@ 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`
|
||||
- Create: `operator/lib/providers/sdk_clients/wallet_access_list.dart`
|
||||
- Generated: `operator/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`:
|
||||
Create `operator/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/proto/operator.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:mtcore/markettakers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -165,15 +165,15 @@ class WalletAccessList extends _$WalletAccessList {
|
||||
- [ ] **Step 2: Run code generation**
|
||||
|
||||
```sh
|
||||
cd useragent && dart run build_runner build --delete-conflicting-outputs
|
||||
cd operator && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
|
||||
Expected: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/providers/sdk_clients/
|
||||
cd operator && flutter analyze lib/providers/sdk_clients/
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
@@ -190,26 +190,26 @@ jj new
|
||||
## Task 4: Create `GrantCard` widget
|
||||
|
||||
**Files:**
|
||||
- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
|
||||
- Create: `operator/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`
|
||||
- `SdkClientWalletAccess` (from `proto/operator.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`
|
||||
- `SdkClientEntry` (from `proto/operator.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`:
|
||||
Create `operator/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/proto/operator.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';
|
||||
@@ -438,7 +438,7 @@ class GrantCard extends ConsumerWidget {
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
|
||||
cd operator && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
@@ -455,13 +455,13 @@ jj new
|
||||
## Task 5: Create `EvmGrantsScreen`
|
||||
|
||||
**Files:**
|
||||
- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart`
|
||||
- Create: `operator/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`:
|
||||
Create `operator/lib/screens/dashboard/evm/grants/grants.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
@@ -702,7 +702,7 @@ class EvmGrantsScreen extends ConsumerWidget {
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/
|
||||
cd operator && flutter analyze lib/screens/dashboard/evm/grants/
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
@@ -719,13 +719,13 @@ 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`
|
||||
- Modify: `operator/lib/router.dart`
|
||||
- Modify: `operator/lib/screens/dashboard.dart`
|
||||
- Regenerated: `operator/lib/router.gr.dart`
|
||||
|
||||
- [ ] **Step 1: Add route to `router.dart`**
|
||||
|
||||
Replace the contents of `useragent/lib/router.dart` with:
|
||||
Replace the contents of `operator/lib/router.dart` with:
|
||||
|
||||
```dart
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
@@ -759,7 +759,7 @@ class Router extends RootStackRouter {
|
||||
|
||||
- [ ] **Step 2: Update `dashboard.dart`**
|
||||
|
||||
In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant:
|
||||
In `operator/lib/screens/dashboard.dart`, replace the `routes` constant:
|
||||
|
||||
```dart
|
||||
final routes = [
|
||||
@@ -800,7 +800,7 @@ destinations: const [
|
||||
- [ ] **Step 3: Regenerate router**
|
||||
|
||||
```sh
|
||||
cd useragent && dart run build_runner build --delete-conflicting-outputs
|
||||
cd operator && dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
|
||||
@@ -808,7 +808,7 @@ Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no error
|
||||
- [ ] **Step 4: Full project verify**
|
||||
|
||||
```sh
|
||||
cd useragent && flutter analyze
|
||||
cd operator && flutter analyze
|
||||
```
|
||||
|
||||
Expected: no issues.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 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.
|
||||
Add a "Grants" dashboard tab to the Flutter operator 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
|
||||
|
||||
@@ -23,7 +23,7 @@ Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM
|
||||
|
||||
### `walletAccessListProvider`
|
||||
|
||||
**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
|
||||
**File:** `operator/lib/providers/sdk_clients/wallet_access_list.dart`
|
||||
|
||||
- `@riverpod` class, watches `connectionManagerProvider.future`
|
||||
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
|
||||
@@ -85,7 +85,7 @@ NavigationDestination(
|
||||
|
||||
## Screen: `EvmGrantsScreen`
|
||||
|
||||
**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart`
|
||||
**File:** `operator/lib/screens/dashboard/evm/grants/grants.dart`
|
||||
|
||||
```
|
||||
Scaffold
|
||||
|
||||
74
mise.lock
74
mise.lock
@@ -1,51 +1,51 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.ast-grep]]
|
||||
version = "0.42.0"
|
||||
version = "0.42.1"
|
||||
backend = "aqua:ast-grep/ast-grep"
|
||||
|
||||
[tools.ast-grep."platforms.linux-arm64"]
|
||||
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
|
||||
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
|
||||
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.linux-x64"]
|
||||
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
|
||||
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
|
||||
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
|
||||
|
||||
[tools.ast-grep."platforms.macos-arm64"]
|
||||
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
|
||||
checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip"
|
||||
|
||||
[tools.ast-grep."platforms.macos-x64"]
|
||||
checksum = "sha256:979ffe611327056f4730a1ae71b0209b3b830f58b22c6ed194cda34f55400db2"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-apple-darwin.zip"
|
||||
checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip"
|
||||
|
||||
[tools.ast-grep."platforms.windows-x64"]
|
||||
checksum = "sha256:55836fa1b2c65dc7d61615a4d9368622a0d2371a76d28b9a165e5a3ab6ae32a4"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-pc-windows-msvc.zip"
|
||||
checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83"
|
||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip"
|
||||
|
||||
[[tools."cargo:cargo-audit"]]
|
||||
version = "0.22.1"
|
||||
backend = "cargo:cargo-audit"
|
||||
|
||||
[[tools."cargo:cargo-edit"]]
|
||||
version = "0.13.9"
|
||||
version = "0.13.10"
|
||||
backend = "cargo:cargo-edit"
|
||||
|
||||
[[tools."cargo:cargo-features-manager"]]
|
||||
version = "0.11.1"
|
||||
version = "0.12.0"
|
||||
backend = "cargo:cargo-features-manager"
|
||||
|
||||
[[tools."cargo:cargo-insta"]]
|
||||
version = "1.46.3"
|
||||
version = "1.47.2"
|
||||
backend = "cargo:cargo-insta"
|
||||
|
||||
[[tools."cargo:cargo-mutants"]]
|
||||
@@ -53,7 +53,7 @@ version = "27.0.0"
|
||||
backend = "cargo:cargo-mutants"
|
||||
|
||||
[[tools."cargo:cargo-nextest"]]
|
||||
version = "0.9.126"
|
||||
version = "0.9.133"
|
||||
backend = "cargo:cargo-nextest"
|
||||
|
||||
[[tools."cargo:cargo-shear"]]
|
||||
@@ -65,7 +65,7 @@ version = "0.10.2"
|
||||
backend = "cargo:cargo-vet"
|
||||
|
||||
[[tools."cargo:diesel_cli"]]
|
||||
version = "2.3.6"
|
||||
version = "2.3.7"
|
||||
backend = "cargo:diesel_cli"
|
||||
|
||||
[tools."cargo:diesel_cli".options]
|
||||
@@ -77,7 +77,7 @@ version = "2.12.0"
|
||||
backend = "cargo:flutter_rust_bridge_codegen"
|
||||
|
||||
[[tools.flutter]]
|
||||
version = "3.38.9-stable"
|
||||
version = "3.41.7-stable"
|
||||
backend = "asdf:flutter"
|
||||
|
||||
[[tools.protoc]]
|
||||
@@ -113,44 +113,44 @@ checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip"
|
||||
|
||||
[[tools.python]]
|
||||
version = "3.14.3"
|
||||
version = "3.14.4"
|
||||
backend = "core:python"
|
||||
|
||||
[tools.python."platforms.linux-arm64"]
|
||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64"]
|
||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-arm64"]
|
||||
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||
checksum = "blake3:0314ec66e0f33ec04959583b5900bc8edae371a396aa96b8874e750d1fe936e6"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-x64"]
|
||||
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:d51250a32fa5d9f0799c7bcb71720c27b10a3afd4a7de288120f96085d508a5a"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.windows-x64"]
|
||||
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||
checksum = "sha256:a976991dcd085c1bb5d9a8084823a6bc8b7f9b079d8c432574a6ddd68c3a6fe1"
|
||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.rust]]
|
||||
version = "1.93.0"
|
||||
version = "1.95.0"
|
||||
backend = "core:rust"
|
||||
|
||||
20
mise.toml
20
mise.toml
@@ -1,17 +1,17 @@
|
||||
[tools]
|
||||
"cargo:diesel_cli" = { version = "2.3.6", features = "sqlite,sqlite-bundled", default-features = false }
|
||||
"cargo:diesel_cli" = { version = "2.3.7", features = "sqlite,sqlite-bundled", default-features = "false" }
|
||||
"cargo:cargo-audit" = "0.22.1"
|
||||
"cargo:cargo-vet" = "0.10.2"
|
||||
flutter = "3.38.9-stable"
|
||||
flutter = "3.41.7-stable"
|
||||
protoc = "29.6"
|
||||
"rust" = {version = "1.93.0", components = "clippy,rust-analyzer"}
|
||||
"cargo:cargo-features-manager" = "0.11.1"
|
||||
"cargo:cargo-nextest" = "0.9.126"
|
||||
rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
|
||||
"cargo:cargo-features-manager" = "0.12.0"
|
||||
"cargo:cargo-nextest" = "0.9.133"
|
||||
"cargo:cargo-shear" = "latest"
|
||||
"cargo:cargo-insta" = "1.46.3"
|
||||
python = "3.14.3"
|
||||
ast-grep = "0.42.0"
|
||||
"cargo:cargo-edit" = "0.13.9"
|
||||
"cargo:cargo-insta" = "1.47.2"
|
||||
python = "3.14.4"
|
||||
ast-grep = "0.42.1"
|
||||
"cargo:cargo-edit" = "0.13.10"
|
||||
"cargo:cargo-mutants" = "27.0.0"
|
||||
"cargo:flutter_rust_bridge_codegen" = "2.12.0"
|
||||
|
||||
@@ -22,3 +22,5 @@ run = '''
|
||||
dart pub global activate protoc_plugin && \
|
||||
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
|
||||
'''
|
||||
|
||||
[tasks.generate_schema]
|
||||
|
||||
@@ -3,7 +3,7 @@ syntax = "proto3";
|
||||
package arbiter;
|
||||
|
||||
import "client.proto";
|
||||
import "user_agent.proto";
|
||||
import "operator.proto";
|
||||
|
||||
message ServerInfo {
|
||||
string version = 1;
|
||||
@@ -12,5 +12,5 @@ message ServerInfo {
|
||||
|
||||
service ArbiterService {
|
||||
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
|
||||
rpc UserAgent(stream arbiter.user_agent.UserAgentRequest) returns (stream arbiter.user_agent.UserAgentResponse);
|
||||
rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum AuthResult {
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_APPROVAL_DENIED = 4;
|
||||
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
|
||||
AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ message SpecificGrant {
|
||||
}
|
||||
}
|
||||
|
||||
// --- UserAgent grant management ---
|
||||
// --- Operator grant management ---
|
||||
message EvmGrantCreateRequest {
|
||||
SharedSettings shared = 1;
|
||||
SpecificGrant specific = 2;
|
||||
|
||||
31
protobufs/operator.proto
Normal file
31
protobufs/operator.proto
Normal file
@@ -0,0 +1,31 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator;
|
||||
|
||||
import "operator/auth.proto";
|
||||
import "operator/evm.proto";
|
||||
import "operator/governance.proto";
|
||||
import "operator/sdk_client.proto";
|
||||
import "operator/vault/vault.proto";
|
||||
|
||||
message OperatorRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
governance.Request governance = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message OperatorResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
governance.Response governance = 5;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.auth;
|
||||
package arbiter.operator.auth;
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.evm;
|
||||
package arbiter.operator.evm;
|
||||
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
136
protobufs/operator/governance.proto
Normal file
136
protobufs/operator/governance.proto
Normal file
@@ -0,0 +1,136 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.governance;
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
CreateProposalRequest create = 1;
|
||||
CastVoteRequest vote = 2;
|
||||
QueryPendingRequest query = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message CreateProposalRequest {
|
||||
oneof kind {
|
||||
ApproveSdkClientPayload approve_sdk_client = 1;
|
||||
GrantWalletAccessPayload grant_wallet_access = 3;
|
||||
ApproveServerUpdatePayload approve_server_update = 4;
|
||||
ReplaceOperatorPayload replace_operator = 5;
|
||||
UpdateShamirParametersPayload update_shamir_parameters = 6;
|
||||
ApprovePersistentGrantPayload approve_persistent_grant = 7;
|
||||
ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
|
||||
}
|
||||
optional uint32 ttl_secs = 2;
|
||||
}
|
||||
|
||||
message ReplaceOperatorPayload {
|
||||
int32 old_operator_id = 1;
|
||||
bytes new_pubkey = 2;
|
||||
}
|
||||
|
||||
message UpdateShamirParametersPayload {
|
||||
uint32 new_n = 1;
|
||||
}
|
||||
|
||||
message ApproveServerUpdatePayload {}
|
||||
|
||||
message ApproveSdkClientPayload {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message GrantWalletAccessPayload {
|
||||
int32 wallet_id = 1;
|
||||
int32 client_id = 2;
|
||||
}
|
||||
|
||||
message CastVoteRequest {
|
||||
int32 proposal_id = 1;
|
||||
bool approve = 2;
|
||||
bytes signature = 3;
|
||||
}
|
||||
|
||||
message QueryPendingRequest {}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
CreateProposalResponse created = 1;
|
||||
VoteResponse voted = 2;
|
||||
QueryPendingResponse pending = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message CreateProposalResponse {
|
||||
int32 proposal_id = 1;
|
||||
}
|
||||
|
||||
message VoteResponse {
|
||||
VoteOutcome outcome = 1;
|
||||
}
|
||||
|
||||
enum VoteOutcome {
|
||||
VOTE_OUTCOME_UNSPECIFIED = 0;
|
||||
VOTE_OUTCOME_PENDING = 1;
|
||||
VOTE_OUTCOME_APPROVED = 2;
|
||||
VOTE_OUTCOME_REJECTED = 3;
|
||||
}
|
||||
|
||||
message ProposalSummary {
|
||||
int32 id = 1;
|
||||
string kind = 2;
|
||||
int32 initiator_id = 3;
|
||||
int64 expires_at = 4;
|
||||
int64 approve_count = 5;
|
||||
int64 reject_count = 6;
|
||||
}
|
||||
|
||||
message QueryPendingResponse {
|
||||
repeated ProposalSummary proposals = 1;
|
||||
}
|
||||
|
||||
message TransactionRateLimitProto {
|
||||
uint32 count = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message VolumeLimitProto {
|
||||
bytes max_volume = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message EtherTransferSpecProto {
|
||||
repeated bytes targets = 1;
|
||||
VolumeLimitProto limit = 2;
|
||||
}
|
||||
|
||||
message TokenTransferSpecProto {
|
||||
bytes token_contract = 1;
|
||||
optional bytes target = 2;
|
||||
repeated VolumeLimitProto volume_limits = 3;
|
||||
}
|
||||
|
||||
message ApproveOneOffTransactionPayload {
|
||||
int32 client_id = 1;
|
||||
bytes wallet_address = 2;
|
||||
uint64 chain_id = 3;
|
||||
uint64 nonce = 4;
|
||||
uint64 gas_limit = 5;
|
||||
bytes max_fee_per_gas = 6;
|
||||
bytes max_priority_fee_per_gas = 7;
|
||||
bytes to = 8;
|
||||
bytes value = 9;
|
||||
bytes input = 10;
|
||||
}
|
||||
|
||||
message ApprovePersistentGrantPayload {
|
||||
int32 wallet_access_id = 1;
|
||||
uint64 chain_id = 2;
|
||||
optional int64 valid_from_secs = 3;
|
||||
optional int64 valid_until_secs = 4;
|
||||
optional bytes max_gas_fee_per_gas = 5;
|
||||
optional bytes max_priority_fee_per_gas = 6;
|
||||
optional TransactionRateLimitProto rate_limit = 7;
|
||||
oneof specific {
|
||||
EtherTransferSpecProto ether_transfer = 8;
|
||||
TokenTransferSpecProto token_transfer = 9;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.sdk_client;
|
||||
package arbiter.operator.sdk_client;
|
||||
|
||||
import "shared/client.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
44
protobufs/operator/vault/bootstrap.proto
Normal file
44
protobufs/operator/vault/bootstrap.proto
Normal file
@@ -0,0 +1,44 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault.bootstrap;
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
message DeclareCommittee {
|
||||
uint32 count = 1;
|
||||
uint32 recovery_count = 2;
|
||||
}
|
||||
|
||||
message ContributePassphrase {
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
BOOTSTRAP_RESULT_AWAITING_CONTRIBUTIONS = 4;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
BootstrapEncryptedKey encrypted_key = 2;
|
||||
DeclareCommittee declare_committee = 3;
|
||||
ContributePassphrase contribute_passphrase = 4;
|
||||
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
BootstrapResult result = 1;
|
||||
}
|
||||
30
protobufs/operator/vault/rekey.proto
Normal file
30
protobufs/operator/vault/rekey.proto
Normal file
@@ -0,0 +1,30 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault.rekey;
|
||||
|
||||
message ContributePassphrase {
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
}
|
||||
|
||||
enum RekeyResult {
|
||||
REKEY_RESULT_UNSPECIFIED = 0;
|
||||
REKEY_RESULT_SUCCESS = 1;
|
||||
REKEY_RESULT_AWAITING_CONTRIBUTIONS = 2;
|
||||
REKEY_RESULT_NOT_IN_PROGRESS = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
ContributePassphrase contribute_passphrase = 1;
|
||||
ContributeRecoveryPassphrase contribute_recovery_passphrase = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
RekeyResult result = 1;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault.unseal;
|
||||
package arbiter.operator.vault.unseal;
|
||||
|
||||
message UnsealStart {
|
||||
bytes client_pubkey = 1;
|
||||
@@ -15,17 +15,29 @@ message UnsealEncryptedKey {
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
message ContributePassphrase {
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||
UNSEAL_RESULT_SUCCESS = 1;
|
||||
UNSEAL_RESULT_INVALID_KEY = 2;
|
||||
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
|
||||
UNSEAL_RESULT_AWAITING_CONTRIBUTIONS = 4;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
UnsealStart start = 1;
|
||||
UnsealEncryptedKey encrypted_key = 2;
|
||||
UnsealStart start = 1;
|
||||
UnsealEncryptedKey encrypted_key = 2;
|
||||
ContributePassphrase contribute_passphrase = 3;
|
||||
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
|
||||
}
|
||||
}
|
||||
|
||||
27
protobufs/operator/vault/vault.proto
Normal file
27
protobufs/operator/vault/vault.proto
Normal file
@@ -0,0 +1,27 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
import "operator/vault/bootstrap.proto";
|
||||
import "operator/vault/rekey.proto";
|
||||
import "operator/vault/unseal.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
unseal.Request unseal = 2;
|
||||
bootstrap.Request bootstrap = 3;
|
||||
rekey.Request rekey = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
unseal.Response unseal = 2;
|
||||
bootstrap.Response bootstrap = 3;
|
||||
rekey.Response rekey = 4;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ package arbiter.shared;
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_SEALED = 2;
|
||||
VAULT_STATE_UNSEALED = 3;
|
||||
VAULT_STATE_ERROR = 4;
|
||||
VAULT_STATE_BOOSTRAPPING = 2;
|
||||
VAULT_STATE_SEALED = 3;
|
||||
VAULT_STATE_UNSEALED = 4;
|
||||
VAULT_STATE_ERROR = 5;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent;
|
||||
|
||||
import "user_agent/auth.proto";
|
||||
import "user_agent/evm.proto";
|
||||
import "user_agent/sdk_client.proto";
|
||||
import "user_agent/vault/vault.proto";
|
||||
|
||||
message UserAgentRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message UserAgentResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault.bootstrap;
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
BootstrapEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
|
||||
message Response {
|
||||
BootstrapResult result = 1;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
import "user_agent/vault/bootstrap.proto";
|
||||
import "user_agent/vault/unseal.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
unseal.Request unseal = 2;
|
||||
bootstrap.Request bootstrap = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
unseal.Response unseal = 2;
|
||||
bootstrap.Response bootstrap = 3;
|
||||
}
|
||||
}
|
||||
2
server/.cargo/config.toml
Normal file
2
server/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
MACOSX_DEPLOYMENT_TARGET = "26.3"
|
||||
842
server/Cargo.lock
generated
842
server/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,30 +6,27 @@ resolver = "3"
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
alloy = "2.0.0"
|
||||
alloy = "2.0.4"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
|
||||
futures = "0.3.32"
|
||||
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
||||
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
|
||||
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
|
||||
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
|
||||
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
|
||||
hmac = "0.13.0"
|
||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] }
|
||||
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
|
||||
mutants = "0.0.4"
|
||||
prost = "0.14.3"
|
||||
prost-types = { version = "0.14.3", features = ["chrono"] }
|
||||
rand = "0.10.1"
|
||||
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
|
||||
rsa = { version = "0.9", features = ["sha2"] }
|
||||
rstest = "0.26.1"
|
||||
rustls = { version = "0.23.38", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
|
||||
rustls-pki-types = "1.14.0"
|
||||
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
|
||||
rustls-pki-types = "1.14.1"
|
||||
sha2 = "0.11"
|
||||
smlang = "0.8.0"
|
||||
spki = "0.8"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["full"] }
|
||||
@@ -109,7 +106,6 @@ indexing_slicing = "warn"
|
||||
infinite_loop = "warn"
|
||||
inline_asm_x86_att_syntax = "warn"
|
||||
inline_asm_x86_intel_syntax = "warn"
|
||||
integer_division = "warn"
|
||||
large_include_file = "warn"
|
||||
lossy_float_literal = "warn"
|
||||
map_with_unused_argument_over_ranges = "warn"
|
||||
|
||||
@@ -26,3 +26,5 @@ trait-assoc-item-kinds-order = [
|
||||
"type",
|
||||
"fn",
|
||||
] # community tested standard
|
||||
|
||||
too-many-lines-threshold = 150
|
||||
|
||||
@@ -21,7 +21,9 @@ tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
thiserror.workspace = true
|
||||
http = "1.4.0"
|
||||
rustls-webpki = { version = "0.103.12", features = ["aws-lc-rs"] }
|
||||
rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
|
||||
async-trait.workspace = true
|
||||
rand.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -26,13 +26,13 @@ use chrono::DateTime;
|
||||
pub enum AuthError {
|
||||
#[error("Server sent invalid auth challenge")]
|
||||
InvalidChallenge,
|
||||
#[error("Client approval denied by User Agent")]
|
||||
#[error("Client approval denied by Operator")]
|
||||
ApprovalDenied,
|
||||
#[error("Auth challenge was not returned by server")]
|
||||
MissingAuthChallenge,
|
||||
|
||||
#[error("No User Agents online to approve client")]
|
||||
NoUserAgentsOnline,
|
||||
#[error("No Operators online to approve client")]
|
||||
NoOperatorsOnline,
|
||||
|
||||
#[error("Signing key storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
@@ -44,7 +44,7 @@ pub enum AuthError {
|
||||
fn map_auth_result(code: i32) -> AuthError {
|
||||
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
|
||||
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
|
||||
AuthResult::NoUserAgentsOnline => AuthError::NoUserAgentsOnline,
|
||||
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
|
||||
AuthResult::Unspecified
|
||||
| AuthResult::Success
|
||||
| AuthResult::InvalidKey
|
||||
|
||||
@@ -20,3 +20,6 @@ workspace = true
|
||||
default = ["authn", "safecell"]
|
||||
authn = ["dep:ml-dsa", "dep:rand"]
|
||||
safecell = ["dep:memsafe"]
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -7,7 +7,8 @@ use ml_dsa::{
|
||||
use rand::RngExt;
|
||||
|
||||
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
|
||||
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
|
||||
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
|
||||
pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote";
|
||||
|
||||
const NONCE_SIZE: usize = 32;
|
||||
|
||||
@@ -90,6 +91,11 @@ impl PublicKey {
|
||||
self.0
|
||||
.verify_with_context(&challenge, context, &signature.0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool {
|
||||
self.0.verify_with_context(message, context, &signature.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Signature {
|
||||
@@ -192,7 +198,7 @@ mod tests {
|
||||
|
||||
use crate::authn::AuthChallenge;
|
||||
|
||||
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
|
||||
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
|
||||
|
||||
#[test]
|
||||
fn public_key_round_trip_decodes() {
|
||||
@@ -227,7 +233,7 @@ mod tests {
|
||||
.expect("signature should be created");
|
||||
|
||||
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
|
||||
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
|
||||
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,7 +22,7 @@ pub trait SafeCellHandle<T> {
|
||||
fn read(&mut self) -> Self::CellRead<'_>;
|
||||
fn write(&mut self) -> Self::CellWrite<'_>;
|
||||
|
||||
fn new_inline<F>(f: F) -> Self
|
||||
fn new_inline_default<F>(f: F) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
T: Default,
|
||||
@@ -36,6 +36,14 @@ pub trait SafeCellHandle<T> {
|
||||
cell
|
||||
}
|
||||
|
||||
fn new_inline<F>(f: Box<F>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
F: for<'a> FnOnce() -> T,
|
||||
{
|
||||
Self::new(f())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_inline<F, R>(&mut self, f: F) -> R
|
||||
where
|
||||
|
||||
@@ -5,6 +5,7 @@ edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1.0"
|
||||
|
||||
@@ -9,7 +9,6 @@ license = "Apache-2.0"
|
||||
tonic.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
hex = "0.4.3"
|
||||
tonic-prost = "0.14.5"
|
||||
prost.workspace = true
|
||||
kameo.workspace = true
|
||||
@@ -19,18 +18,18 @@ thiserror.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
base64.workspace = true
|
||||
prost-types.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14.5"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
rand.workspace = true
|
||||
rcgen.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
ignored = ["tonic-prost", "prost", "kameo"]
|
||||
ignored = ["tonic-prost", "prost"]
|
||||
|
||||
@@ -10,7 +10,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.compile_protos(
|
||||
&[
|
||||
format!("{}/arbiter.proto", PROTOBUF_DIR),
|
||||
format!("{}/user_agent.proto", PROTOBUF_DIR),
|
||||
format!("{}/operator.proto", PROTOBUF_DIR),
|
||||
format!("{}/client.proto", PROTOBUF_DIR),
|
||||
format!("{}/evm.proto", PROTOBUF_DIR),
|
||||
],
|
||||
|
||||
@@ -12,30 +12,38 @@ pub mod proto {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod user_agent {
|
||||
tonic::include_proto!("arbiter.user_agent");
|
||||
pub mod operator {
|
||||
tonic::include_proto!("arbiter.operator");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.user_agent.auth");
|
||||
tonic::include_proto!("arbiter.operator.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.user_agent.evm");
|
||||
tonic::include_proto!("arbiter.operator.evm");
|
||||
}
|
||||
|
||||
pub mod governance {
|
||||
tonic::include_proto!("arbiter.operator.governance");
|
||||
}
|
||||
|
||||
pub mod sdk_client {
|
||||
tonic::include_proto!("arbiter.user_agent.sdk_client");
|
||||
tonic::include_proto!("arbiter.operator.sdk_client");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.user_agent.vault");
|
||||
tonic::include_proto!("arbiter.operator.vault");
|
||||
|
||||
pub mod bootstrap {
|
||||
tonic::include_proto!("arbiter.user_agent.vault.bootstrap");
|
||||
tonic::include_proto!("arbiter.operator.vault.bootstrap");
|
||||
}
|
||||
|
||||
pub mod rekey {
|
||||
tonic::include_proto!("arbiter.operator.vault.rekey");
|
||||
}
|
||||
|
||||
pub mod unseal {
|
||||
tonic::include_proto!("arbiter.user_agent.vault.unseal");
|
||||
tonic::include_proto!("arbiter.operator.vault.unseal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ license = "Apache-2.0"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
diesel = { version = "2.3.7", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
|
||||
diesel-async = { version = "0.8.0", features = [
|
||||
diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
|
||||
diesel-async = { version = "0.9.0", features = [
|
||||
"bb8",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
@@ -27,17 +27,12 @@ tokio.workspace = true
|
||||
rustls.workspace = true
|
||||
smlang.workspace = true
|
||||
thiserror.workspace = true
|
||||
fatality = "0.1.1"
|
||||
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
|
||||
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
|
||||
async-trait.workspace = true
|
||||
secrecy = "0.10.3"
|
||||
futures.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
dashmap = "6.1.0"
|
||||
rand.workspace = true
|
||||
rcgen.workspace = true
|
||||
chrono.workspace = true
|
||||
zeroize = { version = "1.8.2", features = ["std", "simd"] }
|
||||
kameo.workspace = true
|
||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["zeroize"] }
|
||||
@@ -46,23 +41,24 @@ strum = { version = "0.28.0", features = ["derive"] }
|
||||
pem = "3.0.6"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
spki.workspace = true
|
||||
alloy.workspace = true
|
||||
prost-types.workspace = true
|
||||
prost.workspace = true
|
||||
prost-types.workspace = true
|
||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||
anyhow = "1.0.102"
|
||||
serde_with = "3.18.0"
|
||||
mutants.workspace = true
|
||||
subtle = "2.6.1"
|
||||
ml-dsa.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
k256.workspace = true
|
||||
kameo_actors.workspace = true
|
||||
vsss-rs = "5.4.0"
|
||||
rand_core = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.47.2"
|
||||
proptest = "1.11.0"
|
||||
rstest.workspace = true
|
||||
test-log = { version = "0.2", default-features = false, features = ["trace"] }
|
||||
ml-dsa.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -43,13 +43,25 @@ create table if not exists arbiter_settings (
|
||||
insert into arbiter_settings (id) values (1) on conflict do nothing;
|
||||
-- ensure singleton row exists
|
||||
|
||||
create table if not exists useragent_client (
|
||||
create table if not exists operator_identity (
|
||||
id integer not null primary key,
|
||||
public_key blob not null,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key);
|
||||
create unique index if not exists uniq_operator_identity_public_key on operator_identity (public_key);
|
||||
|
||||
create table if not exists operator (
|
||||
id integer primary key references operator_identity(id) on delete restrict, -- same id as operator_identity
|
||||
|
||||
share blob not null,
|
||||
share_nonce blob not null,
|
||||
share_salt blob not null default (randomblob(32)),
|
||||
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
|
||||
) STRICT;
|
||||
|
||||
create table if not exists client_metadata (
|
||||
id integer not null primary key,
|
||||
@@ -204,3 +216,73 @@ create table if not exists integrity_envelope (
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
||||
|
||||
create table if not exists proposal (
|
||||
id integer not null primary key,
|
||||
kind text not null,
|
||||
payload blob not null,
|
||||
initiator_id integer not null references operator_identity(id) on delete restrict,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
expires_at integer not null,
|
||||
status text not null default 'pending'
|
||||
check (status in ('pending', 'approved', 'rejected', 'expired'))
|
||||
) STRICT;
|
||||
|
||||
create table if not exists proposal_vote (
|
||||
id integer not null primary key,
|
||||
proposal_id integer not null references proposal(id) on delete cascade,
|
||||
operator_id integer not null references operator_identity(id) on delete restrict,
|
||||
approve integer not null check (approve in (0, 1)),
|
||||
signature blob not null,
|
||||
voted_at integer not null default(unixepoch('now')),
|
||||
unique (proposal_id, operator_id)
|
||||
) STRICT;
|
||||
|
||||
|
||||
create table if not exists proposal_result (
|
||||
proposal_id integer not null primary key references proposal(id) on delete cascade,
|
||||
data blob not null,
|
||||
created_at integer not null default(unixepoch('now'))
|
||||
) STRICT;
|
||||
|
||||
-- ===============================
|
||||
-- Recovery Operators (§3.4/§3.5/§3.6)
|
||||
-- ===============================
|
||||
|
||||
-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table).
|
||||
create table if not exists recovery_operator (
|
||||
id integer not null primary key references recovery_operator_identity(id) on delete restrict,
|
||||
share blob not null,
|
||||
share_nonce blob not null,
|
||||
share_salt blob not null,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
updated_at integer not null default(unixepoch('now'))
|
||||
) STRICT;
|
||||
|
||||
create table if not exists recovery_operator_identity (
|
||||
id integer not null primary key,
|
||||
public_key blob not null unique,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
updated_at integer not null default(unixepoch('now'))
|
||||
) STRICT;
|
||||
|
||||
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
|
||||
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
|
||||
create table if not exists recovery_wakeup_request (
|
||||
id integer not null primary key,
|
||||
requested_by integer not null references operator_identity(id) on delete restrict,
|
||||
requested_at integer not null default(unixepoch('now')),
|
||||
cancelled_by integer references operator_identity(id) on delete restrict,
|
||||
cancelled_at integer
|
||||
) STRICT;
|
||||
|
||||
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
|
||||
create table if not exists recovery_proposal_vote (
|
||||
id integer not null primary key,
|
||||
proposal_id integer not null references proposal(id) on delete cascade,
|
||||
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
|
||||
approve integer not null check (approve in (0, 1)),
|
||||
signature blob not null,
|
||||
voted_at integer not null default(unixepoch('now')),
|
||||
unique (proposal_id, recovery_operator_id)
|
||||
) STRICT;
|
||||
|
||||
@@ -48,7 +48,7 @@ impl Bootstrapper {
|
||||
let row_count: i64 = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
schema::useragent_client::table
|
||||
schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{
|
||||
crypto::integrity,
|
||||
db::{
|
||||
DatabaseError, DatabasePool,
|
||||
models::{self},
|
||||
models::{self, EvmWalletId},
|
||||
schema,
|
||||
},
|
||||
evm::{
|
||||
@@ -116,7 +116,7 @@ impl EvmActor {
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
||||
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
@@ -134,7 +134,7 @@ impl EvmActor {
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn useragent_create_grant(
|
||||
pub async fn operator_create_grant(
|
||||
&mut self,
|
||||
basic: SharedGrantSettings,
|
||||
grant: SpecificGrant,
|
||||
@@ -160,33 +160,27 @@ impl EvmActor {
|
||||
}
|
||||
|
||||
#[message]
|
||||
#[expect(clippy::unused_async, reason = "reserved for impl")]
|
||||
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 vault = self.vault.clone();
|
||||
pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
|
||||
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
|
||||
// Box::pin(async move {
|
||||
// diesel::update(schema::evm_basic_grant::table)
|
||||
// .filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
||||
// .execute(conn)
|
||||
// .await?;
|
||||
let affected = diesel::update(schema::evm_basic_grant::table)
|
||||
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||
.set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now()))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
|
||||
if affected == 0 {
|
||||
return Err(Error::Database(DatabaseError::from(
|
||||
diesel::result::Error::NotFound,
|
||||
)));
|
||||
}
|
||||
|
||||
// diesel::result::QueryResult::Ok(())
|
||||
// })
|
||||
// })
|
||||
// .await
|
||||
// .map_err(DatabaseError::from)?;
|
||||
|
||||
// Ok(())
|
||||
todo!()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
match self.engine.list_all_grants().await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
actors::flow_coordinator::ApprovalError,
|
||||
peers::{
|
||||
client::ClientProfile,
|
||||
user_agent::{UserAgentSession, session::BeginNewClientApproval},
|
||||
operator::{OperatorSession, session::BeginNewClientApproval},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,12 +15,12 @@ use std::ops::ControlFlow;
|
||||
|
||||
pub struct Args {
|
||||
pub client: ClientProfile,
|
||||
pub user_agents: Vec<ActorRef<UserAgentSession>>,
|
||||
pub operators: Vec<ActorRef<OperatorSession>>,
|
||||
pub reply: ReplySender<Result<bool, ApprovalError>>,
|
||||
}
|
||||
|
||||
pub struct ClientApprovalController {
|
||||
/// Number of UAs that have not yet responded (approval or denial) or died.
|
||||
/// Number of operators that have not yet responded (approval or denial) or died.
|
||||
pending: usize,
|
||||
/// Number of approvals received so far.
|
||||
approved: usize,
|
||||
@@ -42,21 +42,21 @@ impl Actor for ClientApprovalController {
|
||||
async fn on_start(
|
||||
Args {
|
||||
client,
|
||||
user_agents,
|
||||
operators,
|
||||
reply,
|
||||
}: Self::Args,
|
||||
actor_ref: ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let this = Self {
|
||||
pending: user_agents.len(),
|
||||
pending: operators.len(),
|
||||
approved: 0,
|
||||
reply: Some(reply),
|
||||
};
|
||||
|
||||
for user_agent in user_agents {
|
||||
actor_ref.link(&user_agent).await;
|
||||
for operator in operators {
|
||||
actor_ref.link(&operator).await;
|
||||
|
||||
let _ = user_agent
|
||||
let _ = operator
|
||||
.tell(BeginNewClientApproval {
|
||||
client: client.clone(),
|
||||
controller: actor_ref.clone(),
|
||||
@@ -73,10 +73,10 @@ impl Actor for ClientApprovalController {
|
||||
_: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
// A linked UA died before responding — counts as a non-approval.
|
||||
// A linked operator died before responding — counts as a non-approval.
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
if self.pending == 0 {
|
||||
// At least one UA didn't approve: deny.
|
||||
// At least one operator didn't approve: deny.
|
||||
self.send_reply(Ok(false));
|
||||
return Ok(ControlFlow::Break(ActorStopReason::Normal));
|
||||
}
|
||||
@@ -99,7 +99,7 @@ impl ClientApprovalController {
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
|
||||
if self.pending == 0 {
|
||||
// Every connected UA approved.
|
||||
// Every connected operator approved.
|
||||
self.send_reply(Ok(true));
|
||||
ctx.stop();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
useragent_registry::{GetConnected, UserAgentRegistry},
|
||||
operator_registry::{GetConnected, OperatorRegistry},
|
||||
},
|
||||
peers::client::{ClientProfile, session::ClientSession},
|
||||
};
|
||||
@@ -20,14 +20,14 @@ pub mod client_connect_approval;
|
||||
|
||||
pub struct FlowCoordinator {
|
||||
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
|
||||
useragent_registry: ActorRef<UserAgentRegistry>,
|
||||
operator_registry: ActorRef<OperatorRegistry>,
|
||||
}
|
||||
|
||||
impl FlowCoordinator {
|
||||
pub fn new(useragent_registry: ActorRef<UserAgentRegistry>) -> Self {
|
||||
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
|
||||
Self {
|
||||
clients: HashMap::default(),
|
||||
useragent_registry,
|
||||
operator_registry,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,8 @@ impl Actor for FlowCoordinator {
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ApprovalError {
|
||||
#[error("No user agents connected")]
|
||||
NoUserAgentsConnected,
|
||||
#[error("No operators connected")]
|
||||
NoOperatorsConnected,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
@@ -93,19 +93,19 @@ impl FlowCoordinator {
|
||||
unreachable!("Expected `request_client_approval` to have callback channel");
|
||||
};
|
||||
|
||||
let Ok(refs) = self.useragent_registry.ask(GetConnected).await else {
|
||||
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
|
||||
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
|
||||
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
|
||||
return reply;
|
||||
};
|
||||
|
||||
if refs.is_empty() {
|
||||
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
|
||||
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
|
||||
return reply;
|
||||
}
|
||||
|
||||
ClientApprovalController::spawn(client_connect_approval::Args {
|
||||
client,
|
||||
user_agents: refs,
|
||||
operators: refs,
|
||||
reply: reply_sender,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||
useragent_registry::UserAgentRegistry, vault::Vault,
|
||||
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
|
||||
vault_coordinator::VaultCoordinator,
|
||||
},
|
||||
db,
|
||||
};
|
||||
@@ -13,8 +14,10 @@ use thiserror::Error;
|
||||
pub mod bootstrap;
|
||||
pub mod evm;
|
||||
pub mod flow_coordinator;
|
||||
pub mod useragent_registry;
|
||||
pub mod operator_registry;
|
||||
pub mod proposal_manager;
|
||||
pub mod vault;
|
||||
pub mod vault_coordinator;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SpawnError {
|
||||
@@ -30,9 +33,11 @@ pub enum SpawnError {
|
||||
pub struct GlobalActors {
|
||||
pub vault: ActorRef<Vault>,
|
||||
pub bootstrapper: ActorRef<Bootstrapper>,
|
||||
pub vault_coordinator: ActorRef<VaultCoordinator>,
|
||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||
pub useragent_registry: ActorRef<UserAgentRegistry>,
|
||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||
pub evm: ActorRef<EvmActor>,
|
||||
pub proposal_manager: ActorRef<ProposalManager>,
|
||||
pub events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
@@ -44,16 +49,28 @@ impl GlobalActors {
|
||||
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
|
||||
let message_bus = Self::spawn_message_bus();
|
||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||
let useragent_registry = UserAgentRegistry::spawn(UserAgentRegistry::default());
|
||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
|
||||
let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new(
|
||||
db.clone(),
|
||||
key_holder.clone(),
|
||||
));
|
||||
Ok(Self {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
|
||||
vault: key_holder,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||
useragent_registry.clone(),
|
||||
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
||||
db,
|
||||
key_holder.clone(),
|
||||
evm.clone(),
|
||||
vault_coordinator.clone(),
|
||||
)),
|
||||
useragent_registry,
|
||||
vault: key_holder,
|
||||
vault_coordinator,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||
operator_registry.clone(),
|
||||
)),
|
||||
operator_registry,
|
||||
events: message_bus,
|
||||
evm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::peers::user_agent::UserAgentSession;
|
||||
use crate::peers::operator::OperatorSession;
|
||||
|
||||
use kameo::{
|
||||
Actor,
|
||||
@@ -11,11 +11,11 @@ use std::{collections::HashMap, ops::ControlFlow};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserAgentRegistry {
|
||||
connected: HashMap<ActorId, ActorRef<UserAgentSession>>,
|
||||
pub struct OperatorRegistry {
|
||||
connected: HashMap<ActorId, ActorRef<OperatorSession>>,
|
||||
}
|
||||
|
||||
impl Actor for UserAgentRegistry {
|
||||
impl Actor for OperatorRegistry {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Infallible;
|
||||
@@ -33,8 +33,8 @@ impl Actor for UserAgentRegistry {
|
||||
if self.connected.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "UserAgentRegistry",
|
||||
event = "useragent.disconnected"
|
||||
actor = "OperatorRegistry",
|
||||
event = "operator.disconnected"
|
||||
);
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
@@ -42,20 +42,20 @@ impl Actor for UserAgentRegistry {
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentRegistry {
|
||||
impl OperatorRegistry {
|
||||
#[message(ctx)]
|
||||
pub async fn connect_useragent(
|
||||
pub async fn connect_operator(
|
||||
&mut self,
|
||||
actor: ActorRef<UserAgentSession>,
|
||||
actor: ActorRef<OperatorSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "UserAgentRegistry", event = "useragent.connected");
|
||||
info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.connected.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn get_connected(&self) -> Vec<ActorRef<UserAgentSession>> {
|
||||
pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
|
||||
self.connected.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
1011
server/crates/arbiter-server/src/actors/proposal_manager.rs
Normal file
1011
server/crates/arbiter-server/src/actors/proposal_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
use crate::{
|
||||
crypto::{
|
||||
KeyCell, derive_key,
|
||||
KeyCell,
|
||||
encryption::v1::{self, Nonce},
|
||||
integrity::v1::HmacSha256,
|
||||
},
|
||||
db::{
|
||||
self,
|
||||
models::{self, RootKeyHistory},
|
||||
schema::{self},
|
||||
models::{self, RootKeyHistory, RootKeyHistoryId},
|
||||
schema,
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
@@ -25,7 +25,6 @@ use strum::{EnumDiscriminants, IntoDiscriminant};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub mod events {
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Bootstrapped;
|
||||
|
||||
@@ -64,7 +63,7 @@ pub enum Error {
|
||||
}
|
||||
|
||||
struct Unsealed {
|
||||
root_key_history_id: i32,
|
||||
root_key_history_id: RootKeyHistoryId,
|
||||
root_key: KeyCell,
|
||||
}
|
||||
|
||||
@@ -73,8 +72,9 @@ struct Unsealed {
|
||||
enum State {
|
||||
#[default]
|
||||
Unbootstrapped,
|
||||
|
||||
Sealed {
|
||||
root_key_history_id: i32,
|
||||
root_key_history_id: RootKeyHistoryId,
|
||||
},
|
||||
Unsealed(Unsealed),
|
||||
}
|
||||
@@ -90,7 +90,6 @@ pub struct Vault {
|
||||
events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Vault {
|
||||
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
|
||||
let state = {
|
||||
@@ -113,37 +112,38 @@ impl Vault {
|
||||
Ok(Self { db, state, events })
|
||||
}
|
||||
|
||||
// Exclusive transaction to avoid race condtions if multiple vaults write
|
||||
// Exclusive transaction to avoid race conditions if multiple vaults write
|
||||
// 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: RootKeyHistoryId,
|
||||
) -> Result<Nonce, Error> {
|
||||
let mut conn = pool.get().await?;
|
||||
|
||||
let nonce = conn
|
||||
.exclusive_transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let current_nonce: Vec<u8> = schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.select(schema::root_key_history::data_encryption_nonce)
|
||||
.first(conn)
|
||||
.await?;
|
||||
.exclusive_transaction(async |conn| {
|
||||
let current_nonce: Vec<u8> = schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.select(schema::root_key_history::data_encryption_nonce)
|
||||
.first(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for root key history id={}",
|
||||
root_key_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
nonce.increment();
|
||||
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for root key history id={:#?}",
|
||||
root_key_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
nonce.increment();
|
||||
|
||||
update(schema::root_key_history::table)
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
update(schema::root_key_history::table)
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, Error>::Ok(nonce)
|
||||
})
|
||||
Result::<_, Error>::Ok(nonce)
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -157,56 +157,60 @@ impl Vault {
|
||||
State::Sealed { .. } => Err(Error::Sealed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Vault {
|
||||
#[message]
|
||||
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
if !matches!(self.state, State::Unbootstrapped) {
|
||||
pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
|
||||
if !matches!(&self.state, State::Unbootstrapped) {
|
||||
return Err(Error::AlreadyBootstrapped);
|
||||
}
|
||||
let salt = v1::generate_salt();
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
|
||||
let mut root_key = KeyCell::new_secure_random();
|
||||
|
||||
// Zero nonces are fine because they are one-time
|
||||
let root_key_nonce = Nonce::default();
|
||||
let data_encryption_nonce = Nonce::default();
|
||||
|
||||
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
|
||||
let root_key_reader = reader.as_slice();
|
||||
// Generate salt (kept for schema compat)
|
||||
let root_key_salt = v1::generate_salt();
|
||||
|
||||
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
|
||||
seal_key
|
||||
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
|
||||
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
|
||||
.map_err(|err| {
|
||||
error!(?err, "Fatal bootstrap error");
|
||||
Error::Encryption(err)
|
||||
})
|
||||
})?;
|
||||
|
||||
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
|
||||
let root_key_history_id = conn
|
||||
.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
|
||||
.values(&models::NewRootKeyHistory {
|
||||
ciphertext: root_key_ciphertext,
|
||||
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||
root_key_encryption_nonce: root_key_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce_bytes,
|
||||
schema_version: 1,
|
||||
salt: salt.to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
.transaction(async |conn| {
|
||||
let root_key_history_id = insert_into(schema::root_key_history::table)
|
||||
.values(&models::NewRootKeyHistory {
|
||||
ciphertext: root_key_ciphertext.clone(),
|
||||
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||
root_key_encryption_nonce: root_key_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
|
||||
schema_version: 1,
|
||||
salt: root_key_salt.to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
update(schema::arbiter_settings::table)
|
||||
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
update(schema::arbiter_settings::table)
|
||||
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
|
||||
})
|
||||
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
|
||||
root_key_history_id,
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -222,52 +226,47 @@ impl Vault {
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
|
||||
let State::Sealed {
|
||||
root_key_history_id,
|
||||
} = &self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
let root_key_history_id = *root_key_history_id;
|
||||
|
||||
// We don't want to hold connection while doing expensive KDF work
|
||||
// We don't want to hold connection while doing expensive work
|
||||
let current_key = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(*root_key_history_id))
|
||||
.filter(schema::root_key_history::id.eq(root_key_history_id))
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let salt = ¤t_key.salt;
|
||||
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
|
||||
error!("Broken database: invalid salt for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
|
||||
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
|
||||
|
||||
let nonce =
|
||||
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
|
||||
error!("Broken database: invalid nonce for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
|
||||
seal_key
|
||||
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
|
||||
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
|
||||
.map_err(|err| {
|
||||
error!(?err, "Failed to unseal root key: invalid seal key");
|
||||
Error::InvalidKey
|
||||
})?;
|
||||
|
||||
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
|
||||
error!("Broken database: invalid encryption key size");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
self.state = State::Unsealed(Unsealed {
|
||||
root_key_history_id: current_key.id,
|
||||
root_key: KeyCell::try_from(root_key).map_err(|err| {
|
||||
error!(?err, "Broken database: invalid encryption key size");
|
||||
Error::BrokenDatabase
|
||||
})?,
|
||||
root_key,
|
||||
});
|
||||
|
||||
info!("Vault unsealed successfully");
|
||||
@@ -276,6 +275,77 @@ impl Vault {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row.
|
||||
/// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal.
|
||||
#[message]
|
||||
pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
let new_nonce = Nonce::default();
|
||||
let new_salt = v1::generate_salt();
|
||||
|
||||
let new_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
|
||||
new_seal_key
|
||||
.encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
|
||||
.map_err(|err| {
|
||||
error!(?err, "Fatal rekey error");
|
||||
Error::Encryption(err)
|
||||
})
|
||||
})?;
|
||||
|
||||
let data_encryption_nonce = Nonce::default();
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
let new_root_key_history_id: i32 = conn
|
||||
.transaction(async |conn| {
|
||||
let new_id = insert_into(schema::root_key_history::table)
|
||||
.values(&models::NewRootKeyHistory {
|
||||
ciphertext: new_ciphertext,
|
||||
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||
root_key_encryption_nonce: new_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce.to_vec(),
|
||||
schema_version: 1,
|
||||
salt: new_salt.to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result::<i32>(&mut *conn)
|
||||
.await?;
|
||||
|
||||
update(schema::arbiter_settings::table)
|
||||
.set(schema::arbiter_settings::root_key_id.eq(new_id))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(new_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
*root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id);
|
||||
info!("Vault root key rekeyed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn seal(&mut self) -> Result<(), Error> {
|
||||
let Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
self.state = State::Sealed {
|
||||
root_key_history_id: *root_key_history_id,
|
||||
};
|
||||
let _ = self.events.tell(Publish(events::VaultResealed)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side cryptographic operations
|
||||
#[messages]
|
||||
impl Vault {
|
||||
#[message]
|
||||
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
|
||||
@@ -344,19 +414,20 @@ impl Vault {
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
|
||||
pub fn sign_integrity(
|
||||
&mut self,
|
||||
mac_input: Vec<u8>,
|
||||
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
|
||||
let 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());
|
||||
let mut hmac = root_key.0.read_inline(|k| {
|
||||
HmacSha256::new_from_slice(k)
|
||||
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
|
||||
});
|
||||
hmac.update(&root_key_history_id.to_raw().to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
let mac = hmac.finalize().into_bytes().to_vec();
|
||||
@@ -368,7 +439,7 @@ impl Vault {
|
||||
&mut self,
|
||||
mac_input: Vec<u8>,
|
||||
expected_mac: Vec<u8>,
|
||||
key_version: i32,
|
||||
key_version: RootKeyHistoryId,
|
||||
) -> Result<bool, Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
@@ -379,31 +450,15 @@ impl Vault {
|
||||
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());
|
||||
let mut hmac = root_key.0.read_inline(|k| {
|
||||
HmacSha256::new_from_slice(k)
|
||||
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
|
||||
});
|
||||
hmac.update(&key_version.to_raw().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,
|
||||
..
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
self.state = State::Sealed {
|
||||
root_key_history_id: *root_key_history_id,
|
||||
};
|
||||
let _ = self.events.tell(Publish(events::VaultResealed)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -417,8 +472,7 @@ mod tests {
|
||||
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap();
|
||||
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
actor.bootstrap(seal_key).await.unwrap();
|
||||
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
@@ -427,12 +481,12 @@ mod tests {
|
||||
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
|
||||
let db = db::create_test_pool().await;
|
||||
let mut actor = bootstrapped_actor(&db).await;
|
||||
let root_key_history_id = match actor.state {
|
||||
State::Unsealed(Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
}) => root_key_history_id,
|
||||
_ => panic!("expected unsealed state"),
|
||||
let State::Unsealed(Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
}) = actor.state
|
||||
else {
|
||||
panic!("expected unsealed state");
|
||||
};
|
||||
|
||||
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
|
||||
@@ -444,8 +498,8 @@ mod tests {
|
||||
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let root_row: models::RootKeyHistory = schema::root_key_history::table
|
||||
.select(models::RootKeyHistory::as_select())
|
||||
let root_row: RootKeyHistory = schema::root_key_history::table
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
721
server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs
Normal file
721
server/crates/arbiter-server/src/actors/vault_coordinator/mod.rs
Normal file
@@ -0,0 +1,721 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
|
||||
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
|
||||
db::{self, models, schema},
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Already coordinating a bootstrap")]
|
||||
AlreadyBootstrapping,
|
||||
#[error("Already coordinating an unseal")]
|
||||
AlreadyUnsealing,
|
||||
#[error("Rekey not in progress")]
|
||||
NotRekeying,
|
||||
#[error("Bootstrap not in progress")]
|
||||
NotBootstrapping,
|
||||
#[error("Unseal not in progress")]
|
||||
NotUnsealing,
|
||||
#[error("Operator already contributed")]
|
||||
DuplicateContribution,
|
||||
#[error("Operator not found in database")]
|
||||
OperatorNotFound,
|
||||
#[error("Invalid passphrase (decryption failed)")]
|
||||
InvalidPassphrase,
|
||||
#[error("Shamir error: {0}")]
|
||||
Shamir(String),
|
||||
#[error("Database connection error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
#[error("Encryption error")]
|
||||
Encryption,
|
||||
#[error("Vault error")]
|
||||
VaultError,
|
||||
#[error("Two-operator vaults require at least one recovery share")]
|
||||
TwoOperatorsRequireRecovery,
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
}
|
||||
|
||||
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
|
||||
// They are ephemeral and dropped immediately after use.
|
||||
enum CoordinatorState {
|
||||
Idle,
|
||||
Bootstrapping {
|
||||
declared_count: usize,
|
||||
recovery_count: usize,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
Unsealing {
|
||||
threshold: usize,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
/// Shamir re-key after `replace_operator` or `update_shamir_parameters` is approved (§3.3).
|
||||
/// Collects new passphrases from all current operators, then generates a fresh seal key,
|
||||
/// re-splits it, and re-encrypts the vault root key.
|
||||
Rekeying {
|
||||
ordinary_count: usize,
|
||||
recovery_count: usize,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct VaultCoordinator {
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
state: CoordinatorState,
|
||||
}
|
||||
|
||||
impl VaultCoordinator {
|
||||
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
vault,
|
||||
state: CoordinatorState::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||
|
||||
fn encrypt_share(
|
||||
passphrase_bytes: Vec<u8>,
|
||||
share: &[u8],
|
||||
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
|
||||
let mut share_salt = vec![0u8; 32];
|
||||
OsRng.fill_bytes(&mut share_salt);
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
let nonce = Nonce::default();
|
||||
let encrypted_share = share_seal_key
|
||||
.encrypt(&nonce, SHARE_AAD, share)
|
||||
.map_err(|_| Error::Encryption)?;
|
||||
|
||||
Ok((encrypted_share, nonce.to_vec(), share_salt))
|
||||
}
|
||||
|
||||
fn decrypt_share(
|
||||
passphrase_bytes: Vec<u8>,
|
||||
encrypted_share: Vec<u8>,
|
||||
share_nonce_bytes: &[u8],
|
||||
share_salt: &[u8],
|
||||
operator_id: i32,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let nonce = Nonce::try_from(share_nonce_bytes).map_err(|()| {
|
||||
error!(operator_id, "Invalid nonce in DB");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt);
|
||||
|
||||
let mut share_buffer = SafeCell::new(encrypted_share);
|
||||
share_seal_key
|
||||
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
||||
.map_err(|_| Error::InvalidPassphrase)?;
|
||||
|
||||
Ok(share_buffer.read().clone())
|
||||
}
|
||||
|
||||
/// §3.4: Split the seal key across ordinary + recovery operators.
|
||||
/// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery.
|
||||
/// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split,
|
||||
/// so each share is the seal key itself — any single participant can reconstruct.
|
||||
async fn finalize_bootstrap(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let ordinary_count = ordinary_passphrases.len();
|
||||
let recovery_count = recovery_passphrases.len();
|
||||
let total = ordinary_count + recovery_count;
|
||||
let threshold = shamir_threshold(ordinary_count);
|
||||
|
||||
let mut seal_key_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seal_key_bytes);
|
||||
|
||||
// threshold == 1 means any single share reconstructs the key (degenerate split).
|
||||
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
|
||||
let shares: Vec<Vec<u8>> = if threshold >= 2 {
|
||||
shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
||||
.map_err(|e| Error::Shamir(e.to_string()))?
|
||||
} else {
|
||||
std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect()
|
||||
};
|
||||
|
||||
let seal_key = KeyCell::from(seal_key_bytes);
|
||||
|
||||
let mut conn = db.get().await?;
|
||||
let mut shares_iter = shares.into_iter();
|
||||
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let share = shares_iter
|
||||
.next()
|
||||
.expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::operator::table)
|
||||
.values((
|
||||
schema::operator::id.eq(Some(operator_id_raw)),
|
||||
schema::operator::share.eq(&encrypted_share),
|
||||
schema::operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::operator::share_salt.eq(&share_salt),
|
||||
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let share = shares_iter
|
||||
.next()
|
||||
.expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::recovery_operator::table)
|
||||
.values((
|
||||
schema::recovery_operator::id.eq(recovery_id_raw),
|
||||
schema::recovery_operator::share.eq(&encrypted_share),
|
||||
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::recovery_operator::share_salt.eq(&share_salt),
|
||||
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
|
||||
error!(?err, "Vault bootstrap failed");
|
||||
Error::VaultError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
|
||||
async fn finalize_unseal(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
|
||||
let ordinary_operator_count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold = shamir_threshold(ordinary_operator_count as usize);
|
||||
|
||||
let mut shares: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||
schema::operator::table
|
||||
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
||||
.select((
|
||||
schema::operator::share,
|
||||
schema::operator::share_nonce,
|
||||
schema::operator::share_salt,
|
||||
))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|_| Error::OperatorNotFound)?;
|
||||
|
||||
shares.push(decrypt_share(
|
||||
passphrase_bytes,
|
||||
encrypted_share,
|
||||
&share_nonce_bytes,
|
||||
&share_salt,
|
||||
operator_id_raw,
|
||||
)?);
|
||||
}
|
||||
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||
schema::recovery_operator::table
|
||||
.find(recovery_id_raw)
|
||||
.select((
|
||||
schema::recovery_operator::share,
|
||||
schema::recovery_operator::share_nonce,
|
||||
schema::recovery_operator::share_salt,
|
||||
))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|_| Error::OperatorNotFound)?;
|
||||
|
||||
shares.push(decrypt_share(
|
||||
passphrase_bytes,
|
||||
encrypted_share,
|
||||
&share_nonce_bytes,
|
||||
&share_salt,
|
||||
recovery_id_raw,
|
||||
)?);
|
||||
}
|
||||
|
||||
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
|
||||
// Any single decrypted share is the key itself.
|
||||
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
|
||||
let raw = shares
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| Error::Shamir("No shares available".into()))?;
|
||||
raw.try_into()
|
||||
.map_err(|_| Error::Shamir("Invalid share length".into()))?
|
||||
} else {
|
||||
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
|
||||
};
|
||||
|
||||
let seal_key = KeyCell::from(seal_key_bytes);
|
||||
|
||||
vault.ask(TryUnseal { seal_key }).await.map_err(|err| {
|
||||
error!(?err, "Vault unseal failed");
|
||||
Error::VaultError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key.
|
||||
/// Called after `replace_operator` or `update_shamir_parameters` is approved and all contributors submit.
|
||||
async fn finalize_rekey(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let ordinary_count = ordinary_passphrases.len();
|
||||
let recovery_count = recovery_passphrases.len();
|
||||
let total = ordinary_count + recovery_count;
|
||||
let threshold = shamir_threshold(ordinary_count);
|
||||
|
||||
let mut new_seal_key_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut new_seal_key_bytes);
|
||||
|
||||
let shares: Vec<Vec<u8>> = if threshold >= 2 {
|
||||
shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng)
|
||||
.map_err(|e| Error::Shamir(e.to_string()))?
|
||||
} else {
|
||||
std::iter::repeat_with(|| new_seal_key_bytes.to_vec())
|
||||
.take(total)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut conn = db.get().await?;
|
||||
let mut shares_iter = shares.into_iter();
|
||||
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let share = shares_iter
|
||||
.next()
|
||||
.expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::operator::table)
|
||||
.values((
|
||||
schema::operator::id.eq(Some(operator_id_raw)),
|
||||
schema::operator::share.eq(&encrypted_share),
|
||||
schema::operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::operator::share_salt.eq(&share_salt),
|
||||
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let share = shares_iter
|
||||
.next()
|
||||
.expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::recovery_operator::table)
|
||||
.values((
|
||||
schema::recovery_operator::id.eq(recovery_id_raw),
|
||||
schema::recovery_operator::share.eq(&encrypted_share),
|
||||
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::recovery_operator::share_salt.eq(&share_salt),
|
||||
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
let new_seal_key = KeyCell::from(new_seal_key_bytes);
|
||||
vault
|
||||
.ask(RekeyRootKey { new_seal_key })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(?err, "Vault rekey failed");
|
||||
Error::VaultError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl VaultCoordinator {
|
||||
/// Phase 1 of multi-operator bootstrap: declare the committee size.
|
||||
#[message]
|
||||
#[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
|
||||
pub async fn start_bootstrap(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
declared_count: usize,
|
||||
recovery_count: usize,
|
||||
) -> Result<(), Error> {
|
||||
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
|
||||
if !matches!(self.state, CoordinatorState::Idle) {
|
||||
return Err(Error::AlreadyBootstrapping);
|
||||
}
|
||||
if declared_count == 2 && recovery_count == 0 {
|
||||
return Err(Error::TwoOperatorsRequireRecovery);
|
||||
}
|
||||
self.state = CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
|
||||
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
|
||||
#[message]
|
||||
pub async fn contribute_bootstrap(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
};
|
||||
|
||||
if passphrases.contains_key(&operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
passphrases.insert(operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Bootstrapping {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_bootstrap(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase.
|
||||
/// Returns Ok(true) when all contributors are in and bootstrap finalized.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_bootstrap(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Bootstrapping {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_bootstrap(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal (ordinary operator).
|
||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||
#[message]
|
||||
pub async fn contribute_unseal(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
self.ensure_unsealing_state().await?;
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotUnsealing);
|
||||
};
|
||||
|
||||
if ordinary_passphrases.contains_key(&operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
ordinary_passphrases.insert(operator_id, passphrase_bytes);
|
||||
|
||||
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_unseal().await
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal (recovery operator, §3.5).
|
||||
/// Recovery operators may contribute during unseal when recovery is active.
|
||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_unseal(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
self.ensure_unsealing_state().await?;
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotUnsealing);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||
|
||||
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_unseal().await
|
||||
}
|
||||
}
|
||||
|
||||
impl VaultCoordinator {
|
||||
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
|
||||
/// Threshold is based on ordinary operator count only (§3.4).
|
||||
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
|
||||
if matches!(self.state, CoordinatorState::Idle) {
|
||||
let mut conn = self.db.get().await?;
|
||||
let ordinary_count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
|
||||
self.state = CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves state back to Idle and calls finalize_unseal.
|
||||
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
|
||||
let CoordinatorState::Unsealing {
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_unseal(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn do_finalize_rekey(&mut self) -> Result<bool, Error> {
|
||||
let CoordinatorState::Rekeying {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_rekey(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl VaultCoordinator {
|
||||
/// Begin Shamir re-key after a key-rotation proposal is approved (§3.3).
|
||||
/// Queries the current operator and recovery operator counts from the DB,
|
||||
/// then transitions to Rekeying state awaiting contributions from all of them.
|
||||
#[message]
|
||||
pub async fn start_rekey(&mut self) -> Result<(), Error> {
|
||||
if !matches!(self.state, CoordinatorState::Idle) {
|
||||
return Err(Error::AlreadyBootstrapping);
|
||||
}
|
||||
let mut conn = self.db.get().await?;
|
||||
let ordinary_count: i64 = schema::operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let recovery_count: i64 = schema::recovery_operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
self.state = CoordinatorState::Rekeying {
|
||||
ordinary_count: ordinary_count as usize,
|
||||
recovery_count: recovery_count as usize,
|
||||
passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Contribute an ordinary operator passphrase for the re-key.
|
||||
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
|
||||
#[message]
|
||||
pub async fn contribute_rekey(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Rekeying {
|
||||
ordinary_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotRekeying);
|
||||
};
|
||||
|
||||
if passphrases.contains_key(&operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
passphrases.insert(operator_id, passphrase.read().to_vec());
|
||||
|
||||
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_rekey().await
|
||||
}
|
||||
|
||||
/// Contribute a recovery operator passphrase for the re-key.
|
||||
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_rekey(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Rekeying {
|
||||
ordinary_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotRekeying);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec());
|
||||
|
||||
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_rekey().await
|
||||
}
|
||||
}
|
||||
@@ -174,28 +174,26 @@ impl TlsManager {
|
||||
|
||||
{
|
||||
let mut conn = db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async {
|
||||
let new_tls_history = NewTlsHistory {
|
||||
cert: new_cert.cert.pem(),
|
||||
cert_key: new_cert.cert_key.serialize_pem(),
|
||||
ca_cert: encode_cert_to_pem(&ca.cert),
|
||||
ca_key: ca.issuer.key().serialize_pem(),
|
||||
};
|
||||
conn.transaction(async |conn| {
|
||||
let new_tls_history = NewTlsHistory {
|
||||
cert: new_cert.cert.pem(),
|
||||
cert_key: new_cert.cert_key.serialize_pem(),
|
||||
ca_cert: encode_cert_to_pem(&ca.cert),
|
||||
ca_key: ca.issuer.key().serialize_pem(),
|
||||
};
|
||||
|
||||
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
|
||||
.values(&new_tls_history)
|
||||
.returning(tls_history::id)
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
|
||||
.values(&new_tls_history)
|
||||
.returning(tls_history::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
diesel::update(arbiter_settings::table)
|
||||
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
diesel::update(arbiter_settings::table)
|
||||
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(())
|
||||
})
|
||||
Result::<_, diesel::result::Error>::Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -61,12 +61,12 @@ mod tests {
|
||||
#[test]
|
||||
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 mut password = SafeCell::new(PASSWORD.to_vec());
|
||||
let mut 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 mut key1 = derive_key(&mut password, &salt);
|
||||
let mut key2 = derive_key(&mut password2, &salt);
|
||||
|
||||
let key1_reader = key1.0.read();
|
||||
let key2_reader = key2.0.read();
|
||||
@@ -77,10 +77,10 @@ mod tests {
|
||||
#[test]
|
||||
fn successful_derive() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let password = SafeCell::new(PASSWORD.to_vec());
|
||||
let mut password = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key = derive_key(password, &salt);
|
||||
let mut key = derive_key(&mut password, &salt);
|
||||
let key_reader = key.0.read();
|
||||
|
||||
assert_ne!(key_reader.as_slice(), &[0u8; 32][..]);
|
||||
|
||||
@@ -215,8 +215,6 @@ mod tests {
|
||||
},
|
||||
db::{self, schema},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use super::{Error, Integrable, sign_entity, verify_entity};
|
||||
#[derive(Clone, arbiter_macros::Hashable)]
|
||||
struct DummyEntity {
|
||||
@@ -235,7 +233,7 @@ mod tests {
|
||||
);
|
||||
actor
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
|
||||
seal_key: crate::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use encryption::v1::{Nonce, Salt};
|
||||
use encryption::v1::Nonce;
|
||||
|
||||
use argon2::{Algorithm, Argon2};
|
||||
use chacha20poly1305::{
|
||||
@@ -13,6 +13,7 @@ use rand::{
|
||||
|
||||
pub mod encryption;
|
||||
pub mod integrity;
|
||||
pub mod shamir;
|
||||
|
||||
pub struct KeyCell(pub SafeCell<Key>);
|
||||
impl From<SafeCell<Key>> for KeyCell {
|
||||
@@ -20,6 +21,15 @@ impl From<SafeCell<Key>> for KeyCell {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
impl From<[u8; 32]> for KeyCell {
|
||||
fn from(bytes: [u8; 32]) -> Self {
|
||||
let cell = SafeCell::new_inline_default(|key: &mut Key| {
|
||||
key.copy_from_slice(&bytes);
|
||||
});
|
||||
Self(cell)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
|
||||
type Error = ();
|
||||
|
||||
@@ -28,7 +38,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
|
||||
if value.len() != size_of::<Key>() {
|
||||
return Err(());
|
||||
}
|
||||
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
|
||||
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
|
||||
cell_write.copy_from_slice(&value);
|
||||
});
|
||||
Ok(Self(cell))
|
||||
@@ -37,7 +47,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
|
||||
|
||||
impl KeyCell {
|
||||
pub fn new_secure_random() -> Self {
|
||||
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
|
||||
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| {
|
||||
let mut rng = StdRng::try_from_rng(&mut SysRng)
|
||||
.expect("Rng failure is unrecoverable and should panic");
|
||||
rng.fill_bytes(key_buffer);
|
||||
@@ -94,7 +104,7 @@ impl KeyCell {
|
||||
}
|
||||
|
||||
/// 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_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell {
|
||||
let params = {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
@@ -132,10 +142,10 @@ mod tests {
|
||||
#[test]
|
||||
fn encrypt_decrypt() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let password = SafeCell::new(PASSWORD.to_vec());
|
||||
let mut password = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key = derive_key(password, &salt);
|
||||
let mut key = derive_key(&mut password, &salt);
|
||||
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
|
||||
let associated_data = b"associated data";
|
||||
let mut buffer = b"secret data".to_vec();
|
||||
|
||||
41
server/crates/arbiter-server/src/crypto/shamir.rs
Normal file
41
server/crates/arbiter-server/src/crypto/shamir.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use vsss_rs::Gf256;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ShamirError {
|
||||
#[error("Failed to split key: {0}")]
|
||||
Split(String),
|
||||
#[error("Failed to combine shares: {0}")]
|
||||
Combine(String),
|
||||
}
|
||||
|
||||
/// Split `key` into `total` shares where any `threshold` shares can reconstruct it.
|
||||
/// Each returned Vec<u8> is a share with format [`identifier_byte`, `value_bytes`...].
|
||||
pub fn split_key(
|
||||
threshold: usize,
|
||||
total: usize,
|
||||
key: &[u8; 32],
|
||||
rng: impl rand_core::RngCore + rand_core::CryptoRng,
|
||||
) -> Result<Vec<Vec<u8>>, ShamirError> {
|
||||
Gf256::split_array(threshold, total, key.as_slice(), rng)
|
||||
.map_err(|e| ShamirError::Split(format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Returns the minimum number of shares required to reconstruct the secret
|
||||
/// for a committee of `n` operators.
|
||||
#[must_use]
|
||||
pub const fn shamir_threshold(n: usize) -> usize {
|
||||
match n {
|
||||
0 => panic!("No operators"),
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
n => n / 2 + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the secret from `threshold` or more shares.
|
||||
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
|
||||
let bytes = Gf256::combine_array(shares)
|
||||
.map_err(|e| ShamirError::Combine(format!("{e:?}")))?;
|
||||
<[u8; 32]>::try_from(bytes.as_slice())
|
||||
.map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
|
||||
}
|
||||
@@ -15,10 +15,11 @@ use restructed::Models;
|
||||
pub mod types {
|
||||
use chrono::{DateTime, Utc};
|
||||
use diesel::{
|
||||
backend::Backend,
|
||||
deserialize::{FromSql, FromSqlRow},
|
||||
expression::AsExpression,
|
||||
serialize::{IsNull, ToSql},
|
||||
sql_types::Integer,
|
||||
sql_types::{Integer, Text},
|
||||
sqlite::{Sqlite, SqliteType},
|
||||
};
|
||||
|
||||
@@ -61,7 +62,7 @@ pub mod types {
|
||||
|
||||
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
|
||||
fn from_sql(
|
||||
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
mut bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let Some(SqliteType::Long) = bytes.value_type() else {
|
||||
return Err(format!(
|
||||
@@ -79,10 +80,41 @@ pub mod types {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
|
||||
pub struct ChainId(pub i32);
|
||||
macro_rules! declare_id {
|
||||
($name:ident) => {
|
||||
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
|
||||
pub struct $name(i32);
|
||||
|
||||
impl $name {
|
||||
pub const fn to_raw(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
pub const fn from_raw(raw: i32) -> Self {
|
||||
Self(raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Integer, Sqlite> for $name {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
|
||||
}
|
||||
}
|
||||
impl ToSql<Integer, Sqlite> for $name {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare_id!(ChainId);
|
||||
|
||||
#[expect(
|
||||
clippy::cast_sign_loss,
|
||||
@@ -103,19 +135,50 @@ pub mod types {
|
||||
}
|
||||
};
|
||||
|
||||
impl FromSql<Integer, Sqlite> for ChainId {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
|
||||
}
|
||||
declare_id!(OperatorId);
|
||||
declare_id!(OperatorIdentityId);
|
||||
declare_id!(AeadEncryptedId);
|
||||
declare_id!(RootKeyHistoryId);
|
||||
declare_id!(TlsHistoryId);
|
||||
declare_id!(EvmWalletId);
|
||||
declare_id!(ClientId);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = Text)]
|
||||
pub enum ProposalStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
impl ToSql<Integer, Sqlite> for ChainId {
|
||||
|
||||
impl ToSql<Text, Sqlite> for ProposalStatus {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
|
||||
let s: &str = match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Rejected => "rejected",
|
||||
Self::Expired => "expired",
|
||||
};
|
||||
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Text, Sqlite> for ProposalStatus {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
|
||||
match s.as_str() {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"approved" => Ok(Self::Approved),
|
||||
"rejected" => Ok(Self::Rejected),
|
||||
"expired" => Ok(Self::Expired),
|
||||
other => Err(format!("Unknown proposal status: {other}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,12 +193,12 @@ pub use types::*;
|
||||
)]
|
||||
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
|
||||
pub struct AeadEncrypted {
|
||||
pub id: i32,
|
||||
pub id: AeadEncryptedId,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub current_nonce: Vec<u8>,
|
||||
pub schema_version: i32,
|
||||
pub associated_root_key_id: i32, // references root_key_history.id
|
||||
pub associated_root_key_id: RootKeyHistoryId,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
@@ -148,7 +211,7 @@ pub struct AeadEncrypted {
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct RootKeyHistory {
|
||||
pub id: i32,
|
||||
pub id: RootKeyHistoryId,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub root_key_encryption_nonce: Vec<u8>,
|
||||
@@ -166,7 +229,7 @@ pub struct RootKeyHistory {
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct TlsHistory {
|
||||
pub id: i32,
|
||||
pub id: TlsHistoryId,
|
||||
pub cert: String,
|
||||
pub cert_key: String, // PEM Encoded private key
|
||||
pub ca_cert: String, // PEM Encoded certificate for cert signing
|
||||
@@ -191,7 +254,7 @@ pub struct ArbiterSettings {
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmWallet {
|
||||
pub id: i32,
|
||||
pub id: EvmWalletId,
|
||||
pub address: Vec<u8>,
|
||||
pub aead_encrypted_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
@@ -213,7 +276,7 @@ pub struct EvmWallet {
|
||||
)]
|
||||
pub struct EvmWalletAccess {
|
||||
pub id: i32,
|
||||
pub wallet_id: i32,
|
||||
pub wallet_id: EvmWalletId,
|
||||
pub client_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
@@ -240,7 +303,7 @@ pub struct ProgramClientMetadataHistory {
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClient {
|
||||
pub id: i32,
|
||||
pub id: ClientId,
|
||||
pub public_key: Vec<u8>,
|
||||
pub metadata_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
@@ -248,14 +311,25 @@ pub struct ProgramClient {
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug)]
|
||||
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
|
||||
pub struct UseragentClient {
|
||||
pub id: i32,
|
||||
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
|
||||
pub struct OperatorClient {
|
||||
pub id: OperatorIdentityId,
|
||||
pub public_key: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug)]
|
||||
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
|
||||
pub struct Operator {
|
||||
pub id: OperatorId,
|
||||
pub share: Vec<u8>,
|
||||
pub share_nonce: Vec<u8>,
|
||||
pub share_salt: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
@@ -399,8 +473,73 @@ pub struct IntegrityEnvelope {
|
||||
pub entity_kind: String,
|
||||
pub entity_id: Vec<u8>,
|
||||
pub payload_version: i32,
|
||||
pub key_version: i32,
|
||||
pub key_version: RootKeyHistoryId,
|
||||
pub mac: Vec<u8>,
|
||||
pub signed_at: SqliteTimestamp,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||
pub struct Proposal {
|
||||
pub id: i32,
|
||||
pub kind: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub initiator_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub expires_at: SqliteTimestamp,
|
||||
pub status: ProposalStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||
pub struct NewProposal {
|
||||
pub kind: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub initiator_id: i32,
|
||||
// status defaults to 'pending' at the DB layer
|
||||
pub expires_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct ProposalVote {
|
||||
pub id: i32,
|
||||
pub proposal_id: i32,
|
||||
pub operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
pub voted_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct NewProposalVote {
|
||||
pub proposal_id: i32,
|
||||
pub operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
|
||||
pub struct NewProposalResult {
|
||||
pub proposal_id: i32,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct NewRecoveryProposalVote {
|
||||
pub proposal_id: i32,
|
||||
pub recovery_operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
|
||||
pub struct NewRecoveryWakeupRequest {
|
||||
pub requested_by: i32,
|
||||
}
|
||||
@@ -152,6 +152,98 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
operator (id) {
|
||||
id -> Nullable<Integer>,
|
||||
share -> Binary,
|
||||
share_nonce -> Binary,
|
||||
share_salt -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
operator_identity (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal (id) {
|
||||
id -> Integer,
|
||||
kind -> Text,
|
||||
payload -> Binary,
|
||||
initiator_id -> Integer,
|
||||
created_at -> Integer,
|
||||
expires_at -> Integer,
|
||||
status -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal_result (proposal_id) {
|
||||
proposal_id -> Integer,
|
||||
data -> Binary,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_operator (id) {
|
||||
id -> Integer,
|
||||
share -> Binary,
|
||||
share_nonce -> Binary,
|
||||
share_salt -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_operator_identity (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_wakeup_request (id) {
|
||||
id -> Integer,
|
||||
requested_by -> Integer,
|
||||
requested_at -> Integer,
|
||||
cancelled_by -> Nullable<Integer>,
|
||||
cancelled_at -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_proposal_vote (id) {
|
||||
id -> Integer,
|
||||
proposal_id -> Integer,
|
||||
recovery_operator_id -> Integer,
|
||||
approve -> Bool,
|
||||
signature -> Binary,
|
||||
voted_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal_vote (id) {
|
||||
id -> Integer,
|
||||
proposal_id -> Integer,
|
||||
operator_id -> Integer,
|
||||
approve -> Bool,
|
||||
signature -> Binary,
|
||||
voted_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
program_client (id) {
|
||||
id -> Integer,
|
||||
@@ -185,15 +277,6 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
useragent_client (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
|
||||
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
|
||||
diesel::joinable!(arbiter_settings -> tls_history (tls_id));
|
||||
@@ -212,10 +295,24 @@ diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
|
||||
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
|
||||
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
|
||||
diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
||||
diesel::joinable!(operator -> operator_identity (id));
|
||||
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
||||
diesel::joinable!(proposal -> operator_identity (initiator_id));
|
||||
diesel::joinable!(proposal_result -> proposal (proposal_id));
|
||||
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
||||
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
|
||||
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
|
||||
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
|
||||
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
aead_encrypted,
|
||||
proposal_result,
|
||||
recovery_operator,
|
||||
recovery_operator_identity,
|
||||
recovery_wakeup_request,
|
||||
recovery_proposal_vote,
|
||||
arbiter_settings,
|
||||
client_metadata,
|
||||
client_metadata_history,
|
||||
@@ -230,8 +327,11 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
evm_wallet,
|
||||
evm_wallet_access,
|
||||
integrity_envelope,
|
||||
operator,
|
||||
operator_identity,
|
||||
program_client,
|
||||
proposal,
|
||||
proposal_vote,
|
||||
root_key_history,
|
||||
tls_history,
|
||||
useragent_client,
|
||||
);
|
||||
|
||||
@@ -179,24 +179,22 @@ impl Engine {
|
||||
}
|
||||
|
||||
if run_kind == RunKind::Execution {
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
let log_id: i32 = insert_into(evm_transaction_log::table)
|
||||
.values(&NewEvmTransactionLog {
|
||||
grant_id: grant.common_settings_id,
|
||||
wallet_access_id: context.target.id,
|
||||
chain_id: context.chain.into(),
|
||||
eth_value: utils::u256_to_bytes(context.value).to_vec(),
|
||||
signed_at: Utc::now().into(),
|
||||
})
|
||||
.returning(evm_transaction_log::id)
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
conn.transaction(async |conn| {
|
||||
let log_id: i32 = insert_into(evm_transaction_log::table)
|
||||
.values(&NewEvmTransactionLog {
|
||||
grant_id: grant.common_settings_id,
|
||||
wallet_access_id: context.target.id,
|
||||
chain_id: context.chain.into(),
|
||||
eth_value: utils::u256_to_bytes(context.value).to_vec(),
|
||||
signed_at: Utc::now().into(),
|
||||
})
|
||||
.returning(evm_transaction_log::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
|
||||
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?;
|
||||
|
||||
QueryResult::Ok(())
|
||||
})
|
||||
QueryResult::Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
@@ -222,54 +220,52 @@ impl Engine {
|
||||
let vault = self.vault.clone();
|
||||
|
||||
let id = conn
|
||||
.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
use schema::evm_basic_grant;
|
||||
.transaction(async |conn| {
|
||||
use schema::evm_basic_grant;
|
||||
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #86"
|
||||
)]
|
||||
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
|
||||
.values(&NewEvmBasicGrant {
|
||||
chain_id: full_grant.shared.chain.into(),
|
||||
wallet_access_id: full_grant.shared.wallet_access_id,
|
||||
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
|
||||
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
|
||||
max_gas_fee_per_gas: full_grant
|
||||
.shared
|
||||
.max_gas_fee_per_gas
|
||||
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
|
||||
max_priority_fee_per_gas: full_grant
|
||||
.shared
|
||||
.max_priority_fee_per_gas
|
||||
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
|
||||
rate_limit_count: full_grant
|
||||
.shared
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|rl| rl.count as i32),
|
||||
rate_limit_window_secs: full_grant
|
||||
.shared
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|rl| rl.window.num_seconds() as i32),
|
||||
revoked_at: None,
|
||||
})
|
||||
.returning(evm_basic_grant::all_columns)
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #86"
|
||||
)]
|
||||
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
|
||||
.values(&NewEvmBasicGrant {
|
||||
chain_id: full_grant.shared.chain.into(),
|
||||
wallet_access_id: full_grant.shared.wallet_access_id,
|
||||
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
|
||||
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
|
||||
max_gas_fee_per_gas: full_grant
|
||||
.shared
|
||||
.max_gas_fee_per_gas
|
||||
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
|
||||
max_priority_fee_per_gas: full_grant
|
||||
.shared
|
||||
.max_priority_fee_per_gas
|
||||
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
|
||||
rate_limit_count: full_grant
|
||||
.shared
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|rl| rl.count as i32),
|
||||
rate_limit_window_secs: full_grant
|
||||
.shared
|
||||
.rate_limit
|
||||
.as_ref()
|
||||
.map(|rl| rl.window.num_seconds() as i32),
|
||||
revoked_at: None,
|
||||
})
|
||||
.returning(evm_basic_grant::all_columns)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
|
||||
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?;
|
||||
|
||||
integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
|
||||
.await
|
||||
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id)
|
||||
.await
|
||||
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||
|
||||
QueryResult::Ok(basic_grant.id)
|
||||
})
|
||||
QueryResult::Ok(basic_grant.id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -363,7 +359,8 @@ mod tests {
|
||||
use crate::db::{
|
||||
self, DatabaseConnection,
|
||||
models::{
|
||||
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
||||
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
|
||||
SqliteTimestamp,
|
||||
},
|
||||
schema::{evm_basic_grant, evm_transaction_log},
|
||||
};
|
||||
@@ -381,7 +378,7 @@ mod tests {
|
||||
EvalContext {
|
||||
target: EvmWalletAccess {
|
||||
id: WALLET_ACCESS_ID,
|
||||
wallet_id: 10,
|
||||
wallet_id: EvmWalletId::from_raw(5),
|
||||
client_id: 20,
|
||||
created_at: SqliteTimestamp(Utc::now()),
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@ use crate::{
|
||||
db::{
|
||||
self, DatabaseConnection,
|
||||
models::{
|
||||
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
||||
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
|
||||
SqliteTimestamp,
|
||||
},
|
||||
schema::{evm_basic_grant, evm_transaction_log},
|
||||
},
|
||||
@@ -31,7 +32,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
|
||||
EvalContext {
|
||||
target: EvmWalletAccess {
|
||||
id: WALLET_ACCESS_ID,
|
||||
wallet_id: 10,
|
||||
wallet_id: EvmWalletId::from_raw(10),
|
||||
client_id: 20,
|
||||
created_at: SqliteTimestamp(Utc::now()),
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::{Settings, TokenTransfer};
|
||||
use crate::{
|
||||
db::{
|
||||
self, DatabaseConnection,
|
||||
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
|
||||
models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp},
|
||||
schema::evm_basic_grant,
|
||||
},
|
||||
evm::{
|
||||
@@ -45,7 +45,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
|
||||
EvalContext {
|
||||
target: EvmWalletAccess {
|
||||
id: WALLET_ACCESS_ID,
|
||||
wallet_id: 10,
|
||||
wallet_id: EvmWalletId::from_raw(10),
|
||||
client_id: 20,
|
||||
created_at: SqliteTimestamp(Utc::now()),
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ impl std::fmt::Debug for SafeSigner {
|
||||
/// Returns the protected key bytes and the derived Ethereum address.
|
||||
pub fn generate(rng: &mut impl rand::Rng) -> (SafeCell<[u8; 32]>, Address) {
|
||||
loop {
|
||||
let mut cell = SafeCell::new_inline(|w: &mut [u8; 32]| {
|
||||
let mut cell = SafeCell::new_inline_default(|w: &mut [u8; 32]| {
|
||||
rng.fill_bytes(w);
|
||||
});
|
||||
|
||||
|
||||
@@ -176,8 +176,8 @@ impl Convert for auth::Error {
|
||||
InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
|
||||
ApproveError(auth::ApproveError::Denied) => ProtoAuthResult::ApprovalDenied,
|
||||
ApproveError(auth::ApproveError::Upstream(
|
||||
crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected,
|
||||
)) => ProtoAuthResult::NoUserAgentsOnline,
|
||||
crate::actors::flow_coordinator::ApprovalError::NoOperatorsConnected,
|
||||
)) => ProtoAuthResult::NoOperatorsOnline,
|
||||
ApproveError(auth::ApproveError::Internal)
|
||||
| DatabasePoolUnavailable
|
||||
| DatabaseOperationFailed
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::peers::{client::ClientConnection, user_agent::UserAgentConnection};
|
||||
use crate::peers::{client::ClientConnection, operator::OperatorConnection};
|
||||
use arbiter_proto::{
|
||||
proto::{
|
||||
client::{ClientRequest, ClientResponse},
|
||||
user_agent::{UserAgentRequest, UserAgentResponse},
|
||||
operator::{OperatorRequest, OperatorResponse},
|
||||
},
|
||||
transport::grpc::GrpcBi,
|
||||
};
|
||||
@@ -14,7 +14,7 @@ use tracing::info;
|
||||
mod request_tracker;
|
||||
|
||||
pub mod client;
|
||||
pub mod user_agent;
|
||||
pub mod operator;
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub trait TryConvert {
|
||||
|
||||
#[async_trait]
|
||||
impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Server {
|
||||
type UserAgentStream = ReceiverStream<Result<UserAgentResponse, Status>>;
|
||||
type OperatorStream = ReceiverStream<Result<OperatorResponse, Status>>;
|
||||
type ClientStream = ReceiverStream<Result<ClientResponse, Status>>;
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
@@ -52,23 +52,23 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Ser
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
async fn user_agent(
|
||||
async fn operator(
|
||||
&self,
|
||||
request: Request<tonic::Streaming<UserAgentRequest>>,
|
||||
) -> Result<Response<Self::UserAgentStream>, Status> {
|
||||
request: Request<tonic::Streaming<OperatorRequest>>,
|
||||
) -> Result<Response<Self::OperatorStream>, Status> {
|
||||
let req_stream = request.into_inner();
|
||||
|
||||
let (bi, rx) = GrpcBi::from_bi_stream(req_stream);
|
||||
|
||||
tokio::spawn(user_agent::start(
|
||||
UserAgentConnection {
|
||||
tokio::spawn(operator::start(
|
||||
OperatorConnection {
|
||||
db: self.context.db.clone(),
|
||||
actors: self.context.actors.clone(),
|
||||
},
|
||||
bi,
|
||||
));
|
||||
|
||||
info!(event = "connection established", "grpc.user_agent");
|
||||
info!(event = "connection established", "grpc.operator");
|
||||
|
||||
Ok(Response::new(rx))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::{
|
||||
grpc::request_tracker::RequestTracker,
|
||||
peers::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
|
||||
peers::operator::{OutOfBand, OperatorConnection, OperatorSession},
|
||||
};
|
||||
use arbiter_proto::{
|
||||
proto::user_agent::{
|
||||
UserAgentRequest, UserAgentResponse,
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
proto::operator::{
|
||||
OperatorRequest, OperatorResponse,
|
||||
operator_request::Payload as OperatorRequestPayload,
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
},
|
||||
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
|
||||
};
|
||||
@@ -19,6 +19,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
mod auth;
|
||||
mod evm;
|
||||
mod governance;
|
||||
mod inbound;
|
||||
mod outbound;
|
||||
mod sdk_client;
|
||||
@@ -38,8 +39,8 @@ impl Sender<OutOfBand> for OutOfBandAdapter {
|
||||
}
|
||||
|
||||
async fn dispatch_loop(
|
||||
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>,
|
||||
actor: ActorRef<UserAgentSession>,
|
||||
mut bi: GrpcBi<OperatorRequest, OperatorResponse>,
|
||||
actor: ActorRef<OperatorSession>,
|
||||
mut receiver: mpsc::Receiver<OutOfBand>,
|
||||
mut request_tracker: RequestTracker,
|
||||
) {
|
||||
@@ -53,7 +54,7 @@ async fn dispatch_loop(
|
||||
|
||||
let payload = sdk_client::out_of_band_payload(oob);
|
||||
|
||||
if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() {
|
||||
if bi.send(Ok(OperatorResponse { id: None, payload: Some(payload) })).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +65,7 @@ async fn dispatch_loop(
|
||||
let conn = match message {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to receive user agent request");
|
||||
warn!(error = ?err, "Failed to receive operator request");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -78,13 +79,13 @@ async fn dispatch_loop(
|
||||
};
|
||||
|
||||
let Some(payload) = conn.payload else {
|
||||
let _ = bi.send(Err(Status::invalid_argument("Missing user-agent request payload"))).await;
|
||||
let _ = bi.send(Err(Status::invalid_argument("Missing operator request payload"))).await;
|
||||
return;
|
||||
};
|
||||
|
||||
match dispatch_inner(&actor, payload).await {
|
||||
Ok(Some(response)) => {
|
||||
if bi.send(Ok(UserAgentResponse {
|
||||
if bi.send(Ok(OperatorResponse {
|
||||
id: Some(request_id),
|
||||
payload: Some(response),
|
||||
})).await.is_err() {
|
||||
@@ -93,7 +94,7 @@ async fn dispatch_loop(
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(status) => {
|
||||
error!(?status, "Failed to process user agent request");
|
||||
error!(?status, "Failed to process operator request");
|
||||
let _ = bi.send(Err(status)).await;
|
||||
return;
|
||||
}
|
||||
@@ -104,23 +105,24 @@ async fn dispatch_loop(
|
||||
}
|
||||
|
||||
async fn dispatch_inner(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
payload: UserAgentRequestPayload,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
payload: OperatorRequestPayload,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
match payload {
|
||||
UserAgentRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
|
||||
UserAgentRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
|
||||
UserAgentRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
|
||||
UserAgentRequestPayload::Auth(..) => {
|
||||
warn!("Unsupported post-auth user agent auth request");
|
||||
Err(Status::invalid_argument("Unsupported user-agent request"))
|
||||
OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
|
||||
OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
|
||||
OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
|
||||
OperatorRequestPayload::Auth(..) => {
|
||||
warn!("Unsupported post-auth operator auth request");
|
||||
Err(Status::invalid_argument("Unsupported operator request"))
|
||||
}
|
||||
OperatorRequestPayload::Governance(req) => governance::dispatch(actor, req).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
mut conn: UserAgentConnection,
|
||||
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>,
|
||||
mut conn: OperatorConnection,
|
||||
mut bi: GrpcBi<OperatorRequest, OperatorResponse>,
|
||||
) {
|
||||
let mut request_tracker = RequestTracker::default();
|
||||
|
||||
@@ -129,16 +131,16 @@ pub async fn start(
|
||||
|
||||
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 {
|
||||
match crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await {
|
||||
Ok(actor) => actor,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "User agent connection failed");
|
||||
warn!(error = ?e, "Operator connection failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("User agent session established");
|
||||
info!("Operator session established");
|
||||
|
||||
dispatch_loop(bi, actor.clone(), oob_receiver, request_tracker).await;
|
||||
actor.kill();
|
||||
@@ -1,16 +1,16 @@
|
||||
use crate::{grpc::request_tracker::RequestTracker, peers::user_agent::auth};
|
||||
use crate::{grpc::request_tracker::RequestTracker, peers::operator::auth};
|
||||
use arbiter_crypto::authn;
|
||||
use arbiter_proto::{
|
||||
proto::user_agent::{
|
||||
UserAgentRequest, UserAgentResponse,
|
||||
proto::operator::{
|
||||
OperatorRequest, OperatorResponse,
|
||||
auth::{
|
||||
self as proto_auth, AuthChallenge as ProtoAuthChallenge,
|
||||
AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
|
||||
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
|
||||
},
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
operator_request::Payload as OperatorRequestPayload,
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
},
|
||||
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
|
||||
};
|
||||
@@ -20,13 +20,13 @@ use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
pub(super) struct AuthTransportAdapter<'a> {
|
||||
pub(super) bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
|
||||
pub(super) bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>,
|
||||
pub(super) request_tracker: &'a mut RequestTracker,
|
||||
}
|
||||
|
||||
impl<'a> AuthTransportAdapter<'a> {
|
||||
pub(super) const fn new(
|
||||
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
|
||||
bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>,
|
||||
request_tracker: &'a mut RequestTracker,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -35,7 +35,7 @@ impl<'a> AuthTransportAdapter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn bi_mut(&mut self) -> &mut GrpcBi<UserAgentRequest, UserAgentResponse> {
|
||||
pub(super) const fn bi_mut(&mut self) -> &mut GrpcBi<OperatorRequest, OperatorResponse> {
|
||||
self.bi
|
||||
}
|
||||
|
||||
@@ -45,21 +45,21 @@ impl<'a> AuthTransportAdapter<'a> {
|
||||
|
||||
pub(super) async fn send_response_payload(
|
||||
&mut self,
|
||||
payload: UserAgentResponsePayload,
|
||||
payload: OperatorResponsePayload,
|
||||
) -> Result<(), TransportError> {
|
||||
self.bi
|
||||
.send(Ok(UserAgentResponse {
|
||||
.send(Ok(OperatorResponse {
|
||||
id: Some(self.request_tracker.current_request_id()),
|
||||
payload: Some(payload),
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_user_agent_response(
|
||||
async fn send_operator_response(
|
||||
&mut self,
|
||||
payload: AuthResponsePayload,
|
||||
) -> Result<(), TransportError> {
|
||||
self.send_response_payload(UserAgentResponsePayload::Auth(proto_auth::Response {
|
||||
self.send_response_payload(OperatorResponsePayload::Auth(proto_auth::Response {
|
||||
payload: Some(payload),
|
||||
}))
|
||||
.await
|
||||
@@ -107,7 +107,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
|
||||
}
|
||||
};
|
||||
|
||||
self.send_user_agent_response(payload).await
|
||||
self.send_operator_response(payload).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
let request = match self.bi.recv().await? {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
warn!(error = ?error, "Failed to receive user agent auth request");
|
||||
warn!(error = ?error, "Failed to receive operator auth request");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -133,16 +133,16 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
let Some(payload) = request.payload else {
|
||||
warn!(
|
||||
event = "received request with empty payload",
|
||||
"grpc.useragent.auth_adapter"
|
||||
"grpc.operator.auth_adapter"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let UserAgentRequestPayload::Auth(auth_request) = payload else {
|
||||
let OperatorRequestPayload::Auth(auth_request) = payload else {
|
||||
let _ = self
|
||||
.bi
|
||||
.send(Err(Status::invalid_argument(
|
||||
"Unsupported user-agent auth request",
|
||||
"Unsupported operator auth request",
|
||||
)))
|
||||
.await;
|
||||
return None;
|
||||
@@ -151,7 +151,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
let Some(payload) = auth_request.payload else {
|
||||
warn!(
|
||||
event = "received auth request with empty payload",
|
||||
"grpc.useragent.auth_adapter"
|
||||
"grpc.operator.auth_adapter"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
@@ -164,7 +164,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else {
|
||||
warn!(
|
||||
event = "received request with invalid public key",
|
||||
"grpc.useragent.auth_adapter"
|
||||
"grpc.operator.auth_adapter"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
@@ -3,8 +3,8 @@ use crate::{
|
||||
Convert, TryConvert,
|
||||
common::inbound::{RawEvmAddress, RawEvmTransaction},
|
||||
},
|
||||
peers::user_agent::{
|
||||
UserAgentSession,
|
||||
peers::operator::{
|
||||
OperatorSession,
|
||||
session::handlers::{
|
||||
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
|
||||
HandleGrantDelete, HandleGrantList, HandleSignTransaction,
|
||||
@@ -24,12 +24,12 @@ use arbiter_proto::proto::{
|
||||
wallet_create_response::Result as WalletCreateResult,
|
||||
wallet_list_response::Result as WalletListResult,
|
||||
},
|
||||
user_agent::{
|
||||
operator::{
|
||||
evm::{
|
||||
self as proto_evm, SignTransactionRequest as ProtoSignTransactionRequest,
|
||||
request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload,
|
||||
},
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,16 +37,16 @@ use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::Evm(proto_evm::Response {
|
||||
const fn wrap_evm_response(payload: EvmResponsePayload) -> OperatorResponsePayload {
|
||||
OperatorResponsePayload::Evm(proto_evm::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_evm::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing EVM request payload"));
|
||||
};
|
||||
@@ -62,8 +62,8 @@ pub(super) async fn dispatch(
|
||||
}
|
||||
|
||||
async fn handle_wallet_create(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleEvmWalletCreate {}).await {
|
||||
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
|
||||
id: wallet_id,
|
||||
@@ -82,15 +82,15 @@ async fn handle_wallet_create(
|
||||
}
|
||||
|
||||
async fn handle_wallet_list(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleEvmWalletList {}).await {
|
||||
Ok(wallets) => WalletListResult::Wallets(WalletList {
|
||||
wallets: wallets
|
||||
.into_iter()
|
||||
.map(|(id, address)| WalletEntry {
|
||||
address: address.to_vec(),
|
||||
id,
|
||||
id: id.to_raw(),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
@@ -107,8 +107,8 @@ async fn handle_wallet_list(
|
||||
}
|
||||
|
||||
async fn handle_grant_list(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleGrantList {}).await {
|
||||
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
|
||||
grants: grants
|
||||
@@ -134,9 +134,9 @@ async fn handle_grant_list(
|
||||
}
|
||||
|
||||
async fn handle_grant_create(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: EvmGrantCreateRequest,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let basic = req
|
||||
.shared
|
||||
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
|
||||
@@ -164,9 +164,9 @@ async fn handle_grant_create(
|
||||
}
|
||||
|
||||
async fn handle_grant_delete(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: EvmGrantDeleteRequest,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = match actor
|
||||
.ask(HandleGrantDelete {
|
||||
grant_id: req.grant_id,
|
||||
@@ -190,9 +190,9 @@ async fn handle_grant_delete(
|
||||
}
|
||||
|
||||
async fn handle_sign_transaction(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: ProtoSignTransactionRequest,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let request = req
|
||||
.request
|
||||
.ok_or_else(|| Status::invalid_argument("Missing sign transaction request"))?;
|
||||
155
server/crates/arbiter-server/src/grpc/operator/governance.rs
Normal file
155
server/crates/arbiter-server/src/grpc/operator/governance.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use crate::{
|
||||
actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome},
|
||||
peers::operator::{
|
||||
OperatorSession,
|
||||
session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending},
|
||||
},
|
||||
};
|
||||
use arbiter_proto::proto::operator::{
|
||||
governance::{
|
||||
self as proto_gov, CreateProposalRequest, QueryPendingRequest, QueryPendingResponse,
|
||||
VoteOutcome as ProtoVoteOutcome, create_proposal_request::Kind as ProtoKind,
|
||||
request::Payload as GovRequestPayload, response::Payload as GovResponsePayload,
|
||||
},
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
};
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap(payload: GovResponsePayload) -> OperatorResponsePayload {
|
||||
OperatorResponsePayload::Governance(proto_gov::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_gov::Request,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument(
|
||||
"Missing governance request payload",
|
||||
));
|
||||
};
|
||||
|
||||
match payload {
|
||||
GovRequestPayload::Create(req) => handle_create(actor, req).await,
|
||||
GovRequestPayload::Vote(req) => handle_vote(actor, req).await,
|
||||
GovRequestPayload::Query(QueryPendingRequest {}) => handle_query(actor).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_create(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: CreateProposalRequest,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let kind = match req.kind {
|
||||
Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient {
|
||||
client_id: p.client_id,
|
||||
},
|
||||
Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess {
|
||||
wallet_id: p.wallet_id,
|
||||
client_id: p.client_id,
|
||||
},
|
||||
Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate,
|
||||
Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator {
|
||||
old_operator_id: p.old_operator_id,
|
||||
new_pubkey: p.new_pubkey,
|
||||
},
|
||||
Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "new_n is always a small operator count"
|
||||
)]
|
||||
new_n: p.new_n as u8,
|
||||
},
|
||||
Some(ProtoKind::ApprovePersistentGrant(p)) => {
|
||||
use prost::Message as _;
|
||||
ProposalKind::ApprovePersistentGrant {
|
||||
payload_bytes: p.encode_to_vec(),
|
||||
}
|
||||
}
|
||||
Some(ProtoKind::ApproveOneOffTransaction(p)) => {
|
||||
use prost::Message as _;
|
||||
ProposalKind::ApproveOneOffTransaction {
|
||||
payload_bytes: p.encode_to_vec(),
|
||||
}
|
||||
}
|
||||
None => return Err(Status::invalid_argument("Missing proposal kind")),
|
||||
};
|
||||
let ttl_secs = req.ttl_secs.map(i64::from);
|
||||
|
||||
let proposal_id = actor
|
||||
.ask(HandleCreateProposal { kind, ttl_secs })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(?e, "create_proposal failed");
|
||||
Status::internal("Failed to create proposal")
|
||||
})?;
|
||||
|
||||
Ok(Some(wrap(GovResponsePayload::Created(
|
||||
proto_gov::CreateProposalResponse { proposal_id },
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_vote(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_gov::CastVoteRequest,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = actor
|
||||
.ask(HandleCastVote {
|
||||
proposal_id: req.proposal_id,
|
||||
approve: req.approve,
|
||||
signature: req.signature,
|
||||
})
|
||||
.await;
|
||||
|
||||
let outcome = match result {
|
||||
Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending,
|
||||
Ok(VoteOutcome::QuorumApproved) => ProtoVoteOutcome::Approved,
|
||||
Ok(VoteOutcome::QuorumRejected) => ProtoVoteOutcome::Rejected,
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => {
|
||||
return Err(Status::invalid_argument("Already voted on this proposal"));
|
||||
}
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) => {
|
||||
return Err(Status::invalid_argument("Invalid vote signature"));
|
||||
}
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => {
|
||||
return Err(Status::not_found("Proposal not found"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(?e, "cast_vote failed");
|
||||
return Err(Status::internal("Failed to cast vote"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(wrap(GovResponsePayload::Voted(
|
||||
proto_gov::VoteResponse {
|
||||
outcome: outcome.into(),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_query(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let summaries = actor.ask(HandleQueryPending {}).await.unwrap_or_default();
|
||||
|
||||
let proposals = summaries
|
||||
.into_iter()
|
||||
.map(|s| proto_gov::ProposalSummary {
|
||||
id: s.id,
|
||||
kind: s.kind,
|
||||
initiator_id: s.initiator_id,
|
||||
expires_at: s.expires_at.0.timestamp(),
|
||||
approve_count: s.approve_count,
|
||||
reject_count: s.reject_count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Some(wrap(GovResponsePayload::Pending(
|
||||
QueryPendingResponse { proposals },
|
||||
))))
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::{
|
||||
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
|
||||
db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess},
|
||||
evm::policies::{
|
||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
|
||||
token_transfers,
|
||||
},
|
||||
grpc::Convert,
|
||||
grpc::TryConvert,
|
||||
grpc::{Convert, TryConvert},
|
||||
};
|
||||
use arbiter_proto::{
|
||||
proto::evm::{
|
||||
@@ -14,7 +13,7 @@ use arbiter_proto::{
|
||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||
specific_grant::Grant as ProtoSpecificGrantType,
|
||||
},
|
||||
proto::user_agent::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
|
||||
proto::operator::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
|
||||
};
|
||||
|
||||
use alloy::primitives::{Address, U256};
|
||||
@@ -150,7 +149,7 @@ impl Convert for WalletAccess {
|
||||
|
||||
fn convert(self) -> Self::Output {
|
||||
NewEvmWalletAccess {
|
||||
wallet_id: self.wallet_id,
|
||||
wallet_id: EvmWalletId::from_raw(self.wallet_id),
|
||||
client_id: self.sdk_client_id,
|
||||
}
|
||||
}
|
||||
@@ -165,7 +164,7 @@ impl TryConvert for SdkClientWalletAccess {
|
||||
return Err(Status::invalid_argument("Missing wallet access entry"));
|
||||
};
|
||||
Ok(CoreEvmWalletAccess {
|
||||
wallet_id: access.wallet_id,
|
||||
wallet_id: EvmWalletId::from_raw(access.wallet_id),
|
||||
client_id: access.sdk_client_id,
|
||||
id: self.id,
|
||||
})
|
||||
@@ -10,7 +10,7 @@ use arbiter_proto::proto::{
|
||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||
specific_grant::Grant as ProtoSpecificGrantType,
|
||||
},
|
||||
user_agent::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
|
||||
operator::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -103,7 +103,7 @@ impl Convert for EvmWalletAccess {
|
||||
Self::Output {
|
||||
id: self.id,
|
||||
access: Some(WalletAccess {
|
||||
wallet_id: self.wallet_id,
|
||||
wallet_id: self.wallet_id.to_raw(),
|
||||
sdk_client_id: self.client_id,
|
||||
}),
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::{
|
||||
db::models::NewEvmWalletAccess,
|
||||
grpc::Convert,
|
||||
peers::user_agent::{
|
||||
OutOfBand, UserAgentSession,
|
||||
peers::operator::{
|
||||
OperatorSession, OutOfBand,
|
||||
session::handlers::{
|
||||
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
|
||||
HandleRevokeEvmWalletAccess, HandleSdkClientList,
|
||||
@@ -11,8 +11,8 @@ use crate::{
|
||||
};
|
||||
use arbiter_crypto::authn;
|
||||
use arbiter_proto::proto::{
|
||||
shared::ClientInfo as ProtoClientMetadata,
|
||||
user_agent::{
|
||||
operator::{
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
sdk_client::{
|
||||
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
|
||||
ConnectionRequest as ProtoSdkClientConnectionRequest,
|
||||
@@ -24,21 +24,21 @@ use arbiter_proto::proto::{
|
||||
request::Payload as SdkClientRequestPayload,
|
||||
response::Payload as SdkClientResponsePayload,
|
||||
},
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
},
|
||||
shared::ClientInfo as ProtoClientMetadata,
|
||||
};
|
||||
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::SdkClient(proto_sdk_client::Response {
|
||||
const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> OperatorResponsePayload {
|
||||
OperatorResponsePayload::SdkClient(proto_sdk_client::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
|
||||
pub(super) fn out_of_band_payload(oob: OutOfBand) -> OperatorResponsePayload {
|
||||
match oob {
|
||||
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
|
||||
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
|
||||
@@ -59,9 +59,9 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_sdk_client::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument(
|
||||
"Missing SDK client request payload",
|
||||
@@ -87,9 +87,9 @@ pub(super) async fn dispatch(
|
||||
}
|
||||
|
||||
async fn handle_connection_response(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
resp: ProtoSdkClientConnectionResponse,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice())
|
||||
.map_err(|()| Status::invalid_argument("Invalid ML-DSA public key"))?;
|
||||
|
||||
@@ -108,14 +108,14 @@ async fn handle_connection_response(
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleSdkClientList {}).await {
|
||||
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
|
||||
clients: clients
|
||||
.into_iter()
|
||||
.map(|(client, metadata)| ProtoSdkClientEntry {
|
||||
id: client.id,
|
||||
id: client.id.to_raw(),
|
||||
pubkey: client.public_key.clone(),
|
||||
info: Some(ProtoClientMetadata {
|
||||
name: metadata.name,
|
||||
@@ -144,9 +144,9 @@ async fn handle_list(
|
||||
}
|
||||
|
||||
async fn handle_grant_wallet_access(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: ProtoSdkClientGrantWalletAccess,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(Convert::convert).collect();
|
||||
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
||||
Ok(()) => {
|
||||
@@ -161,9 +161,9 @@ async fn handle_grant_wallet_access(
|
||||
}
|
||||
|
||||
async fn handle_revoke_wallet_access(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: ProtoSdkClientRevokeWalletAccess,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
match actor
|
||||
.ask(HandleRevokeEvmWalletAccess {
|
||||
entries: req.accesses,
|
||||
@@ -182,8 +182,8 @@ async fn handle_revoke_wallet_access(
|
||||
}
|
||||
|
||||
async fn handle_list_wallet_access(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
match actor.ask(HandleListWalletAccess {}).await {
|
||||
Ok(accesses) => Ok(Some(wrap_sdk_client_response(
|
||||
SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse {
|
||||
113
server/crates/arbiter-server/src/grpc/operator/vault.rs
Normal file
113
server/crates/arbiter-server/src/grpc/operator/vault.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use crate::{
|
||||
actors::vault::VaultState,
|
||||
peers::operator::{
|
||||
OperatorSession,
|
||||
session::handlers::{
|
||||
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
|
||||
HandleQueryVaultState,
|
||||
},
|
||||
},
|
||||
};
|
||||
use arbiter_proto::{
|
||||
proto::operator::{
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
vault::{
|
||||
self as proto_vault,
|
||||
rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult},
|
||||
request::Payload as VaultRequestPayload,
|
||||
response::Payload as VaultResponsePayload,
|
||||
},
|
||||
},
|
||||
proto::shared::VaultState as ProtoVaultState,
|
||||
};
|
||||
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
|
||||
OperatorResponsePayload::Vault(proto_vault::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_vault::Request,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing vault request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
|
||||
VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await,
|
||||
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
|
||||
Err(Status::permission_denied(
|
||||
"Vault is already unsealed; unseal/bootstrap not permitted in session",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_rekey(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_rekey::Request,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
use arbiter_proto::proto::operator::vault::rekey::request::Payload as RekeyPayload;
|
||||
|
||||
let payload = req
|
||||
.payload
|
||||
.ok_or_else(|| Status::invalid_argument("Missing rekey payload"))?;
|
||||
|
||||
let done: bool = match payload {
|
||||
RekeyPayload::ContributePassphrase(cp) => actor
|
||||
.ask(HandleContributeRekeyPassphrase {
|
||||
passphrase: cp.passphrase,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(?e, "rekey passphrase contribution failed");
|
||||
Status::internal("Rekey contribution failed")
|
||||
})?,
|
||||
RekeyPayload::ContributeRecoveryPassphrase(crp) => actor
|
||||
.ask(HandleContributeRecoveryRekeyPassphrase {
|
||||
recovery_operator_id: crp.recovery_operator_id,
|
||||
passphrase: crp.passphrase,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(?e, "rekey recovery passphrase contribution failed");
|
||||
Status::internal("Rekey recovery contribution failed")
|
||||
})?,
|
||||
};
|
||||
|
||||
let proto_result = if done {
|
||||
ProtoRekeyResult::Success
|
||||
} else {
|
||||
ProtoRekeyResult::AwaitingContributions
|
||||
};
|
||||
|
||||
Ok(Some(wrap_vault_response(VaultResponsePayload::Rekey(
|
||||
proto_rekey::Response {
|
||||
result: proto_result.into(),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_query_vault_state(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let state = match actor.ask(HandleQueryVaultState {}).await {
|
||||
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
||||
Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
|
||||
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to query vault state");
|
||||
ProtoVaultState::Error
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_vault_response(VaultResponsePayload::State(
|
||||
state.into(),
|
||||
))))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::auth::AuthTransportAdapter;
|
||||
use crate::{
|
||||
grpc::TryConvert,
|
||||
peers::user_agent::vault_gate::{self as vault_gate},
|
||||
peers::operator::vault_gate::{self as vault_gate},
|
||||
};
|
||||
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
|
||||
|
||||
@@ -20,7 +20,7 @@ impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
|
||||
Err(error) => {
|
||||
warn!(
|
||||
?error,
|
||||
"Failed to receive user agent request during vault gate"
|
||||
"Failed to receive operator request during vault gate"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
use crate::{
|
||||
grpc::{Convert, TryConvert},
|
||||
peers::user_agent::vault_gate::{
|
||||
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
|
||||
peers::operator::vault_gate::{
|
||||
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
|
||||
HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase,
|
||||
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
|
||||
HandleUnsealEncryptedKey,
|
||||
},
|
||||
};
|
||||
use arbiter_proto::proto::user_agent::{
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
use arbiter_proto::proto::operator::{
|
||||
operator_request::Payload as OperatorRequestPayload,
|
||||
vault::{
|
||||
self as proto_vault,
|
||||
bootstrap::{self as proto_bootstrap},
|
||||
bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload},
|
||||
request::Payload as VaultRequestPayload,
|
||||
unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload},
|
||||
},
|
||||
@@ -16,7 +19,7 @@ use arbiter_proto::proto::user_agent::{
|
||||
|
||||
use tonic::Status;
|
||||
|
||||
impl TryConvert for UserAgentRequestPayload {
|
||||
impl TryConvert for OperatorRequestPayload {
|
||||
type Output = vault_gate::Inbound;
|
||||
type Error = Status;
|
||||
|
||||
@@ -50,6 +53,9 @@ impl TryConvert for VaultRequestPayload {
|
||||
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
|
||||
Self::Unseal(req) => req.try_convert(),
|
||||
Self::Bootstrap(req) => req.try_convert(),
|
||||
Self::Rekey(_) => Err(Status::permission_denied(
|
||||
"Rekey requires an authenticated session",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +79,21 @@ impl TryConvert for UnsealRequestPayload {
|
||||
match self {
|
||||
Self::Start(start) => start.try_convert(),
|
||||
Self::EncryptedKey(key) => Ok(key.convert()),
|
||||
Self::ContributePassphrase(cp) => Ok(
|
||||
vault_gate::Inbound::HandleContributeUnsealPassphrase(
|
||||
HandleContributeUnsealPassphrase {
|
||||
passphrase: cp.passphrase,
|
||||
},
|
||||
),
|
||||
),
|
||||
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
||||
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
|
||||
HandleContributeRecoveryUnsealPassphrase {
|
||||
recovery_operator_id: crp.recovery_operator_id,
|
||||
passphrase: crp.passphrase,
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,12 +128,44 @@ impl TryConvert for proto_bootstrap::Request {
|
||||
type Error = Status;
|
||||
|
||||
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
|
||||
self.encrypted_key
|
||||
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?
|
||||
self.payload
|
||||
.ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))?
|
||||
.try_convert()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryConvert for BootstrapRequestPayload {
|
||||
type Output = vault_gate::Inbound;
|
||||
type Error = Status;
|
||||
|
||||
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
|
||||
match self {
|
||||
Self::EncryptedKey(key) => key.try_convert(),
|
||||
Self::DeclareCommittee(dc) => Ok(
|
||||
vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
|
||||
count: dc.count as usize,
|
||||
recovery_count: dc.recovery_count as usize,
|
||||
}),
|
||||
),
|
||||
Self::ContributePassphrase(cp) => Ok(
|
||||
vault_gate::Inbound::HandleContributeBootstrapPassphrase(
|
||||
HandleContributeBootstrapPassphrase {
|
||||
passphrase: cp.passphrase,
|
||||
},
|
||||
),
|
||||
),
|
||||
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
||||
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
|
||||
HandleContributeRecoveryBootstrapPassphrase {
|
||||
recovery_operator_id: crp.recovery_operator_id,
|
||||
passphrase: crp.passphrase,
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryConvert for proto_bootstrap::BootstrapEncryptedKey {
|
||||
type Output = vault_gate::Inbound;
|
||||
type Error = Status;
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::{
|
||||
actors::vault::VaultState,
|
||||
grpc::{Convert, TryConvert},
|
||||
peers::operator::vault_gate::{self as vault_gate},
|
||||
};
|
||||
use arbiter_proto::proto::{
|
||||
operator::{
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
vault::{
|
||||
self as proto_vault,
|
||||
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
|
||||
response::Payload as VaultResponsePayload,
|
||||
unseal::{
|
||||
self as proto_unseal, UnsealResult as ProtoUnsealResult,
|
||||
response::Payload as UnsealResponsePayload,
|
||||
},
|
||||
},
|
||||
},
|
||||
shared::VaultState as ProtoVaultState,
|
||||
};
|
||||
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
|
||||
OperatorResponsePayload::Vault(proto_vault::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
const fn wrap_unseal_response(payload: UnsealResponsePayload) -> OperatorResponsePayload {
|
||||
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
|
||||
payload: Some(payload),
|
||||
}))
|
||||
}
|
||||
|
||||
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload {
|
||||
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
|
||||
result: result.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
impl Convert for VaultState {
|
||||
type Output = OperatorResponsePayload;
|
||||
|
||||
fn convert(self) -> OperatorResponsePayload {
|
||||
let proto_state = match self {
|
||||
Self::Unbootstrapped => ProtoVaultState::Unbootstrapped,
|
||||
Self::Sealed => ProtoVaultState::Sealed,
|
||||
Self::Unsealed => ProtoVaultState::Unsealed,
|
||||
};
|
||||
wrap_vault_response(VaultResponsePayload::State(proto_state.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Convert for vault_gate::HandshakeResponse {
|
||||
type Output = OperatorResponsePayload;
|
||||
|
||||
fn convert(self) -> OperatorResponsePayload {
|
||||
wrap_unseal_response(UnsealResponsePayload::Start(
|
||||
proto_unseal::UnsealStartResponse {
|
||||
server_pubkey: self.server_pubkey.as_bytes().to_vec(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryConvert for vault_gate::Outbound {
|
||||
type Output = OperatorResponsePayload;
|
||||
type Error = Status;
|
||||
|
||||
fn try_convert(self) -> Result<OperatorResponsePayload, Status> {
|
||||
match self {
|
||||
Self::HandleVaultState(result) => result
|
||||
.map_err(|err| {
|
||||
warn!(?err, "vault state query failed");
|
||||
Status::internal("Failed to query vault state")
|
||||
})
|
||||
.map(VaultState::convert),
|
||||
Self::HandleHandshake(result) => result
|
||||
.map_err(|err| {
|
||||
warn!(?err, "handshake failed");
|
||||
Status::internal("Failed to start unseal flow")
|
||||
})
|
||||
.map(vault_gate::HandshakeResponse::convert),
|
||||
Self::HandleUnsealEncryptedKey(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(()) => ProtoUnsealResult::Success,
|
||||
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
|
||||
Err(err) => {
|
||||
warn!(?err, "unseal failed");
|
||||
return Err(Status::internal("Failed to unseal vault"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
|
||||
proto_result.into(),
|
||||
)))
|
||||
}
|
||||
Self::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 Err(Status::internal("Failed to bootstrap vault"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_bootstrap_response(proto_result))
|
||||
}
|
||||
Self::HandleDeclareCommittee(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(()) => ProtoBootstrapResult::Success,
|
||||
Err(err) => {
|
||||
warn!(?err, "declare committee failed");
|
||||
return Err(Status::internal("Failed to declare committee"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_bootstrap_response(proto_result))
|
||||
}
|
||||
Self::HandleContributeBootstrapPassphrase(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(true) => ProtoBootstrapResult::Success,
|
||||
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
||||
Err(err) => {
|
||||
warn!(?err, "contribute bootstrap passphrase failed");
|
||||
return Err(Status::internal("Failed to contribute bootstrap passphrase"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_bootstrap_response(proto_result))
|
||||
}
|
||||
Self::HandleContributeRecoveryBootstrapPassphrase(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(true) => ProtoBootstrapResult::Success,
|
||||
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
||||
Err(err) => {
|
||||
warn!(?err, "contribute recovery bootstrap passphrase failed");
|
||||
return Err(Status::internal(
|
||||
"Failed to contribute recovery bootstrap passphrase",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(wrap_bootstrap_response(proto_result))
|
||||
}
|
||||
Self::HandleContributeUnsealPassphrase(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(true) => ProtoUnsealResult::Success,
|
||||
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
||||
Err(err) => {
|
||||
warn!(?err, "contribute unseal passphrase failed");
|
||||
return Err(Status::internal("Failed to contribute unseal passphrase"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
|
||||
proto_result.into(),
|
||||
)))
|
||||
}
|
||||
Self::HandleContributeRecoveryUnsealPassphrase(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(true) => ProtoUnsealResult::Success,
|
||||
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
||||
Err(err) => {
|
||||
warn!(?err, "contribute recovery unseal passphrase failed");
|
||||
return Err(Status::internal(
|
||||
"Failed to contribute recovery unseal passphrase",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
|
||||
proto_result.into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use crate::{
|
||||
actors::vault::VaultState,
|
||||
peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState},
|
||||
};
|
||||
use arbiter_proto::{
|
||||
proto::shared::VaultState as ProtoVaultState,
|
||||
proto::user_agent::{
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
vault::{
|
||||
self as proto_vault, request::Payload as VaultRequestPayload,
|
||||
response::Payload as VaultResponsePayload,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::Vault(proto_vault::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: proto_vault::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing vault request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
|
||||
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
|
||||
Err(Status::permission_denied(
|
||||
"Vault is already unsealed; unseal/bootstrap not permitted in session",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_query_vault_state(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let state = match actor.ask(HandleQueryVaultState {}).await {
|
||||
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
||||
Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
|
||||
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to query vault state");
|
||||
ProtoVaultState::Error
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_vault_response(VaultResponsePayload::State(
|
||||
state.into(),
|
||||
))))
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
use crate::{
|
||||
actors::vault::VaultState,
|
||||
grpc::{Convert, TryConvert},
|
||||
peers::user_agent::vault_gate::{self as vault_gate},
|
||||
};
|
||||
use arbiter_proto::proto::{
|
||||
shared::VaultState as ProtoVaultState,
|
||||
user_agent::{
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
vault::{
|
||||
self as proto_vault,
|
||||
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
|
||||
response::Payload as VaultResponsePayload,
|
||||
unseal::{
|
||||
self as proto_unseal, UnsealResult as ProtoUnsealResult,
|
||||
response::Payload as UnsealResponsePayload,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::Vault(proto_vault::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
const 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 Convert for VaultState {
|
||||
type Output = UserAgentResponsePayload;
|
||||
|
||||
fn convert(self) -> UserAgentResponsePayload {
|
||||
let proto_state = match self {
|
||||
Self::Unbootstrapped => ProtoVaultState::Unbootstrapped,
|
||||
Self::Sealed => ProtoVaultState::Sealed,
|
||||
Self::Unsealed => ProtoVaultState::Unsealed,
|
||||
};
|
||||
wrap_vault_response(VaultResponsePayload::State(proto_state.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Convert for vault_gate::HandshakeResponse {
|
||||
type Output = UserAgentResponsePayload;
|
||||
|
||||
fn convert(self) -> UserAgentResponsePayload {
|
||||
wrap_unseal_response(UnsealResponsePayload::Start(
|
||||
proto_unseal::UnsealStartResponse {
|
||||
server_pubkey: self.server_pubkey.as_bytes().to_vec(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryConvert for vault_gate::Outbound {
|
||||
type Output = UserAgentResponsePayload;
|
||||
type Error = Status;
|
||||
|
||||
fn try_convert(self) -> Result<UserAgentResponsePayload, Status> {
|
||||
match self {
|
||||
Self::HandleVaultState(result) => result
|
||||
.map_err(|err| {
|
||||
warn!(?err, "vault state query failed");
|
||||
Status::internal("Failed to query vault state")
|
||||
})
|
||||
.map(VaultState::convert),
|
||||
Self::HandleHandshake(result) => result
|
||||
.map_err(|err| {
|
||||
warn!(?err, "handshake failed");
|
||||
Status::internal("Failed to start unseal flow")
|
||||
})
|
||||
.map(vault_gate::HandshakeResponse::convert),
|
||||
Self::HandleUnsealEncryptedKey(result) => {
|
||||
let proto_result = match result {
|
||||
Ok(()) => ProtoUnsealResult::Success,
|
||||
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
|
||||
Err(err) => {
|
||||
warn!(?err, "unseal failed");
|
||||
return Err(Status::internal("Failed to unseal vault"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
|
||||
proto_result.into(),
|
||||
)))
|
||||
}
|
||||
Self::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 Err(Status::internal("Failed to bootstrap vault"));
|
||||
}
|
||||
};
|
||||
Ok(wrap_bootstrap_response(proto_result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl From<diesel::result::Error> for Error {
|
||||
pub enum ApproveError {
|
||||
#[error("Internal error")]
|
||||
Internal,
|
||||
#[error("Client connection denied by user agents")]
|
||||
#[error("Client connection denied by operators")]
|
||||
Denied,
|
||||
#[error("Upstream error: {0}")]
|
||||
Upstream(flow_coordinator::ApprovalError),
|
||||
@@ -171,46 +171,42 @@ async fn insert_client(
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
|
||||
conn.exclusive_transaction(|conn| {
|
||||
let vault = vault.clone();
|
||||
let pubkey = pubkey.clone();
|
||||
Box::pin(async move {
|
||||
let metadata_id = insert_into(client_metadata::table)
|
||||
.values((
|
||||
client_metadata::name.eq(&metadata.name),
|
||||
client_metadata::description.eq(&metadata.description),
|
||||
client_metadata::version.eq(&metadata.version),
|
||||
))
|
||||
.returning(client_metadata::id)
|
||||
.get_result::<i32>(conn)
|
||||
.await?;
|
||||
conn.exclusive_transaction(async |conn| {
|
||||
let metadata_id = insert_into(client_metadata::table)
|
||||
.values((
|
||||
client_metadata::name.eq(&metadata.name),
|
||||
client_metadata::description.eq(&metadata.description),
|
||||
client_metadata::version.eq(&metadata.version),
|
||||
))
|
||||
.returning(client_metadata::id)
|
||||
.get_result::<i32>(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let client_id = insert_into(program_client::table)
|
||||
.values((
|
||||
program_client::public_key.eq(pubkey.to_bytes()),
|
||||
program_client::metadata_id.eq(metadata_id),
|
||||
))
|
||||
.on_conflict_do_nothing()
|
||||
.returning(program_client::id)
|
||||
.get_result::<i32>(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),
|
||||
))
|
||||
.on_conflict_do_nothing()
|
||||
.returning(program_client::id)
|
||||
.get_result::<i32>(&mut *conn)
|
||||
.await?;
|
||||
|
||||
integrity::sign_entity(
|
||||
conn,
|
||||
&vault,
|
||||
&ClientCredentials {
|
||||
pubkey: pubkey.clone(),
|
||||
},
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Failed to sign integrity tag for new client key");
|
||||
Error::DatabaseOperationFailed
|
||||
})?;
|
||||
integrity::sign_entity(
|
||||
&mut *conn,
|
||||
vault,
|
||||
&ClientCredentials {
|
||||
pubkey: pubkey.clone(),
|
||||
},
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Failed to sign integrity tag for new client key");
|
||||
Error::DatabaseOperationFailed
|
||||
})?;
|
||||
|
||||
Ok(client_id)
|
||||
})
|
||||
Ok(client_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -229,55 +225,51 @@ async fn sync_client_metadata(
|
||||
Error::DatabasePoolUnavailable
|
||||
})?;
|
||||
|
||||
conn.exclusive_transaction(|conn| {
|
||||
let metadata = metadata.clone();
|
||||
Box::pin(async move {
|
||||
let (current_metadata_id, current): (i32, ProgramClientMetadata) =
|
||||
program_client::table
|
||||
.find(client_id)
|
||||
.inner_join(client_metadata::table)
|
||||
.select((
|
||||
program_client::metadata_id,
|
||||
ProgramClientMetadata::as_select(),
|
||||
))
|
||||
.first(conn)
|
||||
.await?;
|
||||
conn.exclusive_transaction(async |conn| {
|
||||
let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table
|
||||
.find(client_id)
|
||||
.inner_join(client_metadata::table)
|
||||
.select((
|
||||
program_client::metadata_id,
|
||||
ProgramClientMetadata::as_select(),
|
||||
))
|
||||
.first(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let unchanged = current.name == metadata.name
|
||||
&& current.description == metadata.description
|
||||
&& current.version == metadata.version;
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
let unchanged = current.name == metadata.name
|
||||
&& current.description == metadata.description
|
||||
&& current.version == metadata.version;
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
insert_into(client_metadata_history::table)
|
||||
.values((
|
||||
client_metadata_history::metadata_id.eq(current_metadata_id),
|
||||
client_metadata_history::client_id.eq(client_id),
|
||||
))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
insert_into(client_metadata_history::table)
|
||||
.values((
|
||||
client_metadata_history::metadata_id.eq(current_metadata_id),
|
||||
client_metadata_history::client_id.eq(client_id),
|
||||
))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let metadata_id = insert_into(client_metadata::table)
|
||||
.values((
|
||||
client_metadata::name.eq(&metadata.name),
|
||||
client_metadata::description.eq(&metadata.description),
|
||||
client_metadata::version.eq(&metadata.version),
|
||||
))
|
||||
.returning(client_metadata::id)
|
||||
.get_result::<i32>(conn)
|
||||
.await?;
|
||||
let metadata_id = insert_into(client_metadata::table)
|
||||
.values((
|
||||
client_metadata::name.eq(&metadata.name),
|
||||
client_metadata::description.eq(&metadata.description),
|
||||
client_metadata::version.eq(&metadata.version),
|
||||
))
|
||||
.returning(client_metadata::id)
|
||||
.get_result::<i32>(&mut *conn)
|
||||
.await?;
|
||||
|
||||
update(program_client::table.find(client_id))
|
||||
.set((
|
||||
program_client::metadata_id.eq(metadata_id),
|
||||
program_client::updated_at.eq(now),
|
||||
))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
update(program_client::table.find(client_id))
|
||||
.set((
|
||||
program_client::metadata_id.eq(metadata_id),
|
||||
program_client::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok::<(), diesel::result::Error>(())
|
||||
})
|
||||
Ok::<(), diesel::result::Error>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -306,7 +298,7 @@ where
|
||||
|
||||
let signature = expect_message(transport, |req: Inbound| match req {
|
||||
Inbound::AuthChallengeSolution { signature } => Some(signature),
|
||||
_ => None,
|
||||
Inbound::AuthChallengeRequest { .. } => None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod client;
|
||||
pub mod user_agent;
|
||||
pub mod operator;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Credentials, UserAgentConnection};
|
||||
use super::{Credentials, OperatorConnection};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge};
|
||||
use arbiter_proto::transport::Bi;
|
||||
|
||||
@@ -69,7 +69,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
|
||||
}
|
||||
|
||||
pub async fn authenticate<T>(
|
||||
props: &mut UserAgentConnection,
|
||||
props: &mut OperatorConnection,
|
||||
transport: &mut T,
|
||||
) -> Result<Credentials, Error>
|
||||
where
|
||||
@@ -1,32 +1,32 @@
|
||||
use super::{
|
||||
super::{Credentials, UserAgentConnection},
|
||||
super::{Credentials, OperatorConnection},
|
||||
Error,
|
||||
};
|
||||
use crate::{
|
||||
actors::bootstrap::ConsumeToken,
|
||||
db::{DatabasePool, schema::useragent_client},
|
||||
peers::user_agent::auth::Outbound,
|
||||
db::{DatabasePool, schema::operator_identity},
|
||||
peers::operator::auth::Outbound,
|
||||
};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
|
||||
use arbiter_proto::transport::Bi;
|
||||
|
||||
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use tracing::error;
|
||||
|
||||
pub(super) struct ChallengeRequest {
|
||||
pub(super) pubkey: authn::PublicKey,
|
||||
pub(super) bootstrap_token: Option<String>,
|
||||
pub(crate) struct ChallengeRequest {
|
||||
pub(crate) pubkey: authn::PublicKey,
|
||||
pub(crate) bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct ChallengeContext {
|
||||
pub(super) challenge: AuthChallenge,
|
||||
pub(super) pubkey: authn::PublicKey,
|
||||
pub(super) bootstrap_token: Option<String>,
|
||||
pub struct ChallengeContext {
|
||||
pub challenge: AuthChallenge,
|
||||
pub pubkey: authn::PublicKey,
|
||||
pub bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct ChallengeSolution {
|
||||
pub(super) solution: Vec<u8>,
|
||||
pub(crate) struct ChallengeSolution {
|
||||
pub(crate) solution: Vec<u8>,
|
||||
}
|
||||
|
||||
smlang::statemachine!(
|
||||
@@ -44,9 +44,9 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<O
|
||||
Error::internal("Database unavailable")
|
||||
})?;
|
||||
|
||||
useragent_client::table
|
||||
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
|
||||
.select(useragent_client::id)
|
||||
operator_identity::table
|
||||
.filter(operator_identity::public_key.eq(pubkey.to_bytes()))
|
||||
.select(operator_identity::id)
|
||||
.first::<i32>(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
@@ -63,9 +63,9 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
|
||||
Error::internal("Database unavailable")
|
||||
})?;
|
||||
|
||||
let id: i32 = diesel::insert_into(useragent_client::table)
|
||||
.values((useragent_client::public_key.eq(pubkey_bytes),))
|
||||
.returning(useragent_client::id)
|
||||
let id: i32 = diesel::insert_into(operator_identity::table)
|
||||
.values((operator_identity::public_key.eq(pubkey_bytes),))
|
||||
.returning(operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -77,12 +77,12 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
|
||||
}
|
||||
|
||||
pub(super) struct AuthContext<'a, T: ?Sized> {
|
||||
pub(super) conn: &'a mut UserAgentConnection,
|
||||
pub(super) conn: &'a mut OperatorConnection,
|
||||
pub(super) transport: &'a mut T,
|
||||
}
|
||||
|
||||
impl<'a, T: ?Sized> AuthContext<'a, T> {
|
||||
pub(super) const fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
|
||||
pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self {
|
||||
Self { conn, transport }
|
||||
}
|
||||
}
|
||||
@@ -127,8 +127,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
async fn verify_solution(
|
||||
&mut self,
|
||||
ChallengeContext {
|
||||
@@ -143,7 +141,7 @@ where
|
||||
Error::InvalidChallengeSolution
|
||||
})?;
|
||||
|
||||
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
|
||||
let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature);
|
||||
|
||||
if !valid {
|
||||
self.transport
|
||||
@@ -17,7 +17,7 @@ use tokio::sync::oneshot;
|
||||
use tracing::{error, warn};
|
||||
|
||||
pub use auth::authenticate;
|
||||
pub use session::UserAgentSession;
|
||||
pub use session::OperatorSession;
|
||||
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
@@ -30,10 +30,10 @@ pub struct Credentials {
|
||||
}
|
||||
|
||||
impl Integrable for Credentials {
|
||||
const KIND: &'static str = "useragent_credentials";
|
||||
const KIND: &'static str = "operator_credentials";
|
||||
}
|
||||
|
||||
// Messages, sent by user agent to connection client without having a request
|
||||
// Messages, sent by operator to connection client without having a request
|
||||
#[derive(Debug)]
|
||||
pub enum OutOfBand {
|
||||
ClientConnectionRequest { profile: ClientProfile },
|
||||
@@ -41,12 +41,12 @@ pub enum OutOfBand {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserAgentConnection {
|
||||
pub struct OperatorConnection {
|
||||
pub(crate) db: DatabasePool,
|
||||
pub(crate) actors: GlobalActors,
|
||||
}
|
||||
|
||||
impl UserAgentConnection {
|
||||
impl OperatorConnection {
|
||||
pub const fn new(db: DatabasePool, actors: GlobalActors) -> Self {
|
||||
Self { db, actors }
|
||||
}
|
||||
@@ -106,7 +106,7 @@ async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
|
||||
}
|
||||
|
||||
async fn run_vault_gate<T>(
|
||||
props: &UserAgentConnection,
|
||||
props: &OperatorConnection,
|
||||
transport: &mut T,
|
||||
auth_creds: Credentials,
|
||||
) -> Result<(), Error>
|
||||
@@ -160,10 +160,10 @@ where
|
||||
}
|
||||
|
||||
pub async fn start<T>(
|
||||
props: &mut UserAgentConnection,
|
||||
props: &mut OperatorConnection,
|
||||
mut transport: T,
|
||||
oob_sender: Box<dyn Sender<OutOfBand>>,
|
||||
) -> Result<ActorRef<UserAgentSession>, Error>
|
||||
) -> Result<ActorRef<OperatorSession>, 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,
|
||||
@@ -178,8 +178,9 @@ where
|
||||
// checking the integrity
|
||||
verify_integrity(&props.db, &props.actors.vault, &creds).await?;
|
||||
|
||||
Ok(UserAgentSession::spawn(UserAgentSession::new(
|
||||
Ok(OperatorSession::spawn(OperatorSession::new(
|
||||
props.clone(),
|
||||
creds.clone(),
|
||||
oob_sender,
|
||||
)))
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
use super::{Error, UserAgentSession};
|
||||
use super::{Error, OperatorSession};
|
||||
use crate::{
|
||||
actors::evm::{
|
||||
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
|
||||
UseragentCreateGrant, UseragentListGrants,
|
||||
actors::{
|
||||
evm::{
|
||||
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorDeleteGrant,
|
||||
OperatorListGrants, SignTransactionError as EvmSignError,
|
||||
},
|
||||
flow_coordinator::client_connect_approval::ClientApprovalAnswer,
|
||||
vault::VaultState,
|
||||
},
|
||||
db::models::{
|
||||
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
||||
},
|
||||
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
|
||||
actors::vault::VaultState,
|
||||
db::models::{EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata},
|
||||
evm::policies::{Grant, SpecificGrant},
|
||||
};
|
||||
use arbiter_crypto::authn;
|
||||
@@ -36,7 +40,7 @@ pub enum GrantMutationError {
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
|
||||
use crate::actors::vault::GetState;
|
||||
@@ -44,7 +48,7 @@ impl UserAgentSession {
|
||||
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
error!(?err, actor = "useragent", "vault.query.failed");
|
||||
error!(?err, actor = "operator", "vault.query.failed");
|
||||
return Err(Error::internal("Vault is in broken state"));
|
||||
}
|
||||
};
|
||||
@@ -54,7 +58,7 @@ impl UserAgentSession {
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
|
||||
match self.props.actors.evm.ask(Generate {}).await {
|
||||
@@ -70,7 +74,9 @@ impl UserAgentSession {
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
|
||||
pub(crate) async fn handle_evm_wallet_list(
|
||||
&mut self,
|
||||
) -> Result<Vec<(EvmWalletId, Address)>, Error> {
|
||||
match self.props.actors.evm.ask(ListWallets {}).await {
|
||||
Ok(wallets) => Ok(wallets),
|
||||
Err(err) => {
|
||||
@@ -82,10 +88,10 @@ impl UserAgentSession {
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
match self.props.actors.evm.ask(UseragentListGrants {}).await {
|
||||
match self.props.actors.evm.ask(OperatorListGrants {}).await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant list failed");
|
||||
@@ -104,7 +110,7 @@ impl UserAgentSession {
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(UseragentCreateGrant { basic, grant })
|
||||
.ask(OperatorCreateGrant { basic, grant })
|
||||
.await
|
||||
{
|
||||
Ok(grant_id) => Ok(grant_id),
|
||||
@@ -116,22 +122,23 @@ impl UserAgentSession {
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) 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!()
|
||||
pub(crate) async fn handle_grant_delete(
|
||||
&mut self,
|
||||
grant_id: i32,
|
||||
) -> Result<(), GrantMutationError> {
|
||||
match self
|
||||
.props
|
||||
.actors
|
||||
.evm
|
||||
.ask(OperatorDeleteGrant { grant_id })
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
error!(?err, "EVM grant delete failed");
|
||||
Err(GrantMutationError::Internal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
@@ -157,7 +164,7 @@ impl UserAgentSession {
|
||||
Err(SignTransactionError::Vet(vet_error))
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "EVM sign transaction failed in user-agent session");
|
||||
error!(?err, "EVM sign transaction failed in operator session");
|
||||
Err(SignTransactionError::Internal)
|
||||
}
|
||||
}
|
||||
@@ -169,20 +176,18 @@ impl UserAgentSession {
|
||||
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;
|
||||
conn.transaction(async |conn| {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
|
||||
for entry in entries {
|
||||
diesel::insert_into(evm_wallet_access::table)
|
||||
.values(&entry)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)
|
||||
.await?;
|
||||
}
|
||||
for entry in entries {
|
||||
diesel::insert_into(evm_wallet_access::table)
|
||||
.values(&entry)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -194,18 +199,16 @@ impl UserAgentSession {
|
||||
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?;
|
||||
}
|
||||
conn.transaction(async |conn| {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
for entry in entries {
|
||||
diesel::delete(evm_wallet_access::table)
|
||||
.filter(evm_wallet_access::wallet_id.eq(entry))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
Result::<_, Error>::Ok(())
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -215,8 +218,8 @@ impl UserAgentSession {
|
||||
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 mut conn = self.props.db.get().await?;
|
||||
let access_entries = evm_wallet_access::table
|
||||
.select(EvmWalletAccess::as_select())
|
||||
.load::<_>(&mut conn)
|
||||
@@ -226,7 +229,7 @@ impl UserAgentSession {
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
impl OperatorSession {
|
||||
#[message(ctx)]
|
||||
pub(crate) async fn handle_new_client_approve(
|
||||
&mut self,
|
||||
@@ -276,3 +279,102 @@ impl UserAgentSession {
|
||||
Ok(clients)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_create_proposal(
|
||||
&mut self,
|
||||
kind: crate::actors::proposal_manager::ProposalKind,
|
||||
ttl_secs: Option<i64>,
|
||||
) -> Result<i32, Error> {
|
||||
use crate::actors::proposal_manager::CreateProposal;
|
||||
let initiator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.proposal_manager
|
||||
.ask(CreateProposal { kind, initiator_id, ttl_secs })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "create_proposal failed");
|
||||
Error::internal("Failed to create proposal")
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_cast_vote(
|
||||
&mut self,
|
||||
proposal_id: i32,
|
||||
approve: bool,
|
||||
signature: Vec<u8>,
|
||||
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
|
||||
use crate::actors::proposal_manager::CastVote;
|
||||
let operator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.proposal_manager
|
||||
.ask(CastVote { proposal_id, operator_id, approve, signature })
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
SendError::HandlerError(e) => e,
|
||||
_ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_pending(
|
||||
&mut self,
|
||||
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
|
||||
use crate::actors::proposal_manager::QueryPending;
|
||||
let operator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.proposal_manager
|
||||
.ask(QueryPending { operator_id })
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_contribute_rekey_passphrase(
|
||||
&mut self,
|
||||
passphrase: Vec<u8>,
|
||||
) -> Result<bool, Error> {
|
||||
use crate::actors::vault_coordinator::ContributeRekey;
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
let operator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.vault_coordinator
|
||||
.ask(ContributeRekey {
|
||||
operator_id,
|
||||
passphrase: SafeCell::new(passphrase),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
passphrase: Vec<u8>,
|
||||
) -> Result<bool, Error> {
|
||||
use crate::actors::vault_coordinator::ContributeRecoveryRekey;
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
self.props
|
||||
.actors
|
||||
.vault_coordinator
|
||||
.ask(ContributeRecoveryRekey {
|
||||
recovery_operator_id,
|
||||
passphrase: SafeCell::new(passphrase),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::{OutOfBand, UserAgentConnection};
|
||||
use super::{Credentials, OutOfBand, OperatorConnection};
|
||||
use crate::{
|
||||
actors::{
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
useragent_registry::ConnectUseragent,
|
||||
operator_registry::ConnectOperator,
|
||||
},
|
||||
peers::client::ClientProfile,
|
||||
};
|
||||
@@ -49,8 +49,9 @@ pub struct PendingClientApproval {
|
||||
controller: ActorRef<ClientApprovalController>,
|
||||
}
|
||||
|
||||
pub struct UserAgentSession {
|
||||
props: UserAgentConnection,
|
||||
pub struct OperatorSession {
|
||||
props: OperatorConnection,
|
||||
credentials: Credentials,
|
||||
sender: Box<dyn Sender<OutOfBand>>,
|
||||
|
||||
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
||||
@@ -58,18 +59,19 @@ pub struct UserAgentSession {
|
||||
|
||||
pub mod handlers;
|
||||
|
||||
impl UserAgentSession {
|
||||
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
||||
impl OperatorSession {
|
||||
pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
||||
Self {
|
||||
props,
|
||||
credentials,
|
||||
sender,
|
||||
pending_client_approvals: Default::default(),
|
||||
pending_client_approvals: HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub async fn begin_new_client_approval(
|
||||
&mut self,
|
||||
@@ -85,7 +87,7 @@ impl UserAgentSession {
|
||||
{
|
||||
error!(
|
||||
?e,
|
||||
actor = "user_agent",
|
||||
actor = "operator",
|
||||
event = "failed to announce new client connection"
|
||||
);
|
||||
return;
|
||||
@@ -101,7 +103,7 @@ impl UserAgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for UserAgentSession {
|
||||
impl Actor for OperatorSession {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Error;
|
||||
@@ -109,17 +111,17 @@ impl Actor for UserAgentSession {
|
||||
async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
args.props
|
||||
.actors
|
||||
.useragent_registry
|
||||
.ask(ConnectUseragent {
|
||||
.operator_registry
|
||||
.ask(ConnectOperator {
|
||||
actor: this.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
?err,
|
||||
"Failed to register user agent connection with user agent registry"
|
||||
"Failed to register operator connection with operator registry"
|
||||
);
|
||||
Error::internal("Failed to register user agent connection with user agent registry")
|
||||
Error::internal("Failed to register operator connection with operator registry")
|
||||
})?;
|
||||
Ok(args)
|
||||
}
|
||||
@@ -149,7 +151,7 @@ impl Actor for UserAgentSession {
|
||||
{
|
||||
error!(
|
||||
?e,
|
||||
actor = "user_agent",
|
||||
actor = "operator",
|
||||
event = "failed to announce client connection cancellation"
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,12 @@ use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
|
||||
vault_coordinator::{
|
||||
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
|
||||
ContributeUnseal, StartBootstrap,
|
||||
},
|
||||
},
|
||||
crypto::integrity::{self},
|
||||
crypto::{KeyCell, integrity::{self}},
|
||||
db::DatabasePool,
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
@@ -17,6 +21,9 @@ use tokio::sync::oneshot;
|
||||
use tracing::{error, info};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
|
||||
|
||||
pub use VaultGateMessage as Inbound;
|
||||
pub use VaultGateMessageReply as Outbound;
|
||||
|
||||
pub mod state;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -98,11 +105,9 @@ impl VaultGate {
|
||||
nonce: &[u8],
|
||||
ciphertext: &[u8],
|
||||
associated_data: &[u8],
|
||||
) -> Result<SafeCell<Vec<u8>>, ()> {
|
||||
) -> Result<KeyCell, ()> {
|
||||
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| {
|
||||
@@ -110,7 +115,9 @@ impl VaultGate {
|
||||
});
|
||||
|
||||
match decryption_result {
|
||||
Ok(()) => Ok(key_buffer),
|
||||
Ok(()) => KeyCell::try_from(key_buffer).map_err(|()| {
|
||||
error!("Decrypted key material has unexpected length");
|
||||
}),
|
||||
Err(err) => {
|
||||
error!(?err, "Failed to decrypt encrypted key material");
|
||||
Err(())
|
||||
@@ -119,7 +126,7 @@ impl VaultGate {
|
||||
}
|
||||
}
|
||||
|
||||
#[messages(messages = Inbound, replies = Outbound)]
|
||||
#[messages(enum)]
|
||||
impl VaultGate {
|
||||
#[message]
|
||||
pub fn handle_handshake(
|
||||
@@ -152,17 +159,14 @@ impl VaultGate {
|
||||
return Err(Error::State);
|
||||
};
|
||||
|
||||
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
|
||||
else {
|
||||
let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else {
|
||||
return Err(Error::InvalidKey);
|
||||
};
|
||||
|
||||
match self
|
||||
.actors
|
||||
.vault
|
||||
.ask(TryUnseal {
|
||||
seal_key_raw: seal_key_buffer,
|
||||
})
|
||||
.ask(TryUnseal { seal_key })
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
@@ -192,17 +196,14 @@ impl VaultGate {
|
||||
return Err(Error::State);
|
||||
};
|
||||
|
||||
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
|
||||
else {
|
||||
let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else {
|
||||
return Err(Error::InvalidKey);
|
||||
};
|
||||
|
||||
match self
|
||||
.actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: seal_key_buffer,
|
||||
})
|
||||
.ask(Bootstrap { seal_key })
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
@@ -234,6 +235,89 @@ impl VaultGate {
|
||||
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_declare_committee(
|
||||
&mut self,
|
||||
count: usize,
|
||||
recovery_count: usize,
|
||||
) -> Result<(), Error> {
|
||||
self.actors
|
||||
.vault_coordinator
|
||||
.ask(StartBootstrap {
|
||||
operator_id: self.auth_creds.id,
|
||||
declared_count: count,
|
||||
recovery_count,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_contribute_bootstrap_passphrase(
|
||||
&mut self,
|
||||
passphrase: Vec<u8>,
|
||||
) -> Result<bool, Error> {
|
||||
let passphrase_cell = SafeCell::new(passphrase);
|
||||
self.actors
|
||||
.vault_coordinator
|
||||
.ask(ContributeBootstrap {
|
||||
operator_id: self.auth_creds.id,
|
||||
passphrase: passphrase_cell,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_contribute_recovery_bootstrap_passphrase(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
passphrase: Vec<u8>,
|
||||
) -> Result<bool, Error> {
|
||||
let passphrase_cell = SafeCell::new(passphrase);
|
||||
self.actors
|
||||
.vault_coordinator
|
||||
.ask(ContributeRecoveryBootstrap {
|
||||
recovery_operator_id,
|
||||
passphrase: passphrase_cell,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_contribute_unseal_passphrase(
|
||||
&mut self,
|
||||
passphrase: Vec<u8>,
|
||||
) -> Result<bool, Error> {
|
||||
let passphrase_cell = SafeCell::new(passphrase);
|
||||
self.actors
|
||||
.vault_coordinator
|
||||
.ask(ContributeUnseal {
|
||||
operator_id: self.auth_creds.id,
|
||||
passphrase: passphrase_cell,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn handle_contribute_recovery_unseal_passphrase(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
passphrase: Vec<u8>,
|
||||
) -> Result<bool, Error> {
|
||||
let passphrase_cell = SafeCell::new(passphrase);
|
||||
self.actors
|
||||
.vault_coordinator
|
||||
.ask(ContributeRecoveryUnseal {
|
||||
recovery_operator_id,
|
||||
passphrase: passphrase_cell,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Message<events::Bootstrapped> for VaultGate {
|
||||
@@ -1,8 +1,5 @@
|
||||
use super::common::ChannelTransport;
|
||||
use arbiter_crypto::{
|
||||
authn::{self, AuthChallenge, CLIENT_CONTEXT},
|
||||
safecell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
|
||||
use arbiter_proto::{
|
||||
ClientMetadata,
|
||||
transport::{Receiver, Sender},
|
||||
@@ -79,28 +76,28 @@ fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
|
||||
async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let sentinel_key = verifying_key(&MlDsa87::key_gen(&mut rand::rng()))
|
||||
.encode()
|
||||
.0
|
||||
.to_vec();
|
||||
|
||||
insert_into(schema::useragent_client::table)
|
||||
.values((schema::useragent_client::public_key.eq(sentinel_key),))
|
||||
insert_into(schema::operator_identity::table)
|
||||
.values((schema::operator_identity::public_key.eq(sentinel_key),))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
|
||||
insert_bootstrap_sentinel_useragent(db).await;
|
||||
insert_bootstrap_sentinel_operator(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()),
|
||||
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
dead_code,
|
||||
reason = "Common test utilities that may not be used in every test"
|
||||
)]
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
|
||||
use arbiter_server::{
|
||||
actors::{GlobalActors, vault::Vault},
|
||||
@@ -19,7 +18,7 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
|
||||
.await
|
||||
.unwrap();
|
||||
actor
|
||||
.bootstrap(SafeCell::new(b"test-seal-key".to_vec()))
|
||||
.bootstrap(arbiter_server::crypto::KeyCell::from([0u8; 32]))
|
||||
.await
|
||||
.unwrap();
|
||||
actor
|
||||
|
||||
1120
server/crates/arbiter-server/tests/governance.rs
Normal file
1120
server/crates/arbiter-server/tests/governance.rs
Normal file
File diff suppressed because it is too large
Load Diff
6
server/crates/arbiter-server/tests/operator.rs
Normal file
6
server/crates/arbiter-server/tests/operator.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod common;
|
||||
|
||||
#[path = "operator/auth.rs"]
|
||||
mod auth;
|
||||
#[path = "operator/unseal.rs"]
|
||||
mod unseal;
|
||||
@@ -1,14 +1,11 @@
|
||||
use super::common::ChannelTransport;
|
||||
use arbiter_crypto::{
|
||||
authn::{self, AuthChallenge, USERAGENT_CONTEXT},
|
||||
safecell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
|
||||
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
|
||||
use arbiter_server::{
|
||||
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
|
||||
crypto::integrity,
|
||||
db::{self, schema},
|
||||
peers::user_agent::{self, Credentials, UserAgentConnection, auth, vault_gate},
|
||||
peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -21,13 +18,13 @@ fn verifying_key(key: &SigningKey<MlDsa87>) -> VerifyingKey<MlDsa87> {
|
||||
<SigningKey<MlDsa87> as Keypair>::verifying_key(key)
|
||||
}
|
||||
|
||||
fn sign_useragent_challenge(
|
||||
fn sign_operator_challenge(
|
||||
key: &SigningKey<MlDsa87>,
|
||||
challenge: &AuthChallenge,
|
||||
) -> authn::Signature {
|
||||
let challenge = challenge.format();
|
||||
key.signing_key()
|
||||
.sign_deterministic(&challenge, USERAGENT_CONTEXT)
|
||||
.sign_deterministic(&challenge, OPERATOR_CONTEXT)
|
||||
.unwrap()
|
||||
.into()
|
||||
}
|
||||
@@ -41,8 +38,8 @@ fn tamper_challenge(challenge: &AuthChallenge) -> AuthChallenge {
|
||||
struct NullOobSender;
|
||||
|
||||
#[async_trait]
|
||||
impl Sender<user_agent::OutOfBand> for NullOobSender {
|
||||
async fn send(&mut self, _item: user_agent::OutOfBand) -> Result<(), TransportError> {
|
||||
impl Sender<operator::OutOfBand> for NullOobSender {
|
||||
async fn send(&mut self, _item: operator::OutOfBand) -> Result<(), TransportError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -157,7 +154,7 @@ pub async fn bootstrap_token_auth() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -166,7 +163,7 @@ pub async fn bootstrap_token_auth() {
|
||||
let (mut server_transport, mut test_transport) = ChannelTransport::new();
|
||||
let db_for_task = db.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut props = UserAgentConnection::new(db_for_task, actors);
|
||||
let mut props = OperatorConnection::new(db_for_task, actors);
|
||||
auth::authenticate(&mut props, &mut server_transport).await
|
||||
});
|
||||
|
||||
@@ -188,7 +185,7 @@ pub async fn bootstrap_token_auth() {
|
||||
other => panic!("Expected AuthChallenge, got {other:?}"),
|
||||
};
|
||||
|
||||
let signature = sign_useragent_challenge(&new_key, &challenge);
|
||||
let signature = sign_operator_challenge(&new_key, &challenge);
|
||||
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeSolution {
|
||||
@@ -206,8 +203,8 @@ pub async fn bootstrap_token_auth() {
|
||||
task.await.unwrap().unwrap();
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let stored_pubkey: Vec<u8> = schema::useragent_client::table
|
||||
.select(schema::useragent_client::public_key)
|
||||
let stored_pubkey: Vec<u8> = schema::operator_identity::table
|
||||
.select(schema::operator_identity::public_key)
|
||||
.first::<Vec<u8>>(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -223,7 +220,7 @@ pub async fn bootstrap_invalid_token_auth() {
|
||||
let (mut server_transport, mut test_transport) = ChannelTransport::new();
|
||||
let db_for_task = db.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut props = UserAgentConnection::new(db_for_task, actors);
|
||||
let mut props = OperatorConnection::new(db_for_task, actors);
|
||||
auth::authenticate(&mut props, &mut server_transport).await
|
||||
});
|
||||
|
||||
@@ -245,7 +242,7 @@ pub async fn bootstrap_invalid_token_auth() {
|
||||
other => panic!("Expected AuthChallenge, got {other:?}"),
|
||||
};
|
||||
|
||||
let signature = sign_useragent_challenge(&new_key, &challenge);
|
||||
let signature = sign_operator_challenge(&new_key, &challenge);
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeSolution {
|
||||
signature: signature.to_bytes(),
|
||||
@@ -259,7 +256,7 @@ pub async fn bootstrap_invalid_token_auth() {
|
||||
));
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let count: i64 = schema::useragent_client::table
|
||||
let count: i64 = schema::operator_identity::table
|
||||
.count()
|
||||
.get_result::<i64>(&mut conn)
|
||||
.await
|
||||
@@ -275,7 +272,7 @@ pub async fn challenge_auth() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -285,9 +282,9 @@ pub async fn challenge_auth() {
|
||||
|
||||
{
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let id: i32 = insert_into(schema::useragent_client::table)
|
||||
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
|
||||
.returning(schema::useragent_client::id)
|
||||
let id: i32 = insert_into(schema::operator_identity::table)
|
||||
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
|
||||
.returning(schema::operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -307,7 +304,7 @@ pub async fn challenge_auth() {
|
||||
let (mut server_transport, mut test_transport) = ChannelTransport::new();
|
||||
let db_for_task = db.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut props = UserAgentConnection::new(db_for_task, actors);
|
||||
let mut props = OperatorConnection::new(db_for_task, actors);
|
||||
auth::authenticate(&mut props, &mut server_transport).await
|
||||
});
|
||||
|
||||
@@ -331,7 +328,7 @@ pub async fn challenge_auth() {
|
||||
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
|
||||
};
|
||||
|
||||
let signature = sign_useragent_challenge(&new_key, &challenge);
|
||||
let signature = sign_operator_challenge(&new_key, &challenge);
|
||||
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeSolution {
|
||||
@@ -361,7 +358,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -371,8 +368,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
|
||||
|
||||
{
|
||||
let mut conn = db.get().await.unwrap();
|
||||
insert_into(schema::useragent_client::table)
|
||||
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
|
||||
insert_into(schema::operator_identity::table)
|
||||
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -381,8 +378,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
|
||||
let (server_transport, mut test_transport) = start_transport_pair();
|
||||
let db_for_task = db.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut props = UserAgentConnection::new(db_for_task, actors);
|
||||
user_agent::start(&mut props, server_transport, Box::new(NullOobSender)).await
|
||||
let mut props = OperatorConnection::new(db_for_task, actors);
|
||||
operator::start(&mut props, server_transport, Box::new(NullOobSender)).await
|
||||
});
|
||||
|
||||
test_transport
|
||||
@@ -405,7 +402,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
|
||||
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
|
||||
};
|
||||
|
||||
let signature = sign_useragent_challenge(&new_key, &challenge);
|
||||
let signature = sign_operator_challenge(&new_key, &challenge);
|
||||
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeSolution {
|
||||
@@ -422,7 +419,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
|
||||
|
||||
assert!(matches!(
|
||||
task.await.unwrap(),
|
||||
Err(user_agent::Error::Internal(_))
|
||||
Err(operator::Error::Internal(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -434,7 +431,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -444,9 +441,9 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
|
||||
{
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let id: i32 = insert_into(schema::useragent_client::table)
|
||||
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
|
||||
.returning(schema::useragent_client::id)
|
||||
let id: i32 = insert_into(schema::operator_identity::table)
|
||||
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
|
||||
.returning(schema::operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -466,7 +463,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
let (mut server_transport, mut test_transport) = ChannelTransport::new();
|
||||
let db_for_task = db.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut props = UserAgentConnection::new(db_for_task, actors);
|
||||
let mut props = OperatorConnection::new(db_for_task, actors);
|
||||
auth::authenticate(&mut props, &mut server_transport).await
|
||||
});
|
||||
|
||||
@@ -490,7 +487,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
|
||||
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
|
||||
};
|
||||
|
||||
let signature = sign_useragent_challenge(&new_key, &tamper_challenge(&challenge));
|
||||
let signature = sign_operator_challenge(&new_key, &tamper_challenge(&challenge));
|
||||
|
||||
test_transport
|
||||
.send(auth::Inbound::AuthChallengeSolution {
|
||||
@@ -1,14 +1,11 @@
|
||||
use arbiter_crypto::{
|
||||
authn,
|
||||
safecell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use arbiter_crypto::authn;
|
||||
use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{Bootstrap, Seal},
|
||||
},
|
||||
db,
|
||||
peers::user_agent::{
|
||||
peers::operator::{
|
||||
Credentials,
|
||||
vault_gate::{
|
||||
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,
|
||||
@@ -22,7 +19,7 @@ use tokio::sync::oneshot;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
async fn setup_sealed_gate(
|
||||
seal_key: &[u8],
|
||||
seal_key: &[u8; 32],
|
||||
) -> (
|
||||
db::DatabasePool,
|
||||
kameo::actor::ActorRef<VaultGate>,
|
||||
@@ -34,7 +31,7 @@ async fn setup_sealed_gate(
|
||||
actors
|
||||
.vault
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(seal_key.to_vec()),
|
||||
seal_key: arbiter_server::crypto::KeyCell::from(*seal_key),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -50,7 +47,7 @@ async fn setup_sealed_gate(
|
||||
|
||||
async fn client_dh_encrypt(
|
||||
gate: &kameo::actor::ActorRef<VaultGate>,
|
||||
key_to_send: &[u8],
|
||||
key_to_send: &[u8; 32],
|
||||
) -> HandleUnsealEncryptedKey {
|
||||
let client_secret = EphemeralSecret::random();
|
||||
let client_public = PublicKey::from(&client_secret);
|
||||
@@ -83,7 +80,7 @@ async fn client_dh_encrypt(
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_success() {
|
||||
let seal_key = b"test-seal-key";
|
||||
let seal_key = b"test-seal-key-padded-to-32bytes!";
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
|
||||
@@ -95,10 +92,10 @@ pub async fn unseal_success() {
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_wrong_seal_key() {
|
||||
let seal_key = b"test-seal-key";
|
||||
let seal_key = b"test-seal-key-padded-to-32bytes!";
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
|
||||
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await;
|
||||
|
||||
let response = gate.ask(encrypted_key).await;
|
||||
assert!(matches!(
|
||||
@@ -112,7 +109,7 @@ pub async fn unseal_wrong_seal_key() {
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_corrupted_ciphertext() {
|
||||
let seal_key = b"test-seal-key";
|
||||
let seal_key = b"test-seal-key-padded-to-32bytes!";
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
let client_secret = EphemeralSecret::random();
|
||||
@@ -143,11 +140,11 @@ pub async fn unseal_corrupted_ciphertext() {
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
pub async fn unseal_retry_after_invalid_key() {
|
||||
let seal_key = b"real-seal-key";
|
||||
let seal_key = b"real-seal-key-padded-to-32bytes!";
|
||||
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
|
||||
|
||||
{
|
||||
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
|
||||
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await;
|
||||
|
||||
let response = gate.ask(encrypted_key).await;
|
||||
assert!(matches!(
|
||||
@@ -1,6 +0,0 @@
|
||||
mod common;
|
||||
|
||||
#[path = "user_agent/auth.rs"]
|
||||
mod auth;
|
||||
#[path = "user_agent/unseal.rs"]
|
||||
mod unseal;
|
||||
@@ -166,7 +166,7 @@ async fn decrypt_roundtrip_after_high_concurrency() {
|
||||
.await
|
||||
.unwrap();
|
||||
decryptor
|
||||
.try_unseal(SafeCell::new(b"test-seal-key".to_vec()))
|
||||
.try_unseal(arbiter_server::crypto::KeyCell::from([0u8; 32]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -3,14 +3,19 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{Error, Vault},
|
||||
vault::{Error, GetState, Vault, VaultState},
|
||||
vault_coordinator::{
|
||||
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
|
||||
Error as CoordinatorError, StartBootstrap, VaultCoordinator,
|
||||
},
|
||||
},
|
||||
crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
|
||||
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
||||
db::{self, models, schema},
|
||||
};
|
||||
|
||||
use diesel::{QueryDsl, SelectableHelper};
|
||||
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::actor::Spawn as _;
|
||||
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
@@ -20,7 +25,7 @@ async fn test_bootstrap() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
let seal_key = KeyCell::from([0u8; 32]);
|
||||
actor.bootstrap(seal_key).await.unwrap();
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
@@ -43,7 +48,7 @@ async fn test_bootstrap_rejects_double() {
|
||||
let db = db::create_test_pool().await;
|
||||
let mut actor = common::bootstrapped_vault(&db).await;
|
||||
|
||||
let seal_key2 = SafeCell::new(b"test-seal-key".to_vec());
|
||||
let seal_key2 = KeyCell::from([0u8; 32]);
|
||||
let err = actor.bootstrap(seal_key2).await.unwrap_err();
|
||||
assert!(matches!(err, Error::AlreadyBootstrapped));
|
||||
}
|
||||
@@ -105,7 +110,7 @@ async fn test_unseal_correct_password() {
|
||||
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap();
|
||||
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
let seal_key = KeyCell::from([0u8; 32]);
|
||||
actor.try_unseal(seal_key).await.unwrap();
|
||||
|
||||
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
|
||||
@@ -129,13 +134,137 @@ async fn test_unseal_wrong_then_correct_password() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bad_key = SafeCell::new(b"wrong-password".to_vec());
|
||||
let bad_key = KeyCell::from([1u8; 32]);
|
||||
let err = actor.try_unseal(bad_key).await.unwrap_err();
|
||||
assert!(matches!(err, Error::InvalidKey));
|
||||
|
||||
let good_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
let good_key = KeyCell::from([0u8; 32]);
|
||||
actor.try_unseal(good_key).await.unwrap();
|
||||
|
||||
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
|
||||
assert_eq!(*decrypted.read(), plaintext);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn two_operator_vault_requires_recovery_share() {
|
||||
let db = db::create_test_pool().await;
|
||||
let bus = GlobalActors::spawn_message_bus();
|
||||
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
|
||||
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref));
|
||||
|
||||
let err = coordinator
|
||||
.ask(StartBootstrap {
|
||||
operator_id: 1,
|
||||
declared_count: 2,
|
||||
recovery_count: 0,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
kameo::error::SendError::HandlerError(CoordinatorError::TwoOperatorsRequireRecovery)
|
||||
),
|
||||
"expected TwoOperatorsRequireRecovery, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// §3.4: Bootstrap with 1 ordinary + 1 recovery operator produces a valid 1-of-2 Shamir split.
|
||||
/// Both ordinary and recovery shares are stored; the vault can be unsealed with either one.
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn recovery_share_stored_and_used_for_unseal() {
|
||||
let db = db::create_test_pool().await;
|
||||
let bus = GlobalActors::spawn_message_bus();
|
||||
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
|
||||
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone()));
|
||||
|
||||
// Register one ordinary operator and one recovery operator in the DB
|
||||
let ordinary_id: i32 = {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
insert_into(schema::operator_identity::table)
|
||||
.values(schema::operator_identity::public_key.eq(vec![1u8; 32]))
|
||||
.returning(schema::operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let recovery_id: i32 = {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
insert_into(schema::recovery_operator_identity::table)
|
||||
.values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32]))
|
||||
.returning(schema::recovery_operator_identity::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Declare committee: 1 ordinary + 1 recovery
|
||||
coordinator
|
||||
.ask(StartBootstrap {
|
||||
operator_id: ordinary_id,
|
||||
declared_count: 1,
|
||||
recovery_count: 1,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Recovery operator contributes first — bootstrap should not finalize yet
|
||||
let done = coordinator
|
||||
.ask(ContributeRecoveryBootstrap {
|
||||
recovery_operator_id: recovery_id,
|
||||
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!done, "should not finalize with only recovery passphrase");
|
||||
|
||||
// Ordinary operator contributes — now bootstrap finalizes
|
||||
let done = coordinator
|
||||
.ask(ContributeBootstrap {
|
||||
operator_id: ordinary_id,
|
||||
passphrase: SafeCell::new(b"ordinary-pass".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(done, "should finalize once all contributors are in");
|
||||
|
||||
// After bootstrap, vault is Unsealed (seal key still in memory).
|
||||
let state = vault_ref.ask(GetState {}).await.unwrap();
|
||||
assert_eq!(state, VaultState::Unsealed);
|
||||
|
||||
// Verify recovery_operator row was created
|
||||
let recovery_share_count: i64 = {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
schema::recovery_operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(recovery_share_count, 1);
|
||||
|
||||
// Simulate restart: drop vault and coordinator, create fresh vault (comes up Sealed).
|
||||
drop(coordinator);
|
||||
drop(vault_ref);
|
||||
let bus2 = GlobalActors::spawn_message_bus();
|
||||
let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap());
|
||||
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||
assert_eq!(state, VaultState::Sealed);
|
||||
|
||||
// §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1).
|
||||
let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone()));
|
||||
let done = coordinator2
|
||||
.ask(ContributeRecoveryUnseal {
|
||||
recovery_operator_id: recovery_id,
|
||||
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(done, "recovery share alone should satisfy threshold");
|
||||
|
||||
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||
assert_eq!(state, VaultState::Unsealed);
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
alloy.workspace = true
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
Reference in New Issue
Block a user