Compare commits

..

2 Commits

Author SHA1 Message Date
Clippy Bot
cc21036448 fix(clippy): apply auto-fixable linting suggestions 2026-06-23 18:47:23 +00:00
CleverWild
4fd75701c7 ci(clippy): add auto-fix bot pipeline 2026-06-23 20:41:41 +02:00
425 changed files with 79190 additions and 79593 deletions

View File

@@ -1,11 +1,11 @@
--- ---
name: Widget decomposition and provider subscriptions name: Widget decomposition and provider subscriptions
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
type: feedback type: feedback
--- ---
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters. Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility. **Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down). **How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).

12
.gitignore vendored
View File

@@ -1,6 +1,6 @@
target/ target/
scripts/__pycache__/ scripts/__pycache__/
.DS_Store .DS_Store
.cargo/config.toml .cargo/config.toml
.vscode/ .vscode/
docs/superpowers docs/superpowers

View File

@@ -1,26 +1,26 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: audit - name: audit
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain and test runner to speed up the CI # Install only the necessary Rust toolchain and test runner to speed up the CI
- mise install rust - mise install rust
- mise install cargo:cargo-audit - mise install cargo:cargo-audit
- mise exec cargo:cargo-audit -- cargo audit - mise exec cargo:cargo-audit -- cargo audit

View File

@@ -0,0 +1,45 @@
when:
- event: push
branch: main
path:
include: ['.woodpecker/server-*.yaml', 'server/**']
steps:
- name: clippy-fix
image: jdxcode/mise:latest
directory: server
environment:
CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry
GITEA_TOKEN:
from_secret: GITEA_TOKEN
GITEA_URL:
from_secret: GITEA_URL
volumes:
- cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry
commands:
- apt-get update && apt-get install -y pkg-config curl
- mise install rust
- mise install protoc
- mise exec rust -- cargo clippy --fix --allow-dirty --all
- |
cd ..
if git diff --quiet HEAD; then
echo "No machine-applicable clippy fixes found, nothing to do."
exit 0
fi
git config user.email "bot@arbiter"
git config user.name "Clippy Bot"
git add -A
git commit -m "fix(clippy): apply auto-fixable linting suggestions"
git config http.extraHeader "Authorization: token ${GITEA_TOKEN}"
git push --force origin HEAD:refs/heads/bot/clippy-fixes
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${GITEA_URL}/api/v1/repos/${CI_REPO}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"title\":\"fix(clippy): apply auto-fixable linting suggestions\",\"head\":\"bot/clippy-fixes\",\"base\":\"main\",\"body\":\"Automated clippy fixes generated by CI bot.\"}")
echo "Gitea API response: ${HTTP_STATUS}"
[ "$HTTP_STATUS" = "201" ] || [ "$HTTP_STATUS" = "409" ] || [ "$HTTP_STATUS" = "422" ] || exit 1

View File

@@ -1,25 +1,25 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: lint - name: lint
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
- mise install rust - mise install rust
- mise install protoc - mise install protoc
- mise exec rust -- cargo clippy --all -- -D warnings - mise exec rust -- cargo clippy --all -- -D warnings

View File

@@ -1,27 +1,27 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: test - name: test
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain and test runner to speed up the CI # Install only the necessary Rust toolchain and test runner to speed up the CI
- mise install rust - mise install rust
- mise install protoc - mise install protoc
- mise install cargo:cargo-nextest - mise install cargo:cargo-nextest
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features - mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features

View File

@@ -1,26 +1,26 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/server-*.yaml', 'server/**'] include: ['.woodpecker/server-*.yaml', 'server/**']
steps: steps:
- name: vet - name: vet
image: jdxcode/mise:latest image: jdxcode/mise:latest
directory: server directory: server
environment: environment:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_TARGET_DIR: /usr/local/cargo/target CARGO_TARGET_DIR: /usr/local/cargo/target
CARGO_HOME: /usr/local/cargo/registry CARGO_HOME: /usr/local/cargo/registry
volumes: volumes:
- cargo-target:/usr/local/cargo/target - cargo-target:/usr/local/cargo/target
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
commands: commands:
- apt-get update && apt-get install -y pkg-config - apt-get update && apt-get install -y pkg-config
# Install only the necessary Rust toolchain and test runner to speed up the CI # Install only the necessary Rust toolchain and test runner to speed up the CI
- mise install rust - mise install rust
- mise install cargo:cargo-vet - mise install cargo:cargo-vet
- mise exec cargo:cargo-vet -- cargo vet - mise exec cargo:cargo-vet -- cargo vet

View File

@@ -1,18 +1,18 @@
when: when:
- event: pull_request - event: pull_request
path: path:
include: ['.woodpecker/useragent-*.yaml', 'useragent/**'] include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
- event: push - event: push
branch: main branch: main
path: path:
include: ['.woodpecker/useragent-*.yaml', 'useragent/**'] include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
steps: steps:
- name: analyze - name: analyze
image: jdxcode/mise:latest image: jdxcode/mise:latest
commands: commands:
- mise install flutter - mise install flutter
- mise install protoc - mise install protoc
# Reruns codegen to catch protocol drift # Reruns codegen to catch protocol drift
- mise codegen - mise codegen
- cd useragent/ && flutter analyze - cd useragent/ && flutter analyze

298
AGENTS.md
View File

@@ -1,149 +1,149 @@
# AGENTS.md # AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of: Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies - **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`operator/`** — 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 - **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies. The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
## Toolchain Setup ## Toolchain Setup
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools: Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
```sh ```sh
mise install mise install
``` ```
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite). 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/`) ## Server (Rust workspace at `server/`)
### Crates ### Crates
| Crate | Purpose | | Crate | Purpose |
|---|---| |---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` | | `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-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-operator` | Rust client library for the operator 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 | | `arbiter-client` | Rust client library for SDK clients |
### Common Commands ### Common Commands
```sh ```sh
cd server cd server
# Build # Build
cargo build cargo build
# Run the server daemon # Run the server daemon
cargo run -p arbiter-server cargo run -p arbiter-server
# Run all tests (preferred over cargo test) # Run all tests (preferred over cargo test)
cargo nextest run cargo nextest run
# Run a single test # Run a single test
cargo nextest run <test_name> cargo nextest run <test_name>
# Lint # Lint
cargo clippy cargo clippy
# Security audit # Security audit
cargo audit cargo audit
# Check unused dependencies # Check unused dependencies
cargo shear cargo shear
# Run snapshot tests and update snapshots # Run snapshot tests and update snapshots
cargo insta review cargo insta review
``` ```
### Architecture ### Architecture
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. - **`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 operators and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/operator/` 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()`. **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:** **Cryptography:**
- Authentication: ed25519 (challenge-response, nonce-tracked per peer) - Authentication: ed25519 (challenge-response, nonce-tracked per peer)
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal) - Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
- Password KDF: Argon2 - Password KDF: Argon2
- Unseal transport: X25519 ephemeral key exchange - Unseal transport: X25519 ephemeral key exchange
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl` - 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). **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 ### Proto Regeneration
When `.proto` files in `protobufs/` change, rebuild to regenerate: When `.proto` files in `protobufs/` change, rebuild to regenerate:
```sh ```sh
cd server && cargo build -p arbiter-proto cd server && cargo build -p arbiter-proto
``` ```
### Database Migrations ### Database Migrations
```sh ```sh
# Create a new migration # Create a new migration
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
# Run migrations manually (server also runs them on startup) # Run migrations manually (server also runs them on startup)
diesel migration run --migration-dir crates/arbiter-server/migrations diesel migration run --migration-dir crates/arbiter-server/migrations
``` ```
### Code Conventions ### Code Conventions
**`#[must_use]` Attribute:** **`#[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: 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 - Methods that return `bool` indicating success/failure or validation state
- Any function where ignoring the return value indicates a logic error - 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]`. Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
Example: Example:
```rust ```rust
#[must_use] #[must_use]
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool { pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
// verification logic // verification logic
} }
``` ```
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures. This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
## Operator (Flutter + Rinf at `operator/`) ## Operator (Flutter + Rinf at `operator/`)
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. 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 `operator/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 ```sh
cd operator && rinf gen cd operator && rinf gen
``` ```
### Common Commands ### Common Commands
```sh ```sh
cd operator cd operator
# Run the app (macOS or Windows) # Run the app (macOS or Windows)
flutter run flutter run
# Regenerate Rust↔Dart signal bindings # Regenerate Rust↔Dart signal bindings
rinf gen rinf gen
# Analyze Dart code # Analyze Dart code
flutter analyze flutter analyze
``` ```
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. 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.

View File

@@ -1 +1 @@
Refer to @AGENTS.md for instructions. Refer to @AGENTS.md for instructions.

380
LICENSE
View File

@@ -1,190 +1,190 @@
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
http://www.apache.org/licenses/ http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions. 1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, "License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document. and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by "Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License. the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition, control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the "control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity. outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity "You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License. exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, "Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation including but not limited to software source code, documentation
source, and configuration files. source, and configuration files.
"Object" form shall mean any form resulting from mechanical "Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation, not limited to compiled object code, generated documentation,
and conversions to other media types. and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or "Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work copyright notice that is included in or attached to the work
(an example is provided in the Appendix below). (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object "Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of, separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof. the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including "Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted" the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution." designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity "Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work. subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of 2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual, this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of, copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form. Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of 3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual, this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, (except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work, use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate granted to You under this License for that Work shall terminate
as of the date such litigation is filed. as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the 4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You modifications, and in Source or Object form, provided that You
meet the following conditions: meet the following conditions:
(a) You must give any other recipients of the Work or (a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices (b) You must cause any modified files to carry prominent notices
stating that You changed the files; and stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works (c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work, attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of excluding those notices that do not pertain to any part of
the Derivative Works; and the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its (d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or, documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed that such additional attribution notices cannot be construed
as modifying the License. as modifying the License.
You may add Your own copyright statement to Your modifications and You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use, for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License. the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, 5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions. this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions. with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade 6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor, names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file. origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or 7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS, Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License. risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, 8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise, whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill, Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages. has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing 9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer, the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity, and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify, of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability. of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
Copyright 2026 MarketTakers Copyright 2026 MarketTakers
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.

View File

@@ -1,13 +1,13 @@
# Arbiter # Arbiter
> Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains > Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains
## Security warning ## Security warning
Arbiter can't meaningfully protect against host compromise. Potential attack flow: Arbiter can't meaningfully protect against host compromise. Potential attack flow:
- Attacker steals TLS keys from database - Attacker steals TLS keys from database
- Pretends to be server; just accepts operator challenge solutions - Pretends to be server; just accepts operator challenge solutions
- Pretend to be in sealed state and performing DH with client - Pretend to be in sealed state and performing DH with client
- Steals user password and derives seal key - Steals user password and derives seal key
While this attack is highly targetive, it's still possible. While this attack is highly targetive, it's still possible.
> This software is experimental. Do not use with funds you cannot afford to lose. > This software is experimental. Do not use with funds you cannot afford to lose.

View File

@@ -1,31 +1,31 @@
Extension Discovery Cache Extension Discovery Cache
========================= =========================
This folder is used by `package:extension_discovery` to cache lists of This folder is used by `package:extension_discovery` to cache lists of
packages that contains extensions for other packages. packages that contains extensions for other packages.
DO NOT USE THIS FOLDER DO NOT USE THIS FOLDER
---------------------- ----------------------
* Do not read (or rely) the contents of this folder. * Do not read (or rely) the contents of this folder.
* Do write to this folder. * Do write to this folder.
If you're interested in the lists of extensions stored in this folder use the If you're interested in the lists of extensions stored in this folder use the
API offered by package `extension_discovery` to get this information. API offered by package `extension_discovery` to get this information.
If this package doesn't work for your use-case, then don't try to read the If this package doesn't work for your use-case, then don't try to read the
contents of this folder. It may change, and will not remain stable. contents of this folder. It may change, and will not remain stable.
Use package `extension_discovery` Use package `extension_discovery`
--------------------------------- ---------------------------------
If you want to access information from this folder. If you want to access information from this folder.
Feel free to delete this folder Feel free to delete this folder
------------------------------- -------------------------------
Files in this folder act as a cache, and the cache is discarded if the files Files in this folder act as a cache, and the cache is discarded if the files
are older than the modification time of `.dart_tool/package_config.json`. are older than the modification time of `.dart_tool/package_config.json`.
Hence, it should never be necessary to clear this cache manually, if you find a Hence, it should never be necessary to clear this cache manually, if you find a
need to do please file a bug. need to do please file a bug.

View File

@@ -1,178 +1,178 @@
{ {
"configVersion": 2, "configVersion": 2,
"packages": [ "packages": [
{ {
"name": "async", "name": "async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "boolean_selector", "name": "boolean_selector",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "characters", "name": "characters",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "clock", "name": "clock",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "collection", "name": "collection",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "cupertino_icons", "name": "cupertino_icons",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "fake_async", "name": "fake_async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{ {
"name": "flutter", "name": "flutter",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter", "rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "flutter_lints", "name": "flutter_lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "flutter_test", "name": "flutter_test",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test", "rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "leak_tracker", "name": "leak_tracker",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
{ {
"name": "leak_tracker_flutter_testing", "name": "leak_tracker_flutter_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
{ {
"name": "leak_tracker_testing", "name": "leak_tracker_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
{ {
"name": "lints", "name": "lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "matcher", "name": "matcher",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "material_color_utilities", "name": "material_color_utilities",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "2.17" "languageVersion": "2.17"
}, },
{ {
"name": "meta", "name": "meta",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{ {
"name": "path", "name": "path",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "sky_engine", "name": "sky_engine",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine", "rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.8" "languageVersion": "3.8"
}, },
{ {
"name": "source_span", "name": "source_span",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "stack_trace", "name": "stack_trace",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{ {
"name": "stream_channel", "name": "stream_channel",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{ {
"name": "string_scanner", "name": "string_scanner",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "term_glyph", "name": "term_glyph",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "test_api", "name": "test_api",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{ {
"name": "vector_math", "name": "vector_math",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{ {
"name": "vm_service", "name": "vm_service",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2", "rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{ {
"name": "app", "name": "app",
"rootUri": "../", "rootUri": "../",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.10" "languageVersion": "3.10"
} }
], ],
"generator": "pub", "generator": "pub",
"generatorVersion": "3.10.8", "generatorVersion": "3.10.8",
"flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable", "flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable",
"flutterVersion": "3.38.9", "flutterVersion": "3.38.9",
"pubCache": "file:///Users/kaska/.pub-cache" "pubCache": "file:///Users/kaska/.pub-cache"
} }

View File

@@ -1,230 +1,230 @@
{ {
"roots": [ "roots": [
"app" "app"
], ],
"packages": [ "packages": [
{ {
"name": "app", "name": "app",
"version": "1.0.0+1", "version": "1.0.0+1",
"dependencies": [ "dependencies": [
"cupertino_icons", "cupertino_icons",
"flutter" "flutter"
], ],
"devDependencies": [ "devDependencies": [
"flutter_lints", "flutter_lints",
"flutter_test" "flutter_test"
] ]
}, },
{ {
"name": "flutter_lints", "name": "flutter_lints",
"version": "6.0.0", "version": "6.0.0",
"dependencies": [ "dependencies": [
"lints" "lints"
] ]
}, },
{ {
"name": "flutter_test", "name": "flutter_test",
"version": "0.0.0", "version": "0.0.0",
"dependencies": [ "dependencies": [
"clock", "clock",
"collection", "collection",
"fake_async", "fake_async",
"flutter", "flutter",
"leak_tracker_flutter_testing", "leak_tracker_flutter_testing",
"matcher", "matcher",
"meta", "meta",
"path", "path",
"stack_trace", "stack_trace",
"stream_channel", "stream_channel",
"test_api", "test_api",
"vector_math" "vector_math"
] ]
}, },
{ {
"name": "cupertino_icons", "name": "cupertino_icons",
"version": "1.0.8", "version": "1.0.8",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "flutter", "name": "flutter",
"version": "0.0.0", "version": "0.0.0",
"dependencies": [ "dependencies": [
"characters", "characters",
"collection", "collection",
"material_color_utilities", "material_color_utilities",
"meta", "meta",
"sky_engine", "sky_engine",
"vector_math" "vector_math"
] ]
}, },
{ {
"name": "lints", "name": "lints",
"version": "6.1.0", "version": "6.1.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "stream_channel", "name": "stream_channel",
"version": "2.1.4", "version": "2.1.4",
"dependencies": [ "dependencies": [
"async" "async"
] ]
}, },
{ {
"name": "meta", "name": "meta",
"version": "1.17.0", "version": "1.17.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "collection", "name": "collection",
"version": "1.19.1", "version": "1.19.1",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "leak_tracker_flutter_testing", "name": "leak_tracker_flutter_testing",
"version": "3.0.10", "version": "3.0.10",
"dependencies": [ "dependencies": [
"flutter", "flutter",
"leak_tracker", "leak_tracker",
"leak_tracker_testing", "leak_tracker_testing",
"matcher", "matcher",
"meta" "meta"
] ]
}, },
{ {
"name": "vector_math", "name": "vector_math",
"version": "2.2.0", "version": "2.2.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "stack_trace", "name": "stack_trace",
"version": "1.12.1", "version": "1.12.1",
"dependencies": [ "dependencies": [
"path" "path"
] ]
}, },
{ {
"name": "clock", "name": "clock",
"version": "1.1.2", "version": "1.1.2",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "fake_async", "name": "fake_async",
"version": "1.3.3", "version": "1.3.3",
"dependencies": [ "dependencies": [
"clock", "clock",
"collection" "collection"
] ]
}, },
{ {
"name": "path", "name": "path",
"version": "1.9.1", "version": "1.9.1",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "matcher", "name": "matcher",
"version": "0.12.17", "version": "0.12.17",
"dependencies": [ "dependencies": [
"async", "async",
"meta", "meta",
"stack_trace", "stack_trace",
"term_glyph", "term_glyph",
"test_api" "test_api"
] ]
}, },
{ {
"name": "test_api", "name": "test_api",
"version": "0.7.7", "version": "0.7.7",
"dependencies": [ "dependencies": [
"async", "async",
"boolean_selector", "boolean_selector",
"collection", "collection",
"meta", "meta",
"source_span", "source_span",
"stack_trace", "stack_trace",
"stream_channel", "stream_channel",
"string_scanner", "string_scanner",
"term_glyph" "term_glyph"
] ]
}, },
{ {
"name": "sky_engine", "name": "sky_engine",
"version": "0.0.0", "version": "0.0.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "material_color_utilities", "name": "material_color_utilities",
"version": "0.11.1", "version": "0.11.1",
"dependencies": [ "dependencies": [
"collection" "collection"
] ]
}, },
{ {
"name": "characters", "name": "characters",
"version": "1.4.0", "version": "1.4.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "async", "name": "async",
"version": "2.13.0", "version": "2.13.0",
"dependencies": [ "dependencies": [
"collection", "collection",
"meta" "meta"
] ]
}, },
{ {
"name": "leak_tracker_testing", "name": "leak_tracker_testing",
"version": "3.0.2", "version": "3.0.2",
"dependencies": [ "dependencies": [
"leak_tracker", "leak_tracker",
"matcher", "matcher",
"meta" "meta"
] ]
}, },
{ {
"name": "leak_tracker", "name": "leak_tracker",
"version": "11.0.2", "version": "11.0.2",
"dependencies": [ "dependencies": [
"clock", "clock",
"collection", "collection",
"meta", "meta",
"path", "path",
"vm_service" "vm_service"
] ]
}, },
{ {
"name": "term_glyph", "name": "term_glyph",
"version": "1.2.2", "version": "1.2.2",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "string_scanner", "name": "string_scanner",
"version": "1.4.1", "version": "1.4.1",
"dependencies": [ "dependencies": [
"source_span" "source_span"
] ]
}, },
{ {
"name": "source_span", "name": "source_span",
"version": "1.10.2", "version": "1.10.2",
"dependencies": [ "dependencies": [
"collection", "collection",
"path", "path",
"term_glyph" "term_glyph"
] ]
}, },
{ {
"name": "boolean_selector", "name": "boolean_selector",
"version": "2.1.2", "version": "2.1.2",
"dependencies": [ "dependencies": [
"source_span", "source_span",
"string_scanner" "string_scanner"
] ]
}, },
{ {
"name": "vm_service", "name": "vm_service",
"version": "15.0.2", "version": "15.0.2",
"dependencies": [] "dependencies": []
} }
], ],
"configVersion": 1 "configVersion": 1
} }

View File

@@ -1,11 +1,11 @@
// This is a generated file; do not edit or check into version control. // This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0 FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1 FLUTTER_BUILD_NUMBER=1
DART_OBFUSCATION=false DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -1,12 +1,12 @@
#!/bin/sh #!/bin/sh
# This is a generated file; do not edit or check into version control. # This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable" export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable"
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app" export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0" export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1" export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false" export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json" export "PACKAGE_CONFIG=.dart_tool/package_config.json"

View File

@@ -1,334 +1,334 @@
# Arbiter # Arbiter
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management. Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies. **Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
--- ---
## 1. Peer Types ## 1. Peer Types
Arbiter distinguishes two kinds of peers: Arbiter distinguishes two kinds of peers:
- **Operator** — 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. - **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. - **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
--- ---
## 2. Authentication ## 2. Authentication
### 2.1 Challenge-Response ### 2.1 Challenge-Response
All peers authenticate via public-key cryptography using a challenge-response protocol: All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge. 1. The peer sends its public key and requests a challenge.
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp. 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. 3. The peer signs the canonical challenge payload with its private key and sends the signature back.
4. The server verifies the signature: 4. The server verifies the signature:
- **Pass:** The connection is considered authenticated. - **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection. - **Fail:** The server closes the connection.
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state. Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
### 2.2 Operator Bootstrap ### 2.2 Operator Bootstrap
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways: 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. - **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
- **Remote setup:** Printed to the server's console output. - **Remote setup:** Printed to the server's console output.
The first Operator 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 ### 2.3 SDK Client Registration
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator. There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
--- ---
## 3. Multi-Operator Governance ## 3. Multi-Operator Governance
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. 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 ### 3.1 Voting Rules
Voting is based on the total number of registered operators: Voting is based on the total number of registered operators:
- **1 operator:** no vote is needed; the single operator decides directly. - **1 operator:** no vote is needed; the single operator decides directly.
- **2 operators:** full consensus is required; both operators must approve. - **2 operators:** full consensus is required; both operators must approve.
- **3 or more operators:** quorum is `floor(N / 2) + 1`. - **3 or more operators:** quorum is `floor(N / 2) + 1`.
For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored. For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored.
Examples: Examples:
- **3 operators:** 2 approvals required - **3 operators:** 2 approvals required
- **4 operators:** 3 approvals required - **4 operators:** 3 approvals required
### 3.2 Actions Requiring a Vote ### 3.2 Actions Requiring a Vote
In multi-operator mode, a successful vote is required for: In multi-operator mode, a successful vote is required for:
- approving new SDK clients - approving new SDK clients
- granting an SDK client visibility to a wallet - granting an SDK client visibility to a wallet
- approving a one-off transaction - approving a one-off transaction
- approving creation of a persistent grant - approving creation of a persistent grant
- approving operator replacement - approving operator replacement
- approving server updates - approving server updates
- updating Shamir secret-sharing parameters - updating Shamir secret-sharing parameters
### 3.3 Special Rule for Key Rotation ### 3.3 Special Rule for Key Rotation
Key rotation always requires full quorum, regardless of the normal voting threshold. Key rotation always requires full quorum, regardless of the normal voting threshold.
This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently. This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently.
### 3.4 Root Key Custody ### 3.4 Root Key Custody
When the vault has multiple operators, the vault root key is protected using Shamir secret sharing. When the vault has multiple operators, the vault root key is protected using Shamir secret sharing.
The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password. The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password.
For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example: For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example:
- **2 operators:** `2-of-2` - **2 operators:** `2-of-2`
- **3 operators:** `2-of-3` - **3 operators:** `2-of-3`
- **4 operators:** `3-of-4` - **4 operators:** `3-of-4`
In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example: In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example:
- **3 ordinary operators + 2 recovery shares:** `2-of-5` - **3 ordinary operators + 2 recovery shares:** `2-of-5`
This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows. This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows.
### 3.5 Recovery Operators ### 3.5 Recovery Operators
Recovery Operators are a separate peer type from ordinary vault operators. Recovery Operators are a separate peer type from ordinary vault operators.
Their role is intentionally narrow. They can only: Their role is intentionally narrow. They can only:
- participate in unsealing the vault - participate in unsealing the vault
- vote for operator replacement - vote for operator replacement
Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters. Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters.
### 3.6 Sleeping and Waking Recovery Operators ### 3.6 Sleeping and Waking Recovery Operators
By default, Recovery Operators are **sleeping** and do not participate in any active flow. By default, Recovery Operators are **sleeping** and do not participate in any active flow.
Any ordinary operator may request that Recovery Operators **wake up**. Any ordinary operator may request that Recovery Operators **wake up**.
Any ordinary operator may also cancel a pending wake-up request. Any ordinary operator may also cancel a pending wake-up request.
This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**. This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**.
Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum. Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum.
The high-level recovery flow is: The high-level recovery flow is:
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor Op as Ordinary Operator actor Op as Ordinary Operator
participant Server participant Server
actor Other as Other Operator actor Other as Other Operator
actor Rec as Recovery Operator actor Rec as Recovery Operator
Op->>Server: Request recovery wake-up Op->>Server: Request recovery wake-up
Server-->>Op: Wake-up pending Server-->>Op: Wake-up pending
Note over Server: Default dispute window: 14 days Note over Server: Default dispute window: 14 days
alt Wake-up cancelled during dispute window alt Wake-up cancelled during dispute window
Other->>Server: Cancel wake-up Other->>Server: Cancel wake-up
Server-->>Op: Recovery cancelled Server-->>Op: Recovery cancelled
Server-->>Rec: Stay sleeping Server-->>Rec: Stay sleeping
else No cancellation for 14 days else No cancellation for 14 days
Server-->>Rec: Wake up Server-->>Rec: Wake up
Rec->>Server: Join recovery flow Rec->>Server: Join recovery flow
critical Recovery authority critical Recovery authority
Rec->>Server: Participate in unseal Rec->>Server: Participate in unseal
Rec->>Server: Vote on operator replacement Rec->>Server: Vote on operator replacement
end end
Server-->>Op: Recovery mode active Server-->>Op: Recovery mode active
end end
``` ```
### 3.7 Committee Formation ### 3.7 Committee Formation
There are two ways to form a multi-operator committee: There are two ways to form a multi-operator committee:
- convert an existing single-operator vault by adding new operators - convert an existing single-operator vault by adding new operators
- bootstrap an unbootstrapped vault directly into multi-operator mode - bootstrap an unbootstrapped vault directly into multi-operator mode
In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations. In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations.
### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode ### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows: 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 an Operator 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: 2. During bootstrap setup, that operator declares:
- the total number of ordinary operators - the total number of ordinary operators
- the total number of Recovery Operators - the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**. 3. The vault enters **multi-bootstrap mode**.
4. While in multi-bootstrap mode: 4. While in multi-bootstrap mode:
- every ordinary operator must connect with an Operator 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 - every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually - each participant is registered individually
- each participant's share is created and protected with that participant's credentials - each participant's share is created and protected with that participant's credentials
5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully. 5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully.
This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered. This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered.
### 3.9 Special Bootstrap Constraint for Two-Operator Vaults ### 3.9 Special Bootstrap Constraint for Two-Operator Vaults
If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap. If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap.
This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator. This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator.
--- ---
## 4. Server Identity ## 4. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination. The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
Peers verify the server by its **public key fingerprint**: Peers verify the server by its **public key fingerprint**:
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token. - **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band. - **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. > A streamlined setup mechanism using a single connection string is planned but not yet implemented.
--- ---
## 5. Key Management ## 5. Key Management
### 5.1 Key Hierarchy ### 5.1 Key Hierarchy
There are three layers of keys: There are three layers of keys:
| Key | Encrypts | Encrypted by | | Key | Encrypts | Encrypted by |
|---|---|---| |---|---|---|
| **User key** (password) | Root key | — (derived from user input) | | **User key** (password) | Root key | — (derived from user input) |
| **Root key** | Wallet keys | User key | | **Root key** | Wallet keys | User key |
| **Wallet keys** | — (used for signing) | Root key | | **Wallet keys** | — (used for signing) | Root key |
This layered design enables: This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted). - **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password. - **Root key rotation** without requiring the user to change their password.
### 5.2 Encryption at Rest ### 5.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism. The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
--- ---
## 6. Vault Lifecycle ## 6. Vault Lifecycle
### 6.1 Sealed State ### 6.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**. On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 6.2 Unseal Flow ### 6.2 Unseal Flow
To transition to the **Unsealed** state, an Operator must provide the password: To transition to the **Unsealed** state, an Operator must provide the password:
1. The Operator 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. 2. The server generates a one-time key pair and returns the public key.
3. The Operator 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: 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. - **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect. - **Failure:** The server returns an error indicating the password is incorrect.
### 6.3 Memory Protection ### 6.3 Memory Protection
Once unsealed, the root key must be protected in memory against: Once unsealed, the root key must be protected in memory against:
- Memory dumps - Memory dumps
- Page swaps to disk - Page swaps to disk
- Hibernation files - Hibernation files
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
--- ---
## 7. Permission Engine ## 7. Permission Engine
### 7.1 Fundamental Rules ### 7.1 Fundamental Rules
- SDK clients have **no access by default**. - SDK clients have **no access by default**.
- Access is granted **explicitly** by an Operator. - Access is granted **explicitly** by an Operator.
- Grants are scoped to **specific wallets** and governed by **policies**. - 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. Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
Arbiter is also responsible for ensuring that **transaction nonces are never reused**. Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 7.2 EVM Policies ### 7.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**. Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 7.2.0 Transaction Signing Sequence #### 7.2.0 Transaction Signing Sequence
The high-level interaction order is: The high-level interaction order is:
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
actor SDK as SDK Client actor SDK as SDK Client
participant Server participant Server
participant operator as Operator participant operator as Operator
SDK->>Server: SignTransactionRequest SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required alt Visibility approval required
Server->>operator: Ask for wallet visibility approval Server->>operator: Ask for wallet visibility approval
operator-->>Server: Vote result operator-->>Server: Vote result
end end
Server->>Server: Evaluate transaction Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context Server->>Server: Load grant and limits context
alt Grant approval required alt Grant approval required
Server->>operator: Ask for execution / grant approval Server->>operator: Ask for execution / grant approval
operator-->>Server: Vote result operator-->>Server: Vote result
opt Create persistent grant opt Create persistent grant
Server->>Server: Create and store grant Server->>Server: Create and store grant
end end
Server->>Server: Retry evaluation Server->>Server: Retry evaluation
end end
critical Final authorization path critical Final authorization path
Server->>Server: Check limits and record execution Server->>Server: Check limits and record execution
Server-->>Server: Signature or evaluation error Server-->>Server: Signature or evaluation error
end end
Server-->>SDK: Signature or error Server-->>SDK: Signature or error
``` ```
#### 7.2.1 Transaction Sub-Grants #### 7.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories: Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
**1. Known contract (ABI available)** **1. Known contract (ABI available)**
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."* The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
Available restrictions: Available restrictions:
- Volume limits (e.g., "no more than 10,000 tokens ever") - Volume limits (e.g., "no more than 10,000 tokens ever")
- Rate limits (e.g., "no more than 100 tokens per hour") - Rate limits (e.g., "no more than 100 tokens per hour")
**2. Unknown contract (no ABI)** **2. Unknown contract (no ABI)**
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field). The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
Available restrictions: Available restrictions:
- Transaction count limits (e.g., "no more than 100 transactions ever") - Transaction count limits (e.g., "no more than 100 transactions ever")
- Rate limits (e.g., "no more than 5 transactions per hour") - Rate limits (e.g., "no more than 5 transactions per hour")
**3. Plain ether transfer (no calldata)** **3. Plain ether transfer (no calldata)**
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above. These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 7.2.2 Global Limits #### 7.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types: In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction. - **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays. - **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

View File

@@ -1,226 +1,226 @@
# Implementation Details # Implementation Details
This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md). This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md).
--- ---
## Client Connection Flow ## Client Connection Flow
### Authentication Result Semantics ### Authentication Result Semantics
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` 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_OPERATORS_ONLINE`, 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` - **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: This makes transport-level failures and actor/domain-level auth failures distinct:
- **Transport/protocol failures** are surfaced as stream/status errors - **Transport/protocol failures** are surfaced as stream/status errors
- **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status - **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status
Clients are expected to handle these status codes directly and present the concrete failure reason to the user. Clients are expected to handle these status codes directly and present the concrete failure reason to the user.
### New Client Approval ### New Client Approval
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. 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 ```mermaid
flowchart TD flowchart TD
A([Client connects]) --> B[Receive AuthChallengeRequest] A([Client connects]) --> B[Receive AuthChallengeRequest]
B --> C{pubkey in DB?} B --> C{pubkey in DB?}
C -- yes --> G[Generate AuthChallenge] C -- yes --> G[Generate AuthChallenge]
C -- no --> E[Ask all Operators:\nClientConnectionRequest] C -- no --> E[Ask all Operators:\nClientConnectionRequest]
E --> F{First response} E --> F{First response}
F -- denied --> Z([Reject connection]) F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nOperator requests] F -- approved --> F2[Cancel remaining\nOperator requests]
F2 --> F3[INSERT client] F2 --> F3[INSERT client]
F3 --> G F3 --> G
G --> H[Send AuthChallenge\ntimestamp + random bytes] G --> H[Send AuthChallenge\ntimestamp + random bytes]
H --> I[Receive AuthChallengeSolution] H --> I[Receive AuthChallengeSolution]
I --> K{Signature valid?} I --> K{Signature valid?}
K -- no --> Z K -- no --> Z
K -- yes --> J([Session started]) K -- yes --> J([Session started])
``` ```
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. 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: The authentication schema stores peer identity, not replay counters:
- `program_client` stores the SDK client's public key, metadata binding, and timestamps. - `program_client` stores the SDK client's public key, metadata binding, and timestamps.
- `operator_client` stores the Operator public key 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. - Neither table stores an authentication nonce, and challenge generation does not update either table.
--- ---
## Cryptography ## Cryptography
### Authentication ### Authentication
- **Client protocol:** ML-DSA - **Client protocol:** ML-DSA
### User-Agent Authentication ### User-Agent Authentication
Operator 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 - **Supported schemes:** ML-DSA
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out - **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
### Encryption at Rest ### Encryption at Rest
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305** - **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
- **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal - **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal
### Server Identity ### Server Identity
- **Transport:** TLS with a self-signed certificate - **Transport:** TLS with a self-signed certificate
- **Key type:** Generated on first run; long-term (no rotation mechanism yet) - **Key type:** Generated on first run; long-term (no rotation mechanism yet)
--- ---
## Communication ## Communication
- **Protocol:** gRPC with Protocol Buffers - **Protocol:** gRPC with Protocol Buffers
- **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs - **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs
- **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint - **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
- **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket - **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket
### Request Multiplexing ### Request Multiplexing
Both `client` and `operator` 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 request carries a monotonically increasing request ID
- Every normal response echoes the request ID it corresponds to - Every normal response echoes the request ID it corresponds to
- Out-of-band server messages omit the response ID entirely - Out-of-band server messages omit the response ID entirely
- The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message - The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message
This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs. This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs.
--- ---
## EVM Policy Engine ## EVM Policy Engine
### Overview ### Overview
The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing. The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing.
The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging. The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging.
### Transaction Evaluation Flow ### Transaction Evaluation Flow
`Engine::evaluate_transaction` runs the following steps in order: `Engine::evaluate_transaction` runs the following steps in order:
1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`. 1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`.
2. **Find grant**`Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address. 2. **Find grant**`Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address.
3. **Check shared constraints**`check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below). 3. **Check shared constraints**`check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below).
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations. 4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume). 5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
The detailed branch structure is shown below: The detailed branch structure is shown below:
```mermaid ```mermaid
flowchart TD flowchart TD
A[SDK Client sends sign transaction request] --> B[Server resolves wallet] A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
B --> C{Wallet exists?} B --> C{Wallet exists?}
C -- No --> Z1[Return wallet not found error] C -- No --> Z1[Return wallet not found error]
C -- Yes --> D[Check SDK client wallet visibility] C -- Yes --> D[Check SDK client wallet visibility]
D --> E{Wallet visible to SDK client?} D --> E{Wallet visible to SDK client?}
E -- No --> F[Start wallet visibility voting flow] E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?} F --> G{Vote approved?}
G -- No --> Z2[Return wallet access denied error] G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility] G -- Yes --> H[Persist wallet visibility]
E -- Yes --> I[Classify transaction meaning] E -- Yes --> I[Classify transaction meaning]
H --> I H --> I
I --> J{Meaning supported?} I --> J{Meaning supported?}
J -- No --> Z3[Return unsupported transaction error] J -- No --> Z3[Return unsupported transaction error]
J -- Yes --> K[Find matching grant] J -- Yes --> K[Find matching grant]
K --> L{Grant exists?} K --> L{Grant exists?}
L -- Yes --> M[Check grant limits] L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow] L -- No --> N[Start execution or grant voting flow]
N --> O{Operator decision} N --> O{Operator decision}
O -- Reject --> Z4[Return no matching grant error] O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits] O -- Create grant --> P[Create grant with user-selected limits]
P --> Q[Persist grant] P --> Q[Persist grant]
Q --> M Q --> M
M --> R{Limits exceeded?} M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error] R -- Yes --> Z5[Return evaluation error]
R -- No --> S[Record transaction in logs] R -- No --> S[Record transaction in logs]
S --> T[Produce signature] S --> T[Produce signature]
T --> U[Return signature to SDK client] T --> U[Return signature to SDK client]
note1[Limit checks include volume, count, and gas constraints.] note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.] note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
K -. uses .-> note2 K -. uses .-> note2
M -. checks .-> note1 M -. checks .-> note1
``` ```
### Policy Trait ### Policy Trait
| Method | Purpose | | Method | Purpose |
|---|---| |---|---|
| `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply | | `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply |
| `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s | | `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s |
| `create_grant` | Inserts policy-specific rows; returns the specific grant ID | | `create_grant` | Inserts policy-specific rows; returns the specific grant ID |
| `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` | | `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` |
| `find_all_grants` | Returns all non-revoked grants (used for listing) | | `find_all_grants` | Returns all non-revoked grants (used for listing) |
| `record_transaction` | Persists policy-specific data after execution | | `record_transaction` | Persists policy-specific data after execution |
`analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume). `analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume).
### Registered Policies ### Registered Policies
**EtherTransfer** — plain ETH transfers (empty calldata) **EtherTransfer** — plain ETH transfers (empty calldata)
- Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window) - Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window)
- Violations: recipient not in allowlist, cumulative ETH volume exceeded - Violations: recipient not in allowlist, cumulative ETH volume exceeded
**TokenTransfer** — ERC-20 `transfer(address,uint256)` calls **TokenTransfer** — ERC-20 `transfer(address,uint256)` calls
- Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`) - Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`)
- Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits - Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
- Violations: recipient mismatch, any volumetric limit exceeded - Violations: recipient mismatch, any volumetric limit exceeded
### Grant Model ### Grant Model
Every grant has two layers: Every grant has two layers:
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type. - **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration. - **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration.
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1. `find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion. The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion.
### Shared Constraints (enforced by the engine) ### Shared Constraints (enforced by the engine)
These are checked centrally in `check_shared_constraints` before policy evaluation: These are checked centrally in `check_shared_constraints` before policy evaluation:
| Constraint | Fields | Behaviour | | Constraint | Fields | Behaviour |
|---|---|---| |---|---|---|
| Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range | | Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range |
| Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached | | Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached |
| Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit | | Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit |
--- ---
### Known Limitations ### Known Limitations
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright. - **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright.
- **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected. - **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected.
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime. - **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime.
--- ---
## Memory Protection ## Memory Protection
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation. The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
- **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today - **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
- **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows) - **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,24 +1,24 @@
[tools] [tools]
"cargo:diesel_cli" = { version = "2.3.7", 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-audit" = "0.22.1"
"cargo:cargo-vet" = "0.10.2" "cargo:cargo-vet" = "0.10.2"
flutter = "3.41.7-stable" flutter = "3.41.7-stable"
protoc = "29.6" protoc = "29.6"
rust = { version = "1.95.0", components = "clippy,rust-analyzer" } rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
"cargo:cargo-features-manager" = "0.12.0" "cargo:cargo-features-manager" = "0.12.0"
"cargo:cargo-nextest" = "0.9.133" "cargo:cargo-nextest" = "0.9.133"
"cargo:cargo-shear" = "latest" "cargo:cargo-shear" = "latest"
"cargo:cargo-insta" = "1.47.2" "cargo:cargo-insta" = "1.47.2"
python = "3.14.4" python = "3.14.4"
ast-grep = "0.42.1" ast-grep = "0.42.1"
"cargo:cargo-edit" = "0.13.10" "cargo:cargo-edit" = "0.13.10"
"cargo:cargo-mutants" = "27.0.0" "cargo:cargo-mutants" = "27.0.0"
"cargo:flutter_rust_bridge_codegen" = "2.12.0" "cargo:flutter_rust_bridge_codegen" = "2.12.0"
[tasks.codegen] [tasks.codegen]
sources = ['protobufs/*.proto', 'protobufs/**/*.proto'] sources = ['protobufs/*.proto', 'protobufs/**/*.proto']
outputs = ['useragent/lib/proto/**'] outputs = ['useragent/lib/proto/**']
run = ''' run = '''
dart pub global activate protoc_plugin && \ dart pub global activate protoc_plugin && \
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort) protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
''' '''

View File

@@ -1,16 +1,16 @@
syntax = "proto3"; syntax = "proto3";
package arbiter; package arbiter;
import "client.proto"; import "client.proto";
import "operator.proto"; import "operator.proto";
message ServerInfo { message ServerInfo {
string version = 1; string version = 1;
bytes cert_public_key = 2; bytes cert_public_key = 2;
} }
service ArbiterService { service ArbiterService {
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse); rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse); rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
} }

View File

@@ -1,25 +1,25 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.client; package arbiter.client;
import "client/auth.proto"; import "client/auth.proto";
import "client/evm.proto"; import "client/evm.proto";
import "client/vault.proto"; import "client/vault.proto";
message ClientRequest { message ClientRequest {
int32 request_id = 4; int32 request_id = 4;
oneof payload { oneof payload {
auth.Request auth = 1; auth.Request auth = 1;
vault.Request vault = 2; vault.Request vault = 2;
evm.Request evm = 3; evm.Request evm = 3;
} }
} }
message ClientResponse { message ClientResponse {
optional int32 request_id = 7; optional int32 request_id = 7;
oneof payload { oneof payload {
auth.Response auth = 1; auth.Response auth = 1;
vault.Response vault = 2; vault.Response vault = 2;
evm.Response evm = 3; evm.Response evm = 3;
} }
} }

View File

@@ -1,43 +1,43 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.client.auth; package arbiter.client.auth;
import "shared/client.proto"; import "shared/client.proto";
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;
arbiter.shared.ClientInfo client_info = 2; arbiter.shared.ClientInfo client_info = 2;
} }
message AuthChallenge { message AuthChallenge {
uint64 timestamp_nanos = 1; uint64 timestamp_nanos = 1;
bytes random = 2; bytes random = 2;
} }
message AuthChallengeSolution { message AuthChallengeSolution {
bytes signature = 1; bytes signature = 1;
} }
enum AuthResult { enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0; AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1; AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2; AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3; AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4; AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_OPERATORS_ONLINE = 5; AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6; AUTH_RESULT_INTERNAL = 6;
} }
message Request { message Request {
oneof payload { oneof payload {
AuthChallengeRequest challenge_request = 1; AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2; AuthChallengeSolution challenge_solution = 2;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
AuthChallenge challenge = 1; AuthChallenge challenge = 1;
AuthResult result = 2; AuthResult result = 2;
} }
} }

View File

@@ -1,19 +1,19 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.client.evm; package arbiter.client.evm;
import "evm.proto"; import "evm.proto";
message Request { message Request {
oneof payload { oneof payload {
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1; arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2; arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1; arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2; arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
} }
} }

View File

@@ -1,18 +1,18 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.client.vault; package arbiter.client.vault;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "shared/vault.proto"; import "shared/vault.proto";
message Request { message Request {
oneof payload { oneof payload {
google.protobuf.Empty query_state = 1; google.protobuf.Empty query_state = 1;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
arbiter.shared.VaultState state = 1; arbiter.shared.VaultState state = 1;
} }
} }

View File

@@ -1,153 +1,153 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.evm; package arbiter.evm;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "shared/evm.proto"; import "shared/evm.proto";
enum EvmError { enum EvmError {
EVM_ERROR_UNSPECIFIED = 0; EVM_ERROR_UNSPECIFIED = 0;
EVM_ERROR_VAULT_SEALED = 1; EVM_ERROR_VAULT_SEALED = 1;
EVM_ERROR_INTERNAL = 2; EVM_ERROR_INTERNAL = 2;
} }
message WalletEntry { message WalletEntry {
int32 id = 1; int32 id = 1;
bytes address = 2; // 20-byte Ethereum address bytes address = 2; // 20-byte Ethereum address
} }
message WalletList { message WalletList {
repeated WalletEntry wallets = 1; repeated WalletEntry wallets = 1;
} }
message WalletCreateResponse { message WalletCreateResponse {
oneof result { oneof result {
WalletEntry wallet = 1; WalletEntry wallet = 1;
EvmError error = 2; EvmError error = 2;
} }
} }
message WalletListResponse { message WalletListResponse {
oneof result { oneof result {
WalletList wallets = 1; WalletList wallets = 1;
EvmError error = 2; EvmError error = 2;
} }
} }
// --- Grant types --- // --- Grant types ---
message TransactionRateLimit { message TransactionRateLimit {
uint32 count = 1; uint32 count = 1;
int64 window_secs = 2; int64 window_secs = 2;
} }
message VolumeRateLimit { message VolumeRateLimit {
bytes max_volume = 1; // U256 as big-endian bytes bytes max_volume = 1; // U256 as big-endian bytes
int64 window_secs = 2; int64 window_secs = 2;
} }
message SharedSettings { message SharedSettings {
int32 wallet_access_id = 1; int32 wallet_access_id = 1;
uint64 chain_id = 2; uint64 chain_id = 2;
optional google.protobuf.Timestamp valid_from = 3; optional google.protobuf.Timestamp valid_from = 3;
optional google.protobuf.Timestamp valid_until = 4; optional google.protobuf.Timestamp valid_until = 4;
optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes
optional TransactionRateLimit rate_limit = 7; optional TransactionRateLimit rate_limit = 7;
} }
message EtherTransferSettings { message EtherTransferSettings {
repeated bytes targets = 1; // list of 20-byte Ethereum addresses repeated bytes targets = 1; // list of 20-byte Ethereum addresses
VolumeRateLimit limit = 2; VolumeRateLimit limit = 2;
} }
message TokenTransferSettings { message TokenTransferSettings {
bytes token_contract = 1; // 20-byte Ethereum address bytes token_contract = 1; // 20-byte Ethereum address
optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed
repeated VolumeRateLimit volume_limits = 3; repeated VolumeRateLimit volume_limits = 3;
} }
message SpecificGrant { message SpecificGrant {
oneof grant { oneof grant {
EtherTransferSettings ether_transfer = 1; EtherTransferSettings ether_transfer = 1;
TokenTransferSettings token_transfer = 2; TokenTransferSettings token_transfer = 2;
} }
} }
// --- Operator grant management --- // --- Operator grant management ---
message EvmGrantCreateRequest { message EvmGrantCreateRequest {
SharedSettings shared = 1; SharedSettings shared = 1;
SpecificGrant specific = 2; SpecificGrant specific = 2;
} }
message EvmGrantCreateResponse { message EvmGrantCreateResponse {
oneof result { oneof result {
int32 grant_id = 1; int32 grant_id = 1;
EvmError error = 2; EvmError error = 2;
} }
} }
message EvmGrantDeleteRequest { message EvmGrantDeleteRequest {
int32 grant_id = 1; int32 grant_id = 1;
} }
message EvmGrantDeleteResponse { message EvmGrantDeleteResponse {
oneof result { oneof result {
google.protobuf.Empty ok = 1; google.protobuf.Empty ok = 1;
EvmError error = 2; EvmError error = 2;
} }
} }
// Basic grant info returned in grant listings // Basic grant info returned in grant listings
message GrantEntry { message GrantEntry {
int32 id = 1; int32 id = 1;
int32 wallet_access_id = 2; int32 wallet_access_id = 2;
SharedSettings shared = 3; SharedSettings shared = 3;
SpecificGrant specific = 4; SpecificGrant specific = 4;
} }
message EvmGrantListRequest { message EvmGrantListRequest {
optional int32 wallet_access_id = 1; optional int32 wallet_access_id = 1;
} }
message EvmGrantListResponse { message EvmGrantListResponse {
oneof result { oneof result {
EvmGrantList grants = 1; EvmGrantList grants = 1;
EvmError error = 2; EvmError error = 2;
} }
} }
message EvmGrantList { message EvmGrantList {
repeated GrantEntry grants = 1; repeated GrantEntry grants = 1;
} }
// --- Client transaction operations --- // --- Client transaction operations ---
message EvmSignTransactionRequest { message EvmSignTransactionRequest {
bytes wallet_address = 1; // 20-byte Ethereum address bytes wallet_address = 1; // 20-byte Ethereum address
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned) bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned)
} }
// oneof because signing and evaluation happen atomically — a signing failure // oneof because signing and evaluation happen atomically — a signing failure
// is always either an eval error or an internal error, never a partial success // is always either an eval error or an internal error, never a partial success
message EvmSignTransactionResponse { message EvmSignTransactionResponse {
oneof result { oneof result {
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1] bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
arbiter.shared.evm.TransactionEvalError eval_error = 2; arbiter.shared.evm.TransactionEvalError eval_error = 2;
EvmError error = 3; EvmError error = 3;
} }
} }
message EvmAnalyzeTransactionRequest { message EvmAnalyzeTransactionRequest {
bytes wallet_address = 1; // 20-byte Ethereum address bytes wallet_address = 1; // 20-byte Ethereum address
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
} }
message EvmAnalyzeTransactionResponse { message EvmAnalyzeTransactionResponse {
oneof result { oneof result {
arbiter.shared.evm.SpecificMeaning meaning = 1; arbiter.shared.evm.SpecificMeaning meaning = 1;
arbiter.shared.evm.TransactionEvalError eval_error = 2; arbiter.shared.evm.TransactionEvalError eval_error = 2;
EvmError error = 3; EvmError error = 3;
} }
} }

View File

@@ -1,28 +1,28 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator; package arbiter.operator;
import "operator/auth.proto"; import "operator/auth.proto";
import "operator/evm.proto"; import "operator/evm.proto";
import "operator/sdk_client.proto"; import "operator/sdk_client.proto";
import "operator/vault/vault.proto"; import "operator/vault/vault.proto";
message OperatorRequest { message OperatorRequest {
int32 id = 16; int32 id = 16;
oneof payload { oneof payload {
auth.Request auth = 1; auth.Request auth = 1;
vault.Request vault = 2; vault.Request vault = 2;
evm.Request evm = 3; evm.Request evm = 3;
sdk_client.Request sdk_client = 4; sdk_client.Request sdk_client = 4;
} }
} }
message OperatorResponse { message OperatorResponse {
optional int32 id = 16; optional int32 id = 16;
oneof payload { oneof payload {
auth.Response auth = 1; auth.Response auth = 1;
vault.Response vault = 2; vault.Response vault = 2;
evm.Response evm = 3; evm.Response evm = 3;
sdk_client.Response sdk_client = 4; sdk_client.Response sdk_client = 4;
} }
} }

View File

@@ -1,41 +1,41 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.auth; package arbiter.operator.auth;
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;
optional string bootstrap_token = 2; optional string bootstrap_token = 2;
} }
message AuthChallenge { message AuthChallenge {
uint64 timestamp_nanos = 1; uint64 timestamp_nanos = 1;
bytes random = 2; bytes random = 2;
} }
message AuthChallengeSolution { message AuthChallengeSolution {
bytes signature = 1; bytes signature = 1;
} }
enum AuthResult { enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0; AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1; AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2; AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3; AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4; AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5; AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6; AUTH_RESULT_INTERNAL = 6;
} }
message Request { message Request {
oneof payload { oneof payload {
AuthChallengeRequest challenge_request = 1; AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2; AuthChallengeSolution challenge_solution = 2;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
AuthChallenge challenge = 1; AuthChallenge challenge = 1;
AuthResult result = 2; AuthResult result = 2;
} }
} }

View File

@@ -1,33 +1,33 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.evm; package arbiter.operator.evm;
import "evm.proto"; import "evm.proto";
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
message SignTransactionRequest { message SignTransactionRequest {
int32 client_id = 1; int32 client_id = 1;
arbiter.evm.EvmSignTransactionRequest request = 2; arbiter.evm.EvmSignTransactionRequest request = 2;
} }
message Request { message Request {
oneof payload { oneof payload {
google.protobuf.Empty wallet_create = 1; google.protobuf.Empty wallet_create = 1;
google.protobuf.Empty wallet_list = 2; google.protobuf.Empty wallet_list = 2;
arbiter.evm.EvmGrantCreateRequest grant_create = 3; arbiter.evm.EvmGrantCreateRequest grant_create = 3;
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4; arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
arbiter.evm.EvmGrantListRequest grant_list = 5; arbiter.evm.EvmGrantListRequest grant_list = 5;
SignTransactionRequest sign_transaction = 6; SignTransactionRequest sign_transaction = 6;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
arbiter.evm.WalletCreateResponse wallet_create = 1; arbiter.evm.WalletCreateResponse wallet_create = 1;
arbiter.evm.WalletListResponse wallet_list = 2; arbiter.evm.WalletListResponse wallet_list = 2;
arbiter.evm.EvmGrantCreateResponse grant_create = 3; arbiter.evm.EvmGrantCreateResponse grant_create = 3;
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4; arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
arbiter.evm.EvmGrantListResponse grant_list = 5; arbiter.evm.EvmGrantListResponse grant_list = 5;
arbiter.evm.EvmSignTransactionResponse sign_transaction = 6; arbiter.evm.EvmSignTransactionResponse sign_transaction = 6;
} }
} }

View File

@@ -1,100 +1,100 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.sdk_client; package arbiter.operator.sdk_client;
import "shared/client.proto"; import "shared/client.proto";
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
enum Error { enum Error {
ERROR_UNSPECIFIED = 0; ERROR_UNSPECIFIED = 0;
ERROR_ALREADY_EXISTS = 1; ERROR_ALREADY_EXISTS = 1;
ERROR_NOT_FOUND = 2; ERROR_NOT_FOUND = 2;
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs) ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
ERROR_INTERNAL = 4; ERROR_INTERNAL = 4;
} }
message RevokeRequest { message RevokeRequest {
int32 client_id = 1; int32 client_id = 1;
} }
message Entry { message Entry {
int32 id = 1; int32 id = 1;
bytes pubkey = 2; bytes pubkey = 2;
arbiter.shared.ClientInfo info = 3; arbiter.shared.ClientInfo info = 3;
int32 created_at = 4; int32 created_at = 4;
} }
message List { message List {
repeated Entry clients = 1; repeated Entry clients = 1;
} }
message RevokeResponse { message RevokeResponse {
oneof result { oneof result {
google.protobuf.Empty ok = 1; google.protobuf.Empty ok = 1;
Error error = 2; Error error = 2;
} }
} }
message ListResponse { message ListResponse {
oneof result { oneof result {
List clients = 1; List clients = 1;
Error error = 2; Error error = 2;
} }
} }
message ConnectionRequest { message ConnectionRequest {
bytes pubkey = 1; bytes pubkey = 1;
arbiter.shared.ClientInfo info = 2; arbiter.shared.ClientInfo info = 2;
} }
message ConnectionResponse { message ConnectionResponse {
bool approved = 1; bool approved = 1;
bytes pubkey = 2; bytes pubkey = 2;
} }
message ConnectionCancel { message ConnectionCancel {
bytes pubkey = 1; bytes pubkey = 1;
} }
message WalletAccess { message WalletAccess {
int32 wallet_id = 1; int32 wallet_id = 1;
int32 sdk_client_id = 2; int32 sdk_client_id = 2;
} }
message WalletAccessEntry { message WalletAccessEntry {
int32 id = 1; int32 id = 1;
WalletAccess access = 2; WalletAccess access = 2;
} }
message GrantWalletAccess { message GrantWalletAccess {
repeated WalletAccess accesses = 1; repeated WalletAccess accesses = 1;
} }
message RevokeWalletAccess { message RevokeWalletAccess {
repeated int32 accesses = 1; repeated int32 accesses = 1;
} }
message ListWalletAccessResponse { message ListWalletAccessResponse {
repeated WalletAccessEntry accesses = 1; repeated WalletAccessEntry accesses = 1;
} }
message Request { message Request {
oneof payload { oneof payload {
ConnectionResponse connection_response = 1; ConnectionResponse connection_response = 1;
RevokeRequest revoke = 2; RevokeRequest revoke = 2;
google.protobuf.Empty list = 3; google.protobuf.Empty list = 3;
GrantWalletAccess grant_wallet_access = 4; GrantWalletAccess grant_wallet_access = 4;
RevokeWalletAccess revoke_wallet_access = 5; RevokeWalletAccess revoke_wallet_access = 5;
google.protobuf.Empty list_wallet_access = 6; google.protobuf.Empty list_wallet_access = 6;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
ConnectionRequest connection_request = 1; ConnectionRequest connection_request = 1;
ConnectionCancel connection_cancel = 2; ConnectionCancel connection_cancel = 2;
RevokeResponse revoke = 3; RevokeResponse revoke = 3;
ListResponse list = 4; ListResponse list = 4;
ListWalletAccessResponse list_wallet_access = 5; ListWalletAccessResponse list_wallet_access = 5;
} }
} }

View File

@@ -1,24 +1,24 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.vault.bootstrap; package arbiter.operator.vault.bootstrap;
message BootstrapEncryptedKey { message BootstrapEncryptedKey {
bytes nonce = 1; bytes nonce = 1;
bytes ciphertext = 2; bytes ciphertext = 2;
bytes associated_data = 3; bytes associated_data = 3;
} }
enum BootstrapResult { enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0; BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1; BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2; BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3; BOOTSTRAP_RESULT_INVALID_KEY = 3;
} }
message Request { message Request {
BootstrapEncryptedKey encrypted_key = 2; BootstrapEncryptedKey encrypted_key = 2;
} }
message Response { message Response {
BootstrapResult result = 1; BootstrapResult result = 1;
} }

View File

@@ -1,38 +1,37 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.vault.unseal; package arbiter.operator.vault.unseal;
message UnsealStart { message UnsealStart {
bytes client_pubkey = 1; bytes client_pubkey = 1;
} }
message UnsealStartResponse { message UnsealStartResponse {
bytes server_pubkey = 1; bytes server_pubkey = 1;
} }
message UnsealEncryptedKey { message UnsealEncryptedKey {
bytes nonce = 1; bytes nonce = 1;
bytes ciphertext = 2; bytes ciphertext = 2;
bytes associated_data = 3; bytes associated_data = 3;
} }
enum UnsealResult { enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0; UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1; UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2; UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3; UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
UNSEAL_RESULT_LOCKED_OUT = 4; }
}
message Request {
message Request { oneof payload {
oneof payload { UnsealStart start = 1;
UnsealStart start = 1; UnsealEncryptedKey encrypted_key = 2;
UnsealEncryptedKey encrypted_key = 2; }
} }
}
message Response {
message Response { oneof payload {
oneof payload { UnsealStartResponse start = 1;
UnsealStartResponse start = 1; UnsealResult result = 2;
UnsealResult result = 2; }
} }
}

View File

@@ -1,24 +1,24 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.operator.vault; package arbiter.operator.vault;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "shared/vault.proto"; import "shared/vault.proto";
import "operator/vault/bootstrap.proto"; import "operator/vault/bootstrap.proto";
import "operator/vault/unseal.proto"; import "operator/vault/unseal.proto";
message Request { message Request {
oneof payload { oneof payload {
google.protobuf.Empty query_state = 1; google.protobuf.Empty query_state = 1;
unseal.Request unseal = 2; unseal.Request unseal = 2;
bootstrap.Request bootstrap = 3; bootstrap.Request bootstrap = 3;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
arbiter.shared.VaultState state = 1; arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2; unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3; bootstrap.Response bootstrap = 3;
} }
} }

View File

@@ -1,9 +1,9 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.shared; package arbiter.shared;
message ClientInfo { message ClientInfo {
string name = 1; string name = 1;
optional string description = 2; optional string description = 2;
optional string version = 3; optional string version = 3;
} }

View File

@@ -1,74 +1,74 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.shared.evm; package arbiter.shared.evm;
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
message EtherTransferMeaning { message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes bytes value = 2; // U256 as big-endian bytes
} }
message TokenInfo { message TokenInfo {
string symbol = 1; string symbol = 1;
bytes address = 2; // 20-byte Ethereum address bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3; uint64 chain_id = 3;
} }
// Mirror of token_transfers::Meaning // Mirror of token_transfers::Meaning
message TokenTransferMeaning { message TokenTransferMeaning {
TokenInfo token = 1; TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes bytes value = 3; // U256 as big-endian bytes
} }
// Mirror of policies::SpecificMeaning // Mirror of policies::SpecificMeaning
message SpecificMeaning { message SpecificMeaning {
oneof meaning { oneof meaning {
EtherTransferMeaning ether_transfer = 1; EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2; TokenTransferMeaning token_transfer = 2;
} }
} }
message GasLimitExceededViolation { message GasLimitExceededViolation {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
} }
message EvalViolation { message EvalViolation {
message ChainIdMismatch { message ChainIdMismatch {
uint64 expected = 1; uint64 expected = 1;
uint64 actual = 2; uint64 actual = 2;
} }
oneof kind { oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2; GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3; google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4; google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5; google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6; google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7; ChainIdMismatch chain_id_mismatch = 7;
} }
} }
// Transaction was classified but no grant covers it // Transaction was classified but no grant covers it
message NoMatchingGrantError { message NoMatchingGrantError {
SpecificMeaning meaning = 1; SpecificMeaning meaning = 1;
} }
// Transaction was classified and a grant was found, but constraints were violated // Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError { message PolicyViolationsError {
SpecificMeaning meaning = 1; SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2; repeated EvalViolation violations = 2;
} }
// top-level error returned when transaction evaluation fails // top-level error returned when transaction evaluation fails
message TransactionEvalError { message TransactionEvalError {
oneof kind { oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1; google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2; google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3; NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4; PolicyViolationsError policy_violations = 4;
} }
} }

View File

@@ -1,11 +1,11 @@
syntax = "proto3"; syntax = "proto3";
package arbiter.shared; package arbiter.shared;
enum VaultState { enum VaultState {
VAULT_STATE_UNSPECIFIED = 0; VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1; VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2; VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3; VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4; VAULT_STATE_ERROR = 4;
} }

View File

@@ -1,150 +1,150 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Fetch the Uniswap default token list and emit Rust `TokenInfo` statics. Fetch the Uniswap default token list and emit Rust `TokenInfo` statics.
Usage: Usage:
python3 gen_erc20_registry.py # fetch from IPFS python3 gen_erc20_registry.py # fetch from IPFS
python3 gen_erc20_registry.py tokens.json # local file python3 gen_erc20_registry.py tokens.json # local file
python3 gen_erc20_registry.py tokens.json out.rs # custom output file python3 gen_erc20_registry.py tokens.json out.rs # custom output file
""" """
import json import json
import re import re
import sys import sys
import unicodedata import unicodedata
import urllib.request import urllib.request
UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org" UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org"
SOLANA_CHAIN_ID = 501000101 SOLANA_CHAIN_ID = 501000101
IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+") IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+")
def load_tokens(source=None): def load_tokens(source=None):
if source: if source:
with open(source) as f: with open(source) as f:
return json.load(f) return json.load(f)
req = urllib.request.Request( req = urllib.request.Request(
UNISWAP_URL, UNISWAP_URL,
headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"}, headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"},
) )
with urllib.request.urlopen(req, timeout=60) as resp: with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read()) return json.loads(resp.read())
def escape(s: str) -> str: def escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"') return s.replace("\\", "\\\\").replace('"', '\\"')
def to_screaming_case(name: str) -> str: def to_screaming_case(name: str) -> str:
normalized = unicodedata.normalize("NFKD", name or "") normalized = unicodedata.normalize("NFKD", name or "")
ascii_name = normalized.encode("ascii", "ignore").decode("ascii") ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper() snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper()
if not snake: if not snake:
snake = "TOKEN" snake = "TOKEN"
if snake[0].isdigit(): if snake[0].isdigit():
snake = f"TOKEN_{snake}" snake = f"TOKEN_{snake}"
return snake return snake
def static_name_for_token(token: dict, used_names: set[str]) -> str: def static_name_for_token(token: dict, used_names: set[str]) -> str:
base = to_screaming_case(token.get("name", "")) base = to_screaming_case(token.get("name", ""))
if base not in used_names: if base not in used_names:
used_names.add(base) used_names.add(base)
return base return base
address = token["address"] address = token["address"]
suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}" suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}"
candidate = f"{base}_{suffix}" candidate = f"{base}_{suffix}"
i = 2 i = 2
while candidate in used_names: while candidate in used_names:
candidate = f"{base}_{suffix}_{i}" candidate = f"{base}_{suffix}_{i}"
i += 1 i += 1
used_names.add(candidate) used_names.add(candidate)
return candidate return candidate
def main(): def main():
source = sys.argv[1] if len(sys.argv) > 1 else None source = sys.argv[1] if len(sys.argv) > 1 else None
output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs" output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs"
data = load_tokens(source) data = load_tokens(source)
tokens = data["tokens"] tokens = data["tokens"]
# Deduplicate by (chainId, address) # Deduplicate by (chainId, address)
seen = set() seen = set()
unique = [] unique = []
for t in tokens: for t in tokens:
key = (t["chainId"], t["address"].lower()) key = (t["chainId"], t["address"].lower())
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
unique.append(t) unique.append(t)
unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper())) unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper()))
evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID] evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID]
ver = data["version"] ver = data["version"]
lines = [] lines = []
w = lines.append w = lines.append
w( w(
f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}" f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}"
) )
w(f"// {len(evm_tokens)} tokens") w(f"// {len(evm_tokens)} tokens")
w("// DO NOT EDIT - regenerate with gen_erc20_registry.py") w("// DO NOT EDIT - regenerate with gen_erc20_registry.py")
w("") w("")
used_static_names = set() used_static_names = set()
token_statics = [] token_statics = []
for t in evm_tokens: for t in evm_tokens:
static_name = static_name_for_token(t, used_static_names) static_name = static_name_for_token(t, used_static_names)
token_statics.append((static_name, t)) token_statics.append((static_name, t))
for static_name, t in token_statics: for static_name, t in token_statics:
addr = t["address"] addr = t["address"]
name = escape(t.get("name", "")) name = escape(t.get("name", ""))
symbol = escape(t.get("symbol", "")) symbol = escape(t.get("symbol", ""))
decimals = t.get("decimals", 18) decimals = t.get("decimals", 18)
logo = t.get("logoURI") logo = t.get("logoURI")
chain = t["chainId"] chain = t["chainId"]
logo_val = f'Some("{escape(logo)}")' if logo else "None" logo_val = f'Some("{escape(logo)}")' if logo else "None"
w(f"pub static {static_name}: TokenInfo = TokenInfo {{") w(f"pub static {static_name}: TokenInfo = TokenInfo {{")
w(f' name: "{name}",') w(f' name: "{name}",')
w(f' symbol: "{symbol}",') w(f' symbol: "{symbol}",')
w(f" decimals: {decimals},") w(f" decimals: {decimals},")
w(f' contract: address!("{addr}"),') w(f' contract: address!("{addr}"),')
w(f" chain: {chain},") w(f" chain: {chain},")
w(f" logo_uri: {logo_val},") w(f" logo_uri: {logo_val},")
w("};") w("};")
w("") w("")
w("pub static TOKENS: &[&TokenInfo] = &[") w("pub static TOKENS: &[&TokenInfo] = &[")
for static_name, _ in token_statics: for static_name, _ in token_statics:
w(f" &{static_name},") w(f" &{static_name},")
w("];") w("];")
w("") w("")
w("pub fn get_token(") w("pub fn get_token(")
w(" chain_id: alloy::primitives::ChainId,") w(" chain_id: alloy::primitives::ChainId,")
w(" address: alloy::primitives::Address,") w(" address: alloy::primitives::Address,")
w(") -> Option<&'static TokenInfo> {") w(") -> Option<&'static TokenInfo> {")
w(" match (chain_id, address) {") w(" match (chain_id, address) {")
for static_name, t in token_statics: for static_name, t in token_statics:
w( w(
f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),' f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),'
) )
w(" _ => None,") w(" _ => None,")
w(" }") w(" }")
w("}") w("}")
w("") w("")
with open(output, "w") as f: with open(output, "w") as f:
f.write("\n".join(lines)) f.write("\n".join(lines))
print(f"Wrote {len(token_statics)} tokens to {output}") print(f"Wrote {len(token_statics)} tokens to {output}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,13 +1,13 @@
[advisories] [advisories]
# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate. # RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate.
# No fixed version is available upstream. # No fixed version is available upstream.
# RSA support is required for Windows Hello / KeyCredentialManager # RSA support is required for Windows Hello / KeyCredentialManager
# (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync), # (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync),
# which only issues RSA-2048 keys. # which only issues RSA-2048 keys.
# Mitigations in place: # Mitigations in place:
# - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to # - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to
# protect the private key from timing recovery during signing. # protect the private key from timing recovery during signing.
# - RSA decryption is never performed; we only verify public-key signatures. # - RSA decryption is never performed; we only verify public-key signatures.
# - The attack requires local, high-resolution timing access against the # - The attack requires local, high-resolution timing access against the
# signing process, which is not exposed in our threat model. # signing process, which is not exposed in our threat model.
ignore = ["RUSTSEC-2023-0071"] ignore = ["RUSTSEC-2023-0071"]

View File

@@ -1,2 +1,2 @@
[env] [env]
MACOSX_DEPLOYMENT_TARGET = "26.3" MACOSX_DEPLOYMENT_TARGET = "26.3"

View File

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

2
server/.gitignore vendored
View File

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

12695
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +1,171 @@
[workspace] [workspace]
members = [ members = [
"crates/*", "crates/*",
] ]
resolver = "3" resolver = "3"
[workspace.dependencies] [workspace.dependencies]
alloy = "2.0.4" alloy = "2.0.4"
async-trait = "0.1.89" async-trait = "0.1.89"
base64 = "0.22.1" base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
futures = "0.3.32" futures = "0.3.32"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
hmac = "0.13.0" hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
mutants = "0.0.4" mutants = "0.0.4"
prost = "0.14.3" prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] } prost-types = { version = "0.14.3", features = ["chrono"] }
rand = "0.10.1" rand = "0.10.1"
rand_core = "0.10.1" rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false } rstest = "0.26.1"
rstest = "0.26.1" rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false } rustls-pki-types = "1.14.1"
rustls-pki-types = "1.14.1" sha2 = "0.11"
sha2 = "0.11" smlang = "0.8.0"
smlang = "0.8.0" thiserror = "2.0.18"
thiserror = "2.0.18" tokio = { version = "1.52.1", features = ["full"] }
tokio = { version = "1.52.1", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] } tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] } tracing = "0.1.44"
tracing = "0.1.44" x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
[workspace.lints.rust]
[workspace.lints.rust] missing_unsafe_on_extern = "deny"
missing_unsafe_on_extern = "deny" unsafe_attr_outside_unsafe = "deny"
unsafe_attr_outside_unsafe = "deny" unsafe_op_in_unsafe_fn = "deny"
unsafe_op_in_unsafe_fn = "deny" unstable_features = "deny"
unstable_features = "deny"
deprecated_safe_2024 = "warn"
deprecated_safe_2024 = "warn" ffi_unwind_calls = "warn"
ffi_unwind_calls = "warn" linker_messages = "warn"
linker_messages = "warn"
elided_lifetimes_in_paths = "warn"
elided_lifetimes_in_paths = "warn" explicit_outlives_requirements = "warn"
explicit_outlives_requirements = "warn" impl-trait-overcaptures = "warn"
impl-trait-overcaptures = "warn" impl-trait-redundant-captures = "warn"
impl-trait-redundant-captures = "warn" redundant_lifetimes = "warn"
redundant_lifetimes = "warn" single_use_lifetimes = "warn"
single_use_lifetimes = "warn" unused_lifetimes = "warn"
unused_lifetimes = "warn"
macro_use_extern_crate = "warn"
macro_use_extern_crate = "warn" redundant_imports = "warn"
redundant_imports = "warn" unused_import_braces = "warn"
unused_import_braces = "warn" unused_macro_rules = "warn"
unused_macro_rules = "warn" unused_qualifications = "warn"
unused_qualifications = "warn"
unit_bindings = "warn"
unit_bindings = "warn"
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!!
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!! unnameable_types = "warn"
unnameable_types = "warn"
[workspace.lints.clippy]
[workspace.lints.clippy] derive_partial_eq_without_eq = "allow"
derive_partial_eq_without_eq = "allow" future_not_send = "allow"
future_not_send = "allow" inconsistent_struct_constructor = "allow"
inconsistent_struct_constructor = "allow" inline_always = "allow"
inline_always = "allow" missing_errors_doc = "allow"
missing_errors_doc = "allow" missing_fields_in_debug = "allow"
missing_fields_in_debug = "allow" missing_panics_doc = "allow"
missing_panics_doc = "allow" must_use_candidate = "allow"
must_use_candidate = "allow" needless_pass_by_ref_mut = "allow"
needless_pass_by_ref_mut = "allow" pub_underscore_fields = "allow"
pub_underscore_fields = "allow" redundant_pub_crate = "allow"
redundant_pub_crate = "allow" uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {}) too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
# restriction lints
# restriction lints alloc_instead_of_core = "warn"
alloc_instead_of_core = "warn" allow_attributes_without_reason = "warn"
allow_attributes_without_reason = "warn" as_conversions = "warn"
as_conversions = "warn" assertions_on_result_states = "warn"
assertions_on_result_states = "warn" cfg_not_test = "warn"
cfg_not_test = "warn" clone_on_ref_ptr = "warn"
clone_on_ref_ptr = "warn" cognitive_complexity = "warn"
cognitive_complexity = "warn" create_dir = "warn"
create_dir = "warn" dbg_macro = "warn"
dbg_macro = "warn" decimal_literal_representation = "warn"
decimal_literal_representation = "warn" default_union_representation = "warn"
default_union_representation = "warn" deref_by_slicing = "warn"
deref_by_slicing = "warn" disallowed_script_idents = "warn"
disallowed_script_idents = "warn" doc_include_without_cfg = "warn"
doc_include_without_cfg = "warn" empty_drop = "warn"
empty_drop = "warn" empty_enum_variants_with_brackets = "warn"
empty_enum_variants_with_brackets = "warn" empty_structs_with_brackets = "warn"
empty_structs_with_brackets = "warn" exit = "warn"
exit = "warn" filetype_is_file = "warn"
filetype_is_file = "warn" float_arithmetic = "warn"
float_arithmetic = "warn" float_cmp_const = "warn"
float_cmp_const = "warn" fn_to_numeric_cast_any = "warn"
fn_to_numeric_cast_any = "warn" get_unwrap = "warn"
get_unwrap = "warn" if_then_some_else_none = "warn"
if_then_some_else_none = "warn" indexing_slicing = "warn"
indexing_slicing = "warn" infinite_loop = "warn"
infinite_loop = "warn" inline_asm_x86_att_syntax = "warn"
inline_asm_x86_att_syntax = "warn" inline_asm_x86_intel_syntax = "warn"
inline_asm_x86_intel_syntax = "warn" integer_division = "warn"
integer_division = "warn" large_include_file = "warn"
large_include_file = "warn" lossy_float_literal = "warn"
lossy_float_literal = "warn" map_with_unused_argument_over_ranges = "warn"
map_with_unused_argument_over_ranges = "warn" mem_forget = "warn"
mem_forget = "warn" missing_assert_message = "warn"
missing_assert_message = "warn" mixed_read_write_in_expression = "warn"
mixed_read_write_in_expression = "warn" modulo_arithmetic = "warn"
modulo_arithmetic = "warn" multiple_unsafe_ops_per_block = "warn"
multiple_unsafe_ops_per_block = "warn" mutex_atomic = "warn"
mutex_atomic = "warn" mutex_integer = "warn"
mutex_integer = "warn" needless_raw_strings = "warn"
needless_raw_strings = "warn" non_ascii_literal = "warn"
non_ascii_literal = "warn" non_zero_suggestions = "warn"
non_zero_suggestions = "warn" pathbuf_init_then_push = "warn"
pathbuf_init_then_push = "warn" pointer_format = "warn"
pointer_format = "warn" precedence_bits = "warn"
precedence_bits = "warn" pub_without_shorthand = "warn"
pub_without_shorthand = "warn" rc_buffer = "warn"
rc_buffer = "warn" rc_mutex = "warn"
rc_mutex = "warn" redundant_test_prefix = "warn"
redundant_test_prefix = "warn" redundant_type_annotations = "warn"
redundant_type_annotations = "warn" ref_patterns = "warn"
ref_patterns = "warn" renamed_function_params = "warn"
renamed_function_params = "warn" rest_pat_in_fully_bound_structs = "warn"
rest_pat_in_fully_bound_structs = "warn" return_and_then = "warn"
return_and_then = "warn" semicolon_inside_block = "warn"
semicolon_inside_block = "warn" str_to_string = "warn"
str_to_string = "warn" string_add = "warn"
string_add = "warn" string_lit_chars_any = "warn"
string_lit_chars_any = "warn" string_slice = "warn"
string_slice = "warn" suspicious_xor_used_as_pow = "warn"
suspicious_xor_used_as_pow = "warn" try_err = "warn"
try_err = "warn" undocumented_unsafe_blocks = "warn"
undocumented_unsafe_blocks = "warn" uninlined_format_args = "warn"
uninlined_format_args = "warn" unnecessary_safety_comment = "warn"
unnecessary_safety_comment = "warn" unnecessary_safety_doc = "warn"
unnecessary_safety_doc = "warn" unnecessary_self_imports = "warn"
unnecessary_self_imports = "warn" unneeded_field_pattern = "warn"
unneeded_field_pattern = "warn" unused_result_ok = "warn"
unused_result_ok = "warn" verbose_file_reads = "warn"
verbose_file_reads = "warn"
# cargo lints
# cargo lints negative_feature_names = "warn"
negative_feature_names = "warn" redundant_feature_names = "warn"
redundant_feature_names = "warn" wildcard_dependencies = "warn"
wildcard_dependencies = "warn"
# ENABLE BY THE FIRST MAJOR VERSION!!
# ENABLE BY THE FIRST MAJOR VERSION!! # todo = "warn"
# todo = "warn" # unimplemented = "warn"
# unimplemented = "warn" # panic = "warn"
# panic = "warn" # panic_in_result_fn = "warn"
# panic_in_result_fn = "warn" #
# # cargo_common_metadata = "warn"
# cargo_common_metadata = "warn" # multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
# multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
disallowed_methods = "deny"
disallowed_methods = "deny"
nursery = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way

View File

@@ -1,28 +1,28 @@
disallowed-methods = [ disallowed-methods = [
# RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack). # RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack).
# We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption # We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption
# is never required and must not be introduced. # is never required and must not be introduced.
{ path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, { path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, { path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, { path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, { path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
] ]
allow-indexing-slicing-in-tests = true allow-indexing-slicing-in-tests = true
allow-panic-in-tests = true allow-panic-in-tests = true
check-inconsistent-struct-field-initializers = true check-inconsistent-struct-field-initializers = true
suppress-restriction-lint-in-const = true suppress-restriction-lint-in-const = true
allow-renamed-params-for = [ allow-renamed-params-for = [
"core::convert::From", "core::convert::From",
"core::convert::TryFrom", "core::convert::TryFrom",
"core::str::FromStr", "core::str::FromStr",
"kameo::actor::Actor", "kameo::actor::Actor",
] ]
module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"] module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"]
source-item-ordering = ["enum"] source-item-ordering = ["enum"]
trait-assoc-item-kinds-order = [ trait-assoc-item-kinds-order = [
"const", "const",
"type", "type",
"fn", "fn",
] # community tested standard ] # community tested standard

View File

@@ -1,29 +1,29 @@
[package] [package]
name = "arbiter-client" name = "arbiter-client"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[lints] [lints]
workspace = true workspace = true
[features] [features]
evm = ["dep:alloy"] evm = ["dep:alloy"]
[dependencies] [dependencies]
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto" arbiter-crypto.path = "../arbiter-crypto"
alloy = { workspace = true, optional = true } alloy = { workspace = true, optional = true }
tonic.workspace = true tonic.workspace = true
tonic.features = ["tls-aws-lc"] tonic.features = ["tls-aws-lc"]
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
thiserror.workspace = true thiserror.workspace = true
http = "1.4.0" http = "1.4.0"
rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] } rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
async-trait.workspace = true async-trait.workspace = true
chrono.workspace = true chrono.workspace = true
[lib] [lib]
doctest = false doctest = false

View File

@@ -1,160 +1,160 @@
use crate::{ use crate::{
storage::StorageError, storage::StorageError,
transport::{ClientTransport, next_request_id}, transport::{ClientTransport, next_request_id},
}; };
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey}; use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata,
proto::{ proto::{
client::{ client::{
ClientRequest, ClientRequest,
auth::{ auth::{
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution, self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
AuthResult, request::Payload as AuthRequestPayload, AuthResult, request::Payload as AuthRequestPayload,
response::Payload as AuthResponsePayload, response::Payload as AuthResponsePayload,
}, },
client_request::Payload as ClientRequestPayload, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload, client_response::Payload as ClientResponsePayload,
}, },
shared::ClientInfo as ProtoClientInfo, shared::ClientInfo as ProtoClientInfo,
}, },
}; };
use chrono::DateTime; use chrono::DateTime;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum AuthError { pub enum AuthError {
#[error("Server sent invalid auth challenge")] #[error("Server sent invalid auth challenge")]
InvalidChallenge, InvalidChallenge,
#[error("Client approval denied by Operator")] #[error("Client approval denied by Operator")]
ApprovalDenied, ApprovalDenied,
#[error("Auth challenge was not returned by server")] #[error("Auth challenge was not returned by server")]
MissingAuthChallenge, MissingAuthChallenge,
#[error("No Operators online to approve client")] #[error("No Operators online to approve client")]
NoOperatorsOnline, NoOperatorsOnline,
#[error("Signing key storage error")] #[error("Signing key storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
#[error("Unexpected auth response payload")] #[error("Unexpected auth response payload")]
UnexpectedAuthResponse, UnexpectedAuthResponse,
} }
fn map_auth_result(code: i32) -> AuthError { fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => AuthError::ApprovalDenied, AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline, AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
AuthResult::Unspecified AuthResult::Unspecified
| AuthResult::Success | AuthResult::Success
| AuthResult::InvalidKey | AuthResult::InvalidKey
| AuthResult::InvalidSignature | AuthResult::InvalidSignature
| AuthResult::Internal => AuthError::UnexpectedAuthResponse, | AuthResult::Internal => AuthError::UnexpectedAuthResponse,
} }
} }
async fn send_auth_challenge_request( async fn send_auth_challenge_request(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &SigningKey, key: &SigningKey,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id: next_request_id(), request_id: next_request_id(),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request { payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest { payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
pubkey: key.public_key().to_bytes(), pubkey: key.public_key().to_bytes(),
client_info: Some(ProtoClientInfo { client_info: Some(ProtoClientInfo {
name: metadata.name, name: metadata.name,
description: metadata.description, description: metadata.description,
version: metadata.version, version: metadata.version,
}), }),
})), })),
})), })),
}) })
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse) .map_err(|_| AuthError::UnexpectedAuthResponse)
} }
async fn receive_auth_challenge( async fn receive_auth_challenge(
transport: &mut ClientTransport, transport: &mut ClientTransport,
) -> Result<AuthChallenge, AuthError> { ) -> Result<AuthChallenge, AuthError> {
let response = transport let response = transport
.recv() .recv()
.await .await
.map_err(|_| AuthError::MissingAuthChallenge)?; .map_err(|_| AuthError::MissingAuthChallenge)?;
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?; let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
match payload { match payload {
ClientResponsePayload::Auth(response) => match response.payload { ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge), Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)), Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
None => Err(AuthError::MissingAuthChallenge), None => Err(AuthError::MissingAuthChallenge),
}, },
_ => Err(AuthError::UnexpectedAuthResponse), _ => Err(AuthError::UnexpectedAuthResponse),
} }
} }
async fn send_auth_challenge_solution( async fn send_auth_challenge_solution(
transport: &mut ClientTransport, transport: &mut ClientTransport,
key: &SigningKey, key: &SigningKey,
challenge: AuthChallenge, challenge: AuthChallenge,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed()); let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
let challenge = authn::AuthChallenge { let challenge = authn::AuthChallenge {
nonce: *challenge nonce: *challenge
.random .random
.as_array() .as_array()
.ok_or(AuthError::InvalidChallenge)?, .ok_or(AuthError::InvalidChallenge)?,
timestamp, timestamp,
}; };
let challenge_payload: Vec<u8> = challenge.format(); let challenge_payload: Vec<u8> = challenge.format();
let signature = key let signature = key
.sign_message(&challenge_payload, CLIENT_CONTEXT) .sign_message(&challenge_payload, CLIENT_CONTEXT)
.map_err(|_| AuthError::UnexpectedAuthResponse)? .map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes(); .to_bytes();
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id: next_request_id(), request_id: next_request_id(),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request { payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeSolution( payload: Some(AuthRequestPayload::ChallengeSolution(
AuthChallengeSolution { signature }, AuthChallengeSolution { signature },
)), )),
})), })),
}) })
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse) .map_err(|_| AuthError::UnexpectedAuthResponse)
} }
async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> { async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
let response = transport let response = transport
.recv() .recv()
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse)?; .map_err(|_| AuthError::UnexpectedAuthResponse)?;
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?; let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
match payload { match payload {
ClientResponsePayload::Auth(response) => match response.payload { ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Result(result)) Some(AuthResponsePayload::Result(result))
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
{ {
Ok(()) Ok(())
} }
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)), Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
_ => Err(AuthError::UnexpectedAuthResponse), _ => Err(AuthError::UnexpectedAuthResponse),
}, },
_ => Err(AuthError::UnexpectedAuthResponse), _ => Err(AuthError::UnexpectedAuthResponse),
} }
} }
pub async fn authenticate( pub async fn authenticate(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &SigningKey, key: &SigningKey,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
send_auth_challenge_request(transport, metadata, key).await?; send_auth_challenge_request(transport, metadata, key).await?;
let challenge = receive_auth_challenge(transport).await?; let challenge = receive_auth_challenge(transport).await?;
send_auth_challenge_solution(transport, key, challenge).await?; send_auth_challenge_solution(transport, key, challenge).await?;
receive_auth_confirmation(transport).await receive_auth_confirmation(transport).await
} }

View File

@@ -1,44 +1,44 @@
use arbiter_client::ArbiterClient; use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl}; use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use std::io::{self, Write}; use std::io::{self, Write};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
println!("Testing connection to Arbiter server..."); println!("Testing connection to Arbiter server...");
print!("Enter ArbiterUrl: "); print!("Enter ArbiterUrl: ");
let _ = io::stdout().flush(); let _ = io::stdout().flush();
let mut input = String::new(); let mut input = String::new();
if let Err(err) = io::stdin().read_line(&mut input) { if let Err(err) = io::stdin().read_line(&mut input) {
eprintln!("Failed to read input: {err}"); eprintln!("Failed to read input: {err}");
return; return;
} }
let input = input.trim(); let input = input.trim();
if input.is_empty() { if input.is_empty() {
eprintln!("ArbiterUrl cannot be empty"); eprintln!("ArbiterUrl cannot be empty");
return; return;
} }
let url = match ArbiterUrl::try_from(input) { let url = match ArbiterUrl::try_from(input) {
Ok(url) => url, Ok(url) => url,
Err(err) => { Err(err) => {
eprintln!("Invalid ArbiterUrl: {err}"); eprintln!("Invalid ArbiterUrl: {err}");
return; return;
} }
}; };
println!("{url:#?}"); println!("{url:#?}");
let metadata = ClientMetadata { let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_owned(), name: "arbiter-client test_connect".to_owned(),
description: Some("Manual connection smoke test".to_owned()), description: Some("Manual connection smoke test".to_owned()),
version: Some(env!("CARGO_PKG_VERSION").to_owned()), version: Some(env!("CARGO_PKG_VERSION").to_owned()),
}; };
match ArbiterClient::connect(url, metadata).await { match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."), Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {err:#?}"), Err(err) => eprintln!("Failed to connect: {err:#?}"),
} }
} }

View File

@@ -1,101 +1,101 @@
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet; use crate::wallets::evm::ArbiterEvmWallet;
use crate::{ use crate::{
StorageError, StorageError,
auth::{AuthError, authenticate}, auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage}, storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport}, transport::{BUFFER_LENGTH, ClientTransport},
}; };
use arbiter_crypto::authn::SigningKey; use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl, ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
}; };
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig; use tonic::transport::ClientTlsConfig;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ArbiterClientError { pub enum ArbiterClientError {
#[error("Authentication error")] #[error("Authentication error")]
Authentication(#[from] AuthError), Authentication(#[from] AuthError),
#[error("Could not establish connection")] #[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error), Connection(#[from] tonic::transport::Error),
#[error("gRPC error")] #[error("gRPC error")]
Grpc(#[from] tonic::Status), Grpc(#[from] tonic::Status),
#[error("Invalid CA certificate")] #[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error), InvalidCaCert(#[from] webpki::Error),
#[error("Invalid server URI")] #[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri), InvalidUri(#[from] http::uri::InvalidUri),
#[error("Storage error")] #[error("Storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
} }
pub struct ArbiterClient { pub struct ArbiterClient {
#[expect( #[expect(
dead_code, dead_code,
reason = "transport will be used in future methods for sending requests and receiving responses" reason = "transport will be used in future methods for sending requests and receiving responses"
)] )]
transport: Arc<Mutex<ClientTransport>>, transport: Arc<Mutex<ClientTransport>>,
} }
impl ArbiterClient { impl ArbiterClient {
pub async fn connect( pub async fn connect(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
) -> Result<Self, ArbiterClientError> { ) -> Result<Self, ArbiterClientError> {
let storage = FileSigningKeyStorage::from_default_location()?; let storage = FileSigningKeyStorage::from_default_location()?;
Self::connect_with_storage(url, metadata, &storage).await Self::connect_with_storage(url, metadata, &storage).await
} }
pub async fn connect_with_storage<S: SigningKeyStorage>( pub async fn connect_with_storage<S: SigningKeyStorage>(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
storage: &S, storage: &S,
) -> Result<Self, ArbiterClientError> { ) -> Result<Self, ArbiterClientError> {
let key = storage.load_or_create()?; let key = storage.load_or_create()?;
Self::connect_with_key(url, metadata, key).await Self::connect_with_key(url, metadata, key).await
} }
pub async fn connect_with_key( pub async fn connect_with_key(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
key: SigningKey, key: SigningKey,
) -> Result<Self, ArbiterClientError> { ) -> Result<Self, ArbiterClientError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor); let tls = ClientTlsConfig::new().trust_anchor(anchor);
let channel = let channel =
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))? tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.tls_config(tls)? .tls_config(tls)?
.connect() .connect()
.await?; .await?;
let mut client = ArbiterServiceClient::new(channel); let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH); let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner(); let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
let mut transport = ClientTransport { let mut transport = ClientTransport {
sender: tx, sender: tx,
receiver: response_stream, receiver: response_stream,
}; };
authenticate(&mut transport, metadata, &key).await?; authenticate(&mut transport, metadata, &key).await?;
Ok(Self { Ok(Self {
transport: Arc::new(Mutex::new(transport)), transport: Arc::new(Mutex::new(transport)),
}) })
} }
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
#[expect(clippy::unused_async, reason = "false positive")] #[expect(clippy::unused_async, reason = "false positive")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> { pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
todo!("fetch EVM wallet list from server") todo!("fetch EVM wallet list from server")
} }
} }

View File

@@ -1,12 +1,12 @@
mod auth; mod auth;
mod client; mod client;
mod storage; mod storage;
mod transport; mod transport;
pub mod wallets; pub mod wallets;
pub use auth::AuthError; pub use auth::AuthError;
pub use client::{ArbiterClient, ArbiterClientError}; pub use client::{ArbiterClient, ArbiterClientError};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet}; pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};

View File

@@ -1,134 +1,134 @@
use arbiter_crypto::authn::SigningKey; use arbiter_crypto::authn::SigningKey;
use arbiter_proto::home_path; use arbiter_proto::home_path;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StorageError { pub enum StorageError {
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")] #[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
InvalidKeyLength { expected: usize, actual: usize }, InvalidKeyLength { expected: usize, actual: usize },
#[error("I/O error")] #[error("I/O error")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
pub trait SigningKeyStorage { pub trait SigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError>; fn load_or_create(&self) -> Result<SigningKey, StorageError>;
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileSigningKeyStorage { pub struct FileSigningKeyStorage {
path: PathBuf, path: PathBuf,
} }
impl FileSigningKeyStorage { impl FileSigningKeyStorage {
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key"; pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key";
pub fn new(path: impl Into<PathBuf>) -> Self { pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() } Self { path: path.into() }
} }
pub fn from_default_location() -> Result<Self, StorageError> { pub fn from_default_location() -> Result<Self, StorageError> {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
} }
fn read_key(path: &Path) -> Result<SigningKey, StorageError> { fn read_key(path: &Path) -> Result<SigningKey, StorageError> {
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
let raw: [u8; 32] = let raw: [u8; 32] =
bytes bytes
.try_into() .try_into()
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength { .map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
expected: 32, expected: 32,
actual: v.len(), actual: v.len(),
})?; })?;
Ok(SigningKey::from_seed(raw)) Ok(SigningKey::from_seed(raw))
} }
} }
impl SigningKeyStorage for FileSigningKeyStorage { impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError> { fn load_or_create(&self) -> Result<SigningKey, StorageError> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
if self.path.exists() { if self.path.exists() {
return Self::read_key(&self.path); return Self::read_key(&self.path);
} }
let key = SigningKey::generate(); let key = SigningKey::generate();
let raw_key = key.to_seed(); let raw_key = key.to_seed();
// Use create_new to prevent accidental overwrite if another process creates the key first. // Use create_new to prevent accidental overwrite if another process creates the key first.
match std::fs::OpenOptions::new() match std::fs::OpenOptions::new()
.create_new(true) .create_new(true)
.write(true) .write(true)
.open(&self.path) .open(&self.path)
{ {
Ok(mut file) => { Ok(mut file) => {
use std::io::Write as _; use std::io::Write as _;
file.write_all(&raw_key)?; file.write_all(&raw_key)?;
Ok(key) Ok(key)
} }
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
Self::read_key(&self.path) Self::read_key(&self.path)
} }
Err(err) => Err(StorageError::Io(err)), Err(err) => Err(StorageError::Io(err)),
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
fn unique_temp_key_path() -> std::path::PathBuf { fn unique_temp_key_path() -> std::path::PathBuf {
let nanos = std::time::SystemTime::now() let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after unix epoch") .expect("clock should be after unix epoch")
.as_nanos(); .as_nanos();
std::env::temp_dir().join(format!( std::env::temp_dir().join(format!(
"arbiter-client-key-{}-{}.bin", "arbiter-client-key-{}-{}.bin",
std::process::id(), std::process::id(),
nanos nanos
)) ))
} }
#[test] #[test]
fn file_storage_creates_and_reuses_key() { fn file_storage_creates_and_reuses_key() {
let path = unique_temp_key_path(); let path = unique_temp_key_path();
let storage = FileSigningKeyStorage::new(path.clone()); let storage = FileSigningKeyStorage::new(path.clone());
let key_a = storage let key_a = storage
.load_or_create() .load_or_create()
.expect("first load_or_create should create key"); .expect("first load_or_create should create key");
let key_b = storage let key_b = storage
.load_or_create() .load_or_create()
.expect("second load_or_create should read same key"); .expect("second load_or_create should read same key");
assert_eq!(key_a.to_seed(), key_b.to_seed()); assert_eq!(key_a.to_seed(), key_b.to_seed());
assert!(path.exists()); assert!(path.exists());
std::fs::remove_file(path).expect("temp key file should be removable"); std::fs::remove_file(path).expect("temp key file should be removable");
} }
#[test] #[test]
fn file_storage_rejects_invalid_key_length() { fn file_storage_rejects_invalid_key_length() {
let path = unique_temp_key_path(); let path = unique_temp_key_path();
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file"); std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
let storage = FileSigningKeyStorage::new(path.clone()); let storage = FileSigningKeyStorage::new(path.clone());
let err = storage let err = storage
.load_or_create() .load_or_create()
.expect_err("storage should reject non-32-byte key file"); .expect_err("storage should reject non-32-byte key file");
match err { match err {
StorageError::InvalidKeyLength { expected, actual } => { StorageError::InvalidKeyLength { expected, actual } => {
assert_eq!(expected, 32); assert_eq!(expected, 32);
assert_eq!(actual, 31); assert_eq!(actual, 31);
} }
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"), other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"),
} }
std::fs::remove_file(path).expect("temp key file should be removable"); std::fs::remove_file(path).expect("temp key file should be removable");
} }
} }

View File

@@ -1,41 +1,41 @@
use arbiter_proto::proto::client::{ClientRequest, ClientResponse}; use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::atomic::{AtomicI32, Ordering};
use tokio::sync::mpsc; use tokio::sync::mpsc;
pub const BUFFER_LENGTH: usize = 16; pub const BUFFER_LENGTH: usize = 16;
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1); static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
pub fn next_request_id() -> i32 { pub fn next_request_id() -> i32 {
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed) NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ClientSignError { pub enum ClientSignError {
#[error("Transport channel closed")] #[error("Transport channel closed")]
ChannelClosed, ChannelClosed,
#[error("Connection closed by server")] #[error("Connection closed by server")]
ConnectionClosed, ConnectionClosed,
} }
pub struct ClientTransport { pub struct ClientTransport {
pub(crate) sender: mpsc::Sender<ClientRequest>, pub(crate) sender: mpsc::Sender<ClientRequest>,
pub(crate) receiver: tonic::Streaming<ClientResponse>, pub(crate) receiver: tonic::Streaming<ClientResponse>,
} }
impl ClientTransport { impl ClientTransport {
pub(crate) async fn send(&mut self, request: ClientRequest) -> Result<(), ClientSignError> { pub(crate) async fn send(&mut self, request: ClientRequest) -> Result<(), ClientSignError> {
self.sender self.sender
.send(request) .send(request)
.await .await
.map_err(|_| ClientSignError::ChannelClosed) .map_err(|_| ClientSignError::ChannelClosed)
} }
pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> { pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> {
match self.receiver.message().await { match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp), Ok(Some(resp)) => Ok(resp),
Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed), Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed),
} }
} }
} }

View File

@@ -1,197 +1,197 @@
use crate::transport::{ClientTransport, next_request_id}; use crate::transport::{ClientTransport, next_request_id};
use arbiter_proto::proto::{ use arbiter_proto::proto::{
client::{ client::{
ClientRequest, ClientRequest,
client_request::Payload as ClientRequestPayload, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload, client_response::Payload as ClientResponsePayload,
evm::{ evm::{
self as proto_evm, request::Payload as EvmRequestPayload, self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload, response::Payload as EvmResponsePayload,
}, },
}, },
evm::{ evm::{
EvmSignTransactionRequest, EvmSignTransactionRequest,
evm_sign_transaction_response::Result as EvmSignTransactionResult, evm_sign_transaction_response::Result as EvmSignTransactionResult,
}, },
shared::evm::TransactionEvalError, shared::evm::TransactionEvalError,
}; };
use alloy::{ use alloy::{
consensus::SignableTransaction, consensus::SignableTransaction,
network::TxSigner, network::TxSigner,
primitives::{Address, B256, ChainId, Signature}, primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer}, signers::{Error, Result, Signer},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing. /// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
/// ///
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and /// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
/// interpret the concrete policy evaluation failure instead of parsing strings. /// interpret the concrete policy evaluation failure instead of parsing strings.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[non_exhaustive] #[non_exhaustive]
pub enum ArbiterEvmSignTransactionError { pub enum ArbiterEvmSignTransactionError {
#[error("transaction rejected by policy: {0:?}")] #[error("transaction rejected by policy: {0:?}")]
PolicyEval(TransactionEvalError), PolicyEval(TransactionEvalError),
} }
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError { impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
type Error = (); type Error = ();
fn try_from(value: &'a Error) -> Result<Self, Self::Error> { fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
if let Error::Other(inner) = value if let Error::Other(inner) = value
&& let Some(eval_error) = inner.downcast_ref() && let Some(eval_error) = inner.downcast_ref()
{ {
Ok(eval_error) Ok(eval_error)
} else { } else {
Err(()) Err(())
} }
} }
} }
pub struct ArbiterEvmWallet { pub struct ArbiterEvmWallet {
transport: Arc<Mutex<ClientTransport>>, transport: Arc<Mutex<ClientTransport>>,
address: Address, address: Address,
chain_id: Option<ChainId>, chain_id: Option<ChainId>,
} }
impl ArbiterEvmWallet { impl ArbiterEvmWallet {
#[expect( #[expect(
dead_code, dead_code,
reason = "new will be used in future methods for creating wallets with different parameters" reason = "new will be used in future methods for creating wallets with different parameters"
)] )]
pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self { pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self { Self {
transport, transport,
address, address,
chain_id: None, chain_id: None,
} }
} }
pub const fn address(&self) -> Address { pub const fn address(&self) -> Address {
self.address self.address
} }
#[must_use] #[must_use]
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self { pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
self.chain_id = Some(chain_id); self.chain_id = Some(chain_id);
self self
} }
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> { fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
if let Some(chain_id) = self.chain_id if let Some(chain_id) = self.chain_id
&& !tx.set_chain_id_checked(chain_id) && !tx.set_chain_id_checked(chain_id)
{ {
return Err(Error::TransactionChainIdMismatch { return Err(Error::TransactionChainIdMismatch {
signer: chain_id, signer: chain_id,
tx: tx.chain_id().unwrap(), tx: tx.chain_id().unwrap(),
}); });
} }
Ok(()) Ok(())
} }
} }
#[async_trait] #[async_trait]
impl Signer for ArbiterEvmWallet { impl Signer for ArbiterEvmWallet {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> { async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
Err(Error::other( Err(Error::other(
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing", "hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
)) ))
} }
fn address(&self) -> Address { fn address(&self) -> Address {
self.address self.address
} }
fn chain_id(&self) -> Option<ChainId> { fn chain_id(&self) -> Option<ChainId> {
self.chain_id self.chain_id
} }
fn set_chain_id(&mut self, chain_id: Option<ChainId>) { fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
self.chain_id = chain_id; self.chain_id = chain_id;
} }
} }
#[async_trait] #[async_trait]
impl TxSigner<Signature> for ArbiterEvmWallet { impl TxSigner<Signature> for ArbiterEvmWallet {
fn address(&self) -> Address { fn address(&self) -> Address {
self.address self.address
} }
async fn sign_transaction( async fn sign_transaction(
&self, &self,
tx: &mut dyn SignableTransaction<Signature>, tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> { ) -> Result<Signature> {
self.validate_chain_id(tx)?; self.validate_chain_id(tx)?;
let mut transport = self.transport.lock().await; let mut transport = self.transport.lock().await;
let request_id = next_request_id(); let request_id = next_request_id();
let rlp_transaction = tx.encoded_for_signing(); let rlp_transaction = tx.encoded_for_signing();
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id, request_id,
payload: Some(ClientRequestPayload::Evm(proto_evm::Request { payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
payload: Some(EvmRequestPayload::SignTransaction( payload: Some(EvmRequestPayload::SignTransaction(
EvmSignTransactionRequest { EvmSignTransactionRequest {
wallet_address: self.address.to_vec(), wallet_address: self.address.to_vec(),
rlp_transaction, rlp_transaction,
}, },
)), )),
})), })),
}) })
.await .await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?; .map_err(|_| Error::other("failed to send evm sign transaction request"))?;
let response = transport let response = transport
.recv() .recv()
.await .await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?; .map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport); drop(transport);
if response.request_id != Some(request_id) { if response.request_id != Some(request_id) {
return Err(Error::other( return Err(Error::other(
"received mismatched response id for evm sign transaction", "received mismatched response id for evm sign transaction",
)); ));
} }
let payload = response let payload = response
.payload .payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?; .ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::Evm(proto_evm::Response { let ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload), payload: Some(payload),
}) = payload }) = payload
else { else {
return Err(Error::other( return Err(Error::other(
"unexpected response payload for evm sign transaction request", "unexpected response payload for evm sign transaction request",
)); ));
}; };
let EvmResponsePayload::SignTransaction(response) = payload else { let EvmResponsePayload::SignTransaction(response) = payload else {
return Err(Error::other( return Err(Error::other(
"unexpected evm response payload for sign transaction request", "unexpected evm response payload for sign transaction request",
)); ));
}; };
let result = response let result = response
.result .result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?; .ok_or_else(|| Error::other("missing evm sign transaction result"))?;
match result { match result {
EvmSignTransactionResult::Signature(signature) => { EvmSignTransactionResult::Signature(signature) => {
Signature::try_from(signature.as_slice()) Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server")) .map_err(|_| Error::other("invalid signature returned by server"))
} }
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other( EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
ArbiterEvmSignTransactionError::PolicyEval(eval_error), ArbiterEvmSignTransactionError::PolicyEval(eval_error),
)), )),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!( EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}" "server failed to sign transaction with error code {code}"
))), ))),
} }
} }
} }

View File

@@ -1,2 +1,2 @@
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
pub mod evm; pub mod evm;

View File

@@ -1 +1 @@
/target /target

View File

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

View File

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

View File

@@ -1,252 +1,252 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use hmac::digest::Digest; use hmac::digest::Digest;
use ml_dsa::{ use ml_dsa::{
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature, EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _, SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
}; };
use rand::RngExt; use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
const NONCE_SIZE: usize = 32; const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid length: expected {expected} bytes, got {actual} bytes")] #[error("invalid length: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidLength { pub struct InvalidLength {
pub expected: usize, pub expected: usize,
pub actual: usize, pub actual: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthChallenge { pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE], pub nonce: [u8; NONCE_SIZE],
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
} }
impl AuthChallenge { impl AuthChallenge {
pub fn generate(rng: &mut impl rand::CryptoRng) -> Self { pub fn generate(rng: &mut impl rand::CryptoRng) -> Self {
let timestamp = Utc::now(); let timestamp = Utc::now();
let nonce = { let nonce = {
let mut array = [0; NONCE_SIZE]; let mut array = [0; NONCE_SIZE];
rng.fill(&mut array); rng.fill(&mut array);
array array
}; };
Self { nonce, timestamp } Self { nonce, timestamp }
} }
pub fn format(&self) -> Vec<u8> { pub fn format(&self) -> Vec<u8> {
{ {
let mut buffer = Vec::from(self.nonce); let mut buffer = Vec::from(self.nonce);
let stamp = self let stamp = self
.timestamp .timestamp
.timestamp_nanos_opt() .timestamp_nanos_opt()
.expect("We would be long dead by the time this triggers :)"); .expect("We would be long dead by the time this triggers :)");
buffer.extend_from_slice(stamp.to_be_bytes().as_slice()); buffer.extend_from_slice(stamp.to_be_bytes().as_slice());
buffer buffer
} }
} }
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> { pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
let random_nonce = nonce.as_array().ok_or(InvalidLength { let random_nonce = nonce.as_array().ok_or(InvalidLength {
expected: NONCE_SIZE, expected: NONCE_SIZE,
actual: nonce.len(), actual: nonce.len(),
})?; })?;
Ok(Self { Ok(Self {
nonce: *random_nonce, nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp), timestamp: DateTime::from_timestamp_nanos(timestamp),
}) })
} }
} }
pub type KeyParams = MlDsa87; pub type KeyParams = MlDsa87;
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>); pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
impl crate::hashing::Hashable for PublicKey { impl crate::hashing::Hashable for PublicKey {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_bytes()); hasher.update(self.to_bytes());
} }
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Signature(Box<MlDsaSignature<KeyParams>>); pub struct Signature(Box<MlDsaSignature<KeyParams>>);
#[derive(Debug)] #[derive(Debug)]
pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>); pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
impl PublicKey { impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> { pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec() self.0.encode().0.to_vec()
} }
#[must_use] #[must_use]
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool { pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
let challenge = challenge.format(); let challenge = challenge.format();
self.0 self.0
.verify_with_context(&challenge, context, &signature.0) .verify_with_context(&challenge, context, &signature.0)
} }
} }
impl Signature { impl Signature {
pub fn to_bytes(&self) -> Vec<u8> { pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec() self.0.encode().0.to_vec()
} }
} }
impl SigningKey { impl SigningKey {
pub fn generate() -> Self { pub fn generate() -> Self {
Self(Box::new(KeyParams::key_gen(&mut rand::rng()))) Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
} }
pub fn from_seed(seed: [u8; 32]) -> Self { pub fn from_seed(seed: [u8; 32]) -> Self {
Self(Box::new(KeyParams::from_seed(&Seed::from(seed)))) Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
} }
pub fn to_seed(&self) -> [u8; 32] { pub fn to_seed(&self) -> [u8; 32] {
self.0.to_seed().into() self.0.to_seed().into()
} }
pub fn public_key(&self) -> PublicKey { pub fn public_key(&self) -> PublicKey {
self.0.verifying_key().into() self.0.verifying_key().into()
} }
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> { pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
self.0 self.0
.signing_key() .signing_key()
.sign_deterministic(message, context) .sign_deterministic(message, context)
.map(Into::into) .map(Into::into)
} }
pub fn sign_challenge( pub fn sign_challenge(
&self, &self,
challenge: &AuthChallenge, challenge: &AuthChallenge,
context: &[u8], context: &[u8],
) -> Result<Signature, Error> { ) -> Result<Signature, Error> {
let challenge = challenge.format(); let challenge = challenge.format();
self.sign_message(&challenge, context) self.sign_message(&challenge, context)
} }
} }
impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey { impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey {
fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self { fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self {
Self(Box::new(value)) Self(Box::new(value))
} }
} }
impl From<MlDsaSignature<KeyParams>> for Signature { impl From<MlDsaSignature<KeyParams>> for Signature {
fn from(value: MlDsaSignature<KeyParams>) -> Self { fn from(value: MlDsaSignature<KeyParams>) -> Self {
Self(Box::new(value)) Self(Box::new(value))
} }
} }
impl From<MlDsaSigningKey<KeyParams>> for SigningKey { impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
fn from(value: MlDsaSigningKey<KeyParams>) -> Self { fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
Self(Box::new(value)) Self(Box::new(value))
} }
} }
impl TryFrom<Vec<u8>> for PublicKey { impl TryFrom<Vec<u8>> for PublicKey {
type Error = (); type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> { fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice()) Self::try_from(value.as_slice())
} }
} }
impl TryFrom<&'_ [u8]> for PublicKey { impl TryFrom<&'_ [u8]> for PublicKey {
type Error = (); type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> { fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?; let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?;
Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded)))) Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded))))
} }
} }
impl TryFrom<Vec<u8>> for Signature { impl TryFrom<Vec<u8>> for Signature {
type Error = (); type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> { fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice()) Self::try_from(value.as_slice())
} }
} }
impl TryFrom<&'_ [u8]> for Signature { impl TryFrom<&'_ [u8]> for Signature {
type Error = (); type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> { fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
MlDsaSignature::try_from(value) MlDsaSignature::try_from(value)
.map(|sig| Self(Box::new(sig))) .map(|sig| Self(Box::new(sig)))
.map_err(|_| ()) .map_err(|_| ())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _}; use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
use crate::authn::AuthChallenge; use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT}; use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
#[test] #[test]
fn public_key_round_trip_decodes() { fn public_key_round_trip_decodes() {
let key = MlDsa87::key_gen(&mut rand::rng()); let key = MlDsa87::key_gen(&mut rand::rng());
let encoded = PublicKey::from(key.verifying_key()).to_bytes(); let encoded = PublicKey::from(key.verifying_key()).to_bytes();
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode"); let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
assert_eq!(decoded, PublicKey::from(key.verifying_key())); assert_eq!(decoded, PublicKey::from(key.verifying_key()));
} }
#[test] #[test]
fn signature_round_trip_decodes() { fn signature_round_trip_decodes() {
let key = SigningKey::generate(); let key = SigningKey::generate();
let signature = key let signature = key
.sign_message(b"challenge", CLIENT_CONTEXT) .sign_message(b"challenge", CLIENT_CONTEXT)
.expect("signature should be created"); .expect("signature should be created");
let decoded = let decoded =
Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode"); Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode");
assert_eq!(decoded, signature); assert_eq!(decoded, signature);
} }
#[test] #[test]
fn challenge_verification_uses_context_and_canonical_key_bytes() { fn challenge_verification_uses_context_and_canonical_key_bytes() {
let key = SigningKey::generate(); let key = SigningKey::generate();
let public_key = key.public_key(); let public_key = key.public_key();
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = key let signature = key
.sign_challenge(&challenge, CLIENT_CONTEXT) .sign_challenge(&challenge, CLIENT_CONTEXT)
.expect("signature should be created"); .expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature)); assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
} }
#[test] #[test]
fn signing_key_round_trip_seed_preserves_public_key_and_signing() { fn signing_key_round_trip_seed_preserves_public_key_and_signing() {
let original = SigningKey::generate(); let original = SigningKey::generate();
let restored = SigningKey::from_seed(original.to_seed()); let restored = SigningKey::from_seed(original.to_seed());
assert_eq!(restored.public_key(), original.public_key()); assert_eq!(restored.public_key(), original.public_key());
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = restored let signature = restored
.sign_challenge(&challenge, CLIENT_CONTEXT) .sign_challenge(&challenge, CLIENT_CONTEXT)
.expect("signature should be created"); .expect("signature should be created");
assert!( assert!(
restored restored
.public_key() .public_key()
.verify(&challenge, CLIENT_CONTEXT, &signature) .verify(&challenge, CLIENT_CONTEXT, &signature)
); );
} }
} }

View File

@@ -1,112 +1,112 @@
use std::collections::HashSet; use std::collections::HashSet;
pub use hmac::digest::Digest; pub use hmac::digest::Digest;
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order. /// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
#[diagnostic::on_unimplemented( #[diagnostic::on_unimplemented(
note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type", note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type",
note = "for types from other crates check whether the crate offers a `Hashable` implementation" note = "for types from other crates check whether the crate offers a `Hashable` implementation"
)] )]
pub trait Hashable { pub trait Hashable {
fn hash<H: Digest>(&self, hasher: &mut H); fn hash<H: Digest>(&self, hasher: &mut H);
} }
macro_rules! impl_numeric { macro_rules! impl_numeric {
($($t:ty),*) => { ($($t:ty),*) => {
$( $(
impl Hashable for $t { impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes()); hasher.update(&self.to_be_bytes());
} }
} }
)* )*
}; };
} }
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64); impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
impl Hashable for &[u8] { impl Hashable for &[u8] {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self); hasher.update(self);
} }
} }
impl Hashable for String { impl Hashable for String {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_bytes()); hasher.update(self.as_bytes());
} }
} }
impl<T: Hashable + PartialOrd> Hashable for Vec<T> { impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = { let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>(); let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted sorted
}; };
for item in ref_sorted { for item in ref_sorted {
item.hash(hasher); item.hash(hasher);
} }
} }
} }
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> { impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = { let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>(); let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted sorted
}; };
for item in ref_sorted { for item in ref_sorted {
item.hash(hasher); item.hash(hasher);
} }
} }
} }
impl<T: Hashable> Hashable for Option<T> { impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
match self { match self {
Some(value) => { Some(value) => {
hasher.update([1]); hasher.update([1]);
value.hash(hasher); value.hash(hasher);
} }
None => hasher.update([0]), None => hasher.update([0]),
} }
} }
} }
impl<T: Hashable> Hashable for Box<T> { impl<T: Hashable> Hashable for Box<T> {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
self.as_ref().hash(hasher); self.as_ref().hash(hasher);
} }
} }
impl<T: Hashable> Hashable for &T { impl<T: Hashable> Hashable for &T {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
(*self).hash(hasher); (*self).hash(hasher);
} }
} }
impl Hashable for alloy::primitives::Address { impl Hashable for alloy::primitives::Address {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_slice()); hasher.update(self.as_slice());
} }
} }
impl Hashable for alloy::primitives::U256 { impl Hashable for alloy::primitives::U256 {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_be_bytes::<32>()); hasher.update(self.to_be_bytes::<32>());
} }
} }
impl Hashable for chrono::Duration { impl Hashable for chrono::Duration {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.num_seconds().to_be_bytes()); hasher.update(self.num_seconds().to_be_bytes());
} }
} }
impl Hashable for chrono::DateTime<chrono::Utc> { impl Hashable for chrono::DateTime<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) { fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.timestamp_millis().to_be_bytes()); hasher.update(self.timestamp_millis().to_be_bytes());
} }
} }

View File

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

View File

@@ -1,118 +1,118 @@
use memsafe::MemSafe; use memsafe::MemSafe;
use std::{ use std::{
any::type_name, any::type_name,
fmt, fmt,
ops::{Deref, DerefMut}, ops::{Deref, DerefMut},
}; };
pub trait SafeCellHandle<T> { pub trait SafeCellHandle<T> {
type CellRead<'a>: Deref<Target = T> type CellRead<'a>: Deref<Target = T>
where where
Self: 'a, Self: 'a,
T: 'a; T: 'a;
type CellWrite<'a>: Deref<Target = T> + DerefMut<Target = T> type CellWrite<'a>: Deref<Target = T> + DerefMut<Target = T>
where where
Self: 'a, Self: 'a,
T: 'a; T: 'a;
fn new(value: T) -> Self fn new(value: T) -> Self
where where
Self: Sized; Self: Sized;
fn read(&mut self) -> Self::CellRead<'_>; fn read(&mut self) -> Self::CellRead<'_>;
fn write(&mut self) -> Self::CellWrite<'_>; fn write(&mut self) -> Self::CellWrite<'_>;
fn new_inline<F>(f: F) -> Self fn new_inline<F>(f: F) -> Self
where where
Self: Sized, Self: Sized,
T: Default, T: Default,
F: for<'a> FnOnce(&'a mut T), F: for<'a> FnOnce(&'a mut T),
{ {
let mut cell = Self::new(T::default()); let mut cell = Self::new(T::default());
{ {
let mut handle = cell.write(); let mut handle = cell.write();
f(&mut *handle); f(&mut *handle);
} }
cell cell
} }
#[inline(always)] #[inline(always)]
fn read_inline<F, R>(&mut self, f: F) -> R fn read_inline<F, R>(&mut self, f: F) -> R
where where
F: FnOnce(&T) -> R, F: FnOnce(&T) -> R,
{ {
f(&*self.read()) f(&*self.read())
} }
#[inline(always)] #[inline(always)]
fn write_inline<F, R>(&mut self, f: F) -> R fn write_inline<F, R>(&mut self, f: F) -> R
where where
F: FnOnce(&mut T) -> R, F: FnOnce(&mut T) -> R,
{ {
f(&mut *self.write()) f(&mut *self.write())
} }
} }
pub struct MemSafeCell<T>(MemSafe<T>); pub struct MemSafeCell<T>(MemSafe<T>);
impl<T> fmt::Debug for MemSafeCell<T> { impl<T> fmt::Debug for MemSafeCell<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MemSafeCell") f.debug_struct("MemSafeCell")
.field("inner", &format_args!("<protected {}>", type_name::<T>())) .field("inner", &format_args!("<protected {}>", type_name::<T>()))
.finish() .finish()
} }
} }
impl<T> SafeCellHandle<T> for MemSafeCell<T> { impl<T> SafeCellHandle<T> for MemSafeCell<T> {
type CellRead<'a> type CellRead<'a>
= memsafe::MemSafeRead<'a, T> = memsafe::MemSafeRead<'a, T>
where where
Self: 'a, Self: 'a,
T: 'a; T: 'a;
type CellWrite<'a> type CellWrite<'a>
= memsafe::MemSafeWrite<'a, T> = memsafe::MemSafeWrite<'a, T>
where where
Self: 'a, Self: 'a,
T: 'a; T: 'a;
fn new(value: T) -> Self { fn new(value: T) -> Self {
match MemSafe::new(value) { match MemSafe::new(value) {
Ok(inner) => Self(inner), Ok(inner) => Self(inner),
Err(err) => { Err(err) => {
// If protected memory cannot be allocated, process integrity is compromised. // If protected memory cannot be allocated, process integrity is compromised.
abort_memory_breach("safe cell allocation", &err) abort_memory_breach("safe cell allocation", &err)
} }
} }
} }
#[inline(always)] #[inline(always)]
fn read(&mut self) -> Self::CellRead<'_> { fn read(&mut self) -> Self::CellRead<'_> {
match self.0.read() { match self.0.read() {
Ok(inner) => inner, Ok(inner) => inner,
Err(err) => abort_memory_breach("safe cell read", &err), Err(err) => abort_memory_breach("safe cell read", &err),
} }
} }
#[inline(always)] #[inline(always)]
fn write(&mut self) -> Self::CellWrite<'_> { fn write(&mut self) -> Self::CellWrite<'_> {
match self.0.write() { match self.0.write() {
Ok(inner) => inner, Ok(inner) => inner,
Err(err) => { Err(err) => {
// If protected memory becomes unwritable here, treat it as a fatal memory breach. // If protected memory becomes unwritable here, treat it as a fatal memory breach.
abort_memory_breach("safe cell write", &err) abort_memory_breach("safe cell write", &err)
} }
} }
} }
} }
fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! { fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! {
eprintln!("fatal {action}: {err}"); eprintln!("fatal {action}: {err}");
// SAFETY: Intentionally cause a segmentation fault to prevent further execution in a compromised state. // SAFETY: Intentionally cause a segmentation fault to prevent further execution in a compromised state.
unsafe { unsafe {
let unsafe_pointer = std::ptr::null_mut::<u8>(); let unsafe_pointer = std::ptr::null_mut::<u8>();
std::ptr::write_volatile(unsafe_pointer, 0); std::ptr::write_volatile(unsafe_pointer, 0);
} }
std::process::abort(); std::process::abort();
} }
pub type SafeCell<T> = MemSafeCell<T>; pub type SafeCell<T> = MemSafeCell<T>;

View File

@@ -1,19 +1,19 @@
[package] [package]
name = "arbiter-macros" name = "arbiter-macros"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib] [lib]
proc-macro = true proc-macro = true
doctest = false doctest = false
[dependencies] [dependencies]
proc-macro2 = "1.0" proc-macro2 = "1.0"
quote = "1.0" quote = "1.0"
syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] } syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] }
[dev-dependencies] [dev-dependencies]
arbiter-crypto = { path = "../arbiter-crypto" } arbiter-crypto = { path = "../arbiter-crypto" }
[lints] [lints]
workspace = true workspace = true

View File

@@ -1,131 +1,131 @@
use crate::utils::{HASHABLE_TRAIT_PATH, HMAC_DIGEST_PATH}; use crate::utils::{HASHABLE_TRAIT_PATH, HMAC_DIGEST_PATH};
use proc_macro2::{Span, TokenStream, TokenTree}; use proc_macro2::{Span, TokenStream, TokenTree};
use quote::quote; use quote::quote;
use syn::{DataStruct, DeriveInput, Fields, Generics, Index, parse_quote, spanned::Spanned}; use syn::{DataStruct, DeriveInput, Fields, Generics, Index, parse_quote, spanned::Spanned};
pub(crate) fn derive(input: &DeriveInput) -> TokenStream { pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
match &input.data { match &input.data {
syn::Data::Struct(struct_data) => hashable_struct(input, struct_data), syn::Data::Struct(struct_data) => hashable_struct(input, struct_data),
syn::Data::Enum(_) => { syn::Data::Enum(_) => {
syn::Error::new_spanned(input, "Hashable can currently be derived only for structs") syn::Error::new_spanned(input, "Hashable can currently be derived only for structs")
.to_compile_error() .to_compile_error()
} }
syn::Data::Union(_) => { syn::Data::Union(_) => {
syn::Error::new_spanned(input, "Hashable cannot be derived for unions") syn::Error::new_spanned(input, "Hashable cannot be derived for unions")
.to_compile_error() .to_compile_error()
} }
} }
} }
fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream { fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream {
let ident = &input.ident; let ident = &input.ident;
let hashable_trait = HASHABLE_TRAIT_PATH.to_path(); let hashable_trait = HASHABLE_TRAIT_PATH.to_path();
let hmac_digest = HMAC_DIGEST_PATH.to_path(); let hmac_digest = HMAC_DIGEST_PATH.to_path();
let generics = add_hashable_bounds(input.generics.clone(), &hashable_trait); let generics = add_hashable_bounds(input.generics.clone(), &hashable_trait);
let field_accesses = collect_field_accesses(struct_data); let field_accesses = collect_field_accesses(struct_data);
let hash_calls = build_hash_calls(&field_accesses, &hashable_trait); let hash_calls = build_hash_calls(&field_accesses, &hashable_trait);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote! { quote! {
#[automatically_derived] #[automatically_derived]
impl #impl_generics #hashable_trait for #ident #ty_generics #where_clause { impl #impl_generics #hashable_trait for #ident #ty_generics #where_clause {
fn hash<H: #hmac_digest>(&self, hasher: &mut H) { fn hash<H: #hmac_digest>(&self, hasher: &mut H) {
#(#hash_calls)* #(#hash_calls)*
} }
} }
} }
} }
fn add_hashable_bounds(mut generics: Generics, hashable_trait: &syn::Path) -> Generics { fn add_hashable_bounds(mut generics: Generics, hashable_trait: &syn::Path) -> Generics {
for type_param in generics.type_params_mut() { for type_param in generics.type_params_mut() {
type_param.bounds.push(parse_quote!(#hashable_trait)); type_param.bounds.push(parse_quote!(#hashable_trait));
} }
generics generics
} }
struct FieldAccess { struct FieldAccess {
access: TokenStream, access: TokenStream,
span: Span, span: Span,
} }
fn collect_field_accesses(struct_data: &DataStruct) -> Vec<FieldAccess> { fn collect_field_accesses(struct_data: &DataStruct) -> Vec<FieldAccess> {
match &struct_data.fields { match &struct_data.fields {
Fields::Named(fields) => { Fields::Named(fields) => {
// Keep deterministic alphabetical order for named fields. // Keep deterministic alphabetical order for named fields.
// Do not remove this sort, because it keeps hash output stable regardless of source order. // Do not remove this sort, because it keeps hash output stable regardless of source order.
let mut named_fields = fields let mut named_fields = fields
.named .named
.iter() .iter()
.map(|field| { .map(|field| {
let name = field let name = field
.ident .ident
.as_ref() .as_ref()
.expect("Fields::Named(fields) must have names") .expect("Fields::Named(fields) must have names")
.clone(); .clone();
(name.to_string(), name) (name.to_string(), name)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
named_fields.sort_by(|a, b| a.0.cmp(&b.0)); named_fields.sort_by(|a, b| a.0.cmp(&b.0));
named_fields named_fields
.into_iter() .into_iter()
.map(|(_, name)| FieldAccess { .map(|(_, name)| FieldAccess {
access: quote! { #name }, access: quote! { #name },
span: name.span(), span: name.span(),
}) })
.collect() .collect()
} }
Fields::Unnamed(fields) => fields Fields::Unnamed(fields) => fields
.unnamed .unnamed
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, field)| FieldAccess { .map(|(i, field)| FieldAccess {
access: { access: {
let index = Index::from(i); let index = Index::from(i);
quote! { #index } quote! { #index }
}, },
span: field.ty.span(), span: field.ty.span(),
}) })
.collect(), .collect(),
Fields::Unit => Vec::new(), Fields::Unit => Vec::new(),
} }
} }
fn build_hash_calls( fn build_hash_calls(
field_accesses: &[FieldAccess], field_accesses: &[FieldAccess],
hashable_trait: &syn::Path, hashable_trait: &syn::Path,
) -> Vec<TokenStream> { ) -> Vec<TokenStream> {
field_accesses field_accesses
.iter() .iter()
.map(|field| { .map(|field| {
let access = &field.access; let access = &field.access;
let call = quote! { let call = quote! {
#hashable_trait::hash(&self.#access, hasher); #hashable_trait::hash(&self.#access, hasher);
}; };
respan(call, field.span) respan(call, field.span)
}) })
.collect() .collect()
} }
/// Recursively set span on all tokens, including interpolated ones. /// Recursively set span on all tokens, including interpolated ones.
fn respan(tokens: TokenStream, span: Span) -> TokenStream { fn respan(tokens: TokenStream, span: Span) -> TokenStream {
tokens tokens
.into_iter() .into_iter()
.map(|tt| match tt { .map(|tt| match tt {
TokenTree::Group(g) => { TokenTree::Group(g) => {
let mut new = proc_macro2::Group::new(g.delimiter(), respan(g.stream(), span)); let mut new = proc_macro2::Group::new(g.delimiter(), respan(g.stream(), span));
new.set_span(span); new.set_span(span);
TokenTree::Group(new) TokenTree::Group(new)
} }
mut other => { mut other => {
other.set_span(span); other.set_span(span);
other other
} }
}) })
.collect() .collect()
} }

View File

@@ -1,10 +1,10 @@
use syn::{DeriveInput, parse_macro_input}; use syn::{DeriveInput, parse_macro_input};
mod hashable; mod hashable;
mod utils; mod utils;
#[proc_macro_derive(Hashable)] #[proc_macro_derive(Hashable)]
pub fn derive_hashable(input: proc_macro::TokenStream) -> proc_macro::TokenStream { pub fn derive_hashable(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput); let input = parse_macro_input!(input as DeriveInput);
hashable::derive(&input).into() hashable::derive(&input).into()
} }

View File

@@ -1,24 +1,24 @@
pub(crate) struct ToPath(pub &'static str); pub(crate) struct ToPath(pub &'static str);
impl ToPath { impl ToPath {
pub(crate) fn to_path(&self) -> syn::Path { pub(crate) fn to_path(&self) -> syn::Path {
syn::parse_str(self.0).expect("Invalid path") syn::parse_str(self.0).expect("Invalid path")
} }
} }
macro_rules! ensure_path { macro_rules! ensure_path {
($path:path as $name:ident) => { ($path:path as $name:ident) => {
const _: () = { const _: () = {
#[cfg(test)] #[cfg(test)]
#[expect( #[expect(
unused_imports, unused_imports,
reason = "Ensures the path is valid and will cause a compile error if not" reason = "Ensures the path is valid and will cause a compile error if not"
)] )]
use $path as _; use $path as _;
}; };
pub(crate) const $name: ToPath = ToPath(stringify!($path)); pub(crate) const $name: ToPath = ToPath(stringify!($path));
}; };
} }
ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH); ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH);
ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH); ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH);

View File

@@ -1,35 +1,35 @@
[package] [package]
name = "arbiter-proto" name = "arbiter-proto"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[dependencies] [dependencies]
tonic.workspace = true tonic.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true futures.workspace = true
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost.workspace = true prost.workspace = true
kameo.workspace = true kameo.workspace = true
url = "2.5.8" url = "2.5.8"
miette.workspace = true miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
base64.workspace = true base64.workspace = true
prost-types.workspace = true prost-types.workspace = true
async-trait.workspace = true async-trait.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14.5" tonic-prost-build = "0.14.5"
[dev-dependencies] [dev-dependencies]
rstest.workspace = true rstest.workspace = true
rcgen.workspace = true rcgen.workspace = true
[lib] [lib]
doctest = false doctest = false
[package.metadata.cargo-shear] [package.metadata.cargo-shear]
ignored = ["tonic-prost", "prost"] ignored = ["tonic-prost", "prost"]

View File

@@ -1,21 +1,21 @@
use tonic_prost_build::configure; use tonic_prost_build::configure;
static PROTOBUF_DIR: &str = "../../../protobufs"; static PROTOBUF_DIR: &str = "../../../protobufs";
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo::rerun-if-changed={PROTOBUF_DIR}"); println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
configure() configure()
.message_attribute(".", "#[derive(::kameo::Reply)]") .message_attribute(".", "#[derive(::kameo::Reply)]")
.compile_protos( .compile_protos(
&[ &[
format!("{}/arbiter.proto", PROTOBUF_DIR), format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/operator.proto", PROTOBUF_DIR), format!("{}/operator.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR), format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR), format!("{}/evm.proto", PROTOBUF_DIR),
], ],
&[PROTOBUF_DIR.to_string()], &[PROTOBUF_DIR.to_string()],
) )
.unwrap(); .unwrap();
Ok(()) Ok(())
} }

View File

@@ -1,84 +1,84 @@
pub mod transport; pub mod transport;
pub mod url; pub mod url;
pub mod proto { pub mod proto {
tonic::include_proto!("arbiter"); tonic::include_proto!("arbiter");
pub mod shared { pub mod shared {
tonic::include_proto!("arbiter.shared"); tonic::include_proto!("arbiter.shared");
pub mod evm { pub mod evm {
tonic::include_proto!("arbiter.shared.evm"); tonic::include_proto!("arbiter.shared.evm");
} }
} }
pub mod operator { pub mod operator {
tonic::include_proto!("arbiter.operator"); tonic::include_proto!("arbiter.operator");
pub mod auth { pub mod auth {
tonic::include_proto!("arbiter.operator.auth"); tonic::include_proto!("arbiter.operator.auth");
} }
pub mod evm { pub mod evm {
tonic::include_proto!("arbiter.operator.evm"); tonic::include_proto!("arbiter.operator.evm");
} }
pub mod sdk_client { pub mod sdk_client {
tonic::include_proto!("arbiter.operator.sdk_client"); tonic::include_proto!("arbiter.operator.sdk_client");
} }
pub mod vault { pub mod vault {
tonic::include_proto!("arbiter.operator.vault"); tonic::include_proto!("arbiter.operator.vault");
pub mod bootstrap { pub mod bootstrap {
tonic::include_proto!("arbiter.operator.vault.bootstrap"); tonic::include_proto!("arbiter.operator.vault.bootstrap");
} }
pub mod unseal { pub mod unseal {
tonic::include_proto!("arbiter.operator.vault.unseal"); tonic::include_proto!("arbiter.operator.vault.unseal");
} }
} }
} }
pub mod client { pub mod client {
tonic::include_proto!("arbiter.client"); tonic::include_proto!("arbiter.client");
pub mod auth { pub mod auth {
tonic::include_proto!("arbiter.client.auth"); tonic::include_proto!("arbiter.client.auth");
} }
pub mod evm { pub mod evm {
tonic::include_proto!("arbiter.client.evm"); tonic::include_proto!("arbiter.client.evm");
} }
pub mod vault { pub mod vault {
tonic::include_proto!("arbiter.client.vault"); tonic::include_proto!("arbiter.client.vault");
} }
} }
pub mod evm { pub mod evm {
tonic::include_proto!("arbiter.evm"); tonic::include_proto!("arbiter.evm");
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientMetadata { pub struct ClientMetadata {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub version: Option<String>, pub version: Option<String>,
} }
pub static BOOTSTRAP_PATH: &str = "bootstrap_token"; pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> { pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
static ARBITER_HOME: &str = ".arbiter"; static ARBITER_HOME: &str = ".arbiter";
let home_dir = std::env::home_dir().ok_or(std::io::Error::new( let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
std::io::ErrorKind::PermissionDenied, std::io::ErrorKind::PermissionDenied,
"can not get home directory", "can not get home directory",
))?; ))?;
let arbiter_home = home_dir.join(ARBITER_HOME); let arbiter_home = home_dir.join(ARBITER_HOME);
std::fs::create_dir_all(&arbiter_home)?; std::fs::create_dir_all(&arbiter_home)?;
Ok(arbiter_home) Ok(arbiter_home)
} }

View File

@@ -1,218 +1,218 @@
//! Transport-facing abstractions shared by protocol/session code. //! Transport-facing abstractions shared by protocol/session code.
//! //!
//! This module defines a small set of transport traits that actors and other //! This module defines a small set of transport traits that actors and other
//! protocol code can depend on without knowing anything about the concrete //! protocol code can depend on without knowing anything about the concrete
//! transport underneath. //! transport underneath.
//! //!
//! The abstraction is split into: //! The abstraction is split into:
//! - [`Sender`] for outbound delivery //! - [`Sender`] for outbound delivery
//! - [`Receiver`] for inbound delivery //! - [`Receiver`] for inbound delivery
//! - [`Bi`] as the combined duplex form (`Sender + Receiver`) //! - [`Bi`] as the combined duplex form (`Sender + Receiver`)
//! //!
//! This split lets code depend only on the half it actually needs. For //! This split lets code depend only on the half it actually needs. For
//! example, some actor/session code only sends out-of-band messages, while //! example, some actor/session code only sends out-of-band messages, while
//! auth/state-machine code may need full duplex access. //! auth/state-machine code may need full duplex access.
//! //!
//! [`Bi`] remains intentionally minimal and transport-agnostic: //! [`Bi`] remains intentionally minimal and transport-agnostic:
//! - [`Receiver::recv`] yields inbound messages //! - [`Receiver::recv`] yields inbound messages
//! - [`Sender::send`] accepts outbound messages //! - [`Sender::send`] accepts outbound messages
//! //!
//! Transport-specific adapters, including protobuf or gRPC bridges, live in the //! Transport-specific adapters, including protobuf or gRPC bridges, live in the
//! crates that own those boundaries rather than in `arbiter-proto`. //! crates that own those boundaries rather than in `arbiter-proto`.
//! //!
//! [`Bi`] deliberately does not model request/response correlation. Some //! [`Bi`] deliberately does not model request/response correlation. Some
//! transports may carry multiplexed request/response traffic, some may emit //! transports may carry multiplexed request/response traffic, some may emit
//! out-of-band messages, and some may be one-message-at-a-time state machines. //! out-of-band messages, and some may be one-message-at-a-time state machines.
//! Correlation concerns such as request IDs, pending response maps, and //! Correlation concerns such as request IDs, pending response maps, and
//! out-of-band routing belong in the adapter or connection layer built on top //! out-of-band routing belong in the adapter or connection layer built on top
//! of [`Bi`], not in this abstraction itself. //! of [`Bi`], not in this abstraction itself.
//! //!
//! # Generic Ordering Rule //! # Generic Ordering Rule
//! //!
//! This module consistently uses `Inbound` first and `Outbound` second in //! This module consistently uses `Inbound` first and `Outbound` second in
//! generic parameter lists. //! generic parameter lists.
//! //!
//! For [`Receiver`], [`Sender`], and [`Bi`], this means: //! For [`Receiver`], [`Sender`], and [`Bi`], this means:
//! - `Receiver<Inbound>` //! - `Receiver<Inbound>`
//! - `Sender<Outbound>` //! - `Sender<Outbound>`
//! - `Bi<Inbound, Outbound>` //! - `Bi<Inbound, Outbound>`
//! //!
//! Concretely, for [`Bi`]: //! Concretely, for [`Bi`]:
//! - `recv() -> Option<Inbound>` //! - `recv() -> Option<Inbound>`
//! - `send(Outbound)` //! - `send(Outbound)`
//! //!
//! [`expect_message`] is a small helper for linear protocol steps: it reads one //! [`expect_message`] is a small helper for linear protocol steps: it reads one
//! inbound message from a transport and extracts a typed value from it, failing //! inbound message from a transport and extracts a typed value from it, failing
//! if the channel closes or the message shape is not what the caller expected. //! if the channel closes or the message shape is not what the caller expected.
//! //!
//! [`DummyTransport`] is a no-op implementation useful for tests and local //! [`DummyTransport`] is a no-op implementation useful for tests and local
//! actor execution where no real stream exists. //! actor execution where no real stream exists.
//! //!
//! # Design Notes //! # Design Notes
//! //!
//! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such //! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such
//! as a closed outbound channel. //! as a closed outbound channel.
//! - [`Bi::recv`] returns `None` when the underlying transport closes. //! - [`Bi::recv`] returns `None` when the underlying transport closes.
//! - Message translation is intentionally out of scope for this module. //! - Message translation is intentionally out of scope for this module.
use async_trait::async_trait; use async_trait::async_trait;
use kameo::{error::Infallible, prelude::*}; use kameo::{error::Infallible, prelude::*};
use std::marker::PhantomData; use std::marker::PhantomData;
/// Errors returned by transport adapters implementing [`Bi`]. /// Errors returned by transport adapters implementing [`Bi`].
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
#[error("Transport channel is closed")] #[error("Transport channel is closed")]
ChannelClosed, ChannelClosed,
#[error("Unexpected message received")] #[error("Unexpected message received")]
UnexpectedMessage, UnexpectedMessage,
} }
/// Receives one message from `transport` and extracts a value from it using /// Receives one message from `transport` and extracts a value from it using
/// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and /// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and
/// [`Error::UnexpectedMessage`] if `extractor` returns `None`. /// [`Error::UnexpectedMessage`] if `extractor` returns `None`.
pub async fn expect_message<T, Inbound, Outbound, Target, F>( pub async fn expect_message<T, Inbound, Outbound, Target, F>(
transport: &mut T, transport: &mut T,
extractor: F, extractor: F,
) -> Result<Target, Error> ) -> Result<Target, Error>
where where
T: Bi<Inbound, Outbound> + ?Sized, T: Bi<Inbound, Outbound> + ?Sized,
F: FnOnce(Inbound) -> Option<Target>, F: FnOnce(Inbound) -> Option<Target>,
{ {
let msg = transport.recv().await.ok_or(Error::ChannelClosed)?; let msg = transport.recv().await.ok_or(Error::ChannelClosed)?;
extractor(msg).ok_or(Error::UnexpectedMessage) extractor(msg).ok_or(Error::UnexpectedMessage)
} }
#[async_trait] #[async_trait]
pub trait Sender<Outbound>: Send + Sync { pub trait Sender<Outbound>: Send + Sync {
async fn send(&mut self, item: Outbound) -> Result<(), Error>; async fn send(&mut self, item: Outbound) -> Result<(), Error>;
} }
#[async_trait] #[async_trait]
pub trait Receiver<Inbound>: Send + Sync { pub trait Receiver<Inbound>: Send + Sync {
async fn recv(&mut self) -> Option<Inbound>; async fn recv(&mut self) -> Option<Inbound>;
} }
/// Minimal bidirectional transport abstraction used by protocol code. /// Minimal bidirectional transport abstraction used by protocol code.
/// ///
/// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and /// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and
/// [`Receiver`]. /// [`Receiver`].
/// ///
/// It models a channel with: /// It models a channel with:
/// - inbound items of type `Inbound` read via [`Bi::recv`] /// - inbound items of type `Inbound` read via [`Bi::recv`]
/// - outbound items of type `Outbound` written via [`Bi::send`] /// - outbound items of type `Outbound` written via [`Bi::send`]
/// ///
/// It does not imply request/response sequencing, one-at-a-time exchange, or /// It does not imply request/response sequencing, one-at-a-time exchange, or
/// any built-in correlation mechanism between inbound and outbound items. /// any built-in correlation mechanism between inbound and outbound items.
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {} pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
#[async_trait] #[async_trait]
impl<T, Outbound> Sender<Outbound> for &mut T impl<T, Outbound> Sender<Outbound> for &mut T
where where
T: Sender<Outbound> + ?Sized, T: Sender<Outbound> + ?Sized,
Outbound: Send + 'static, Outbound: Send + 'static,
{ {
async fn send(&mut self, item: Outbound) -> Result<(), Error> { async fn send(&mut self, item: Outbound) -> Result<(), Error> {
(**self).send(item).await (**self).send(item).await
} }
} }
#[async_trait] #[async_trait]
impl<T, Inbound> Receiver<Inbound> for &mut T impl<T, Inbound> Receiver<Inbound> for &mut T
where where
T: Receiver<Inbound> + ?Sized, T: Receiver<Inbound> + ?Sized,
Inbound: Send + 'static, Inbound: Send + 'static,
{ {
async fn recv(&mut self) -> Option<Inbound> { async fn recv(&mut self) -> Option<Inbound> {
(**self).recv().await (**self).recv().await
} }
} }
impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
where where
T: Bi<Inbound, Outbound> + ?Sized, T: Bi<Inbound, Outbound> + ?Sized,
Inbound: Send + 'static, Inbound: Send + 'static,
Outbound: Send + 'static, Outbound: Send + 'static,
{ {
} }
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> { pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
type Sender: Sender<Outbound>; type Sender: Sender<Outbound>;
type Receiver: Receiver<Inbound>; type Receiver: Receiver<Inbound>;
fn split(self) -> (Self::Sender, Self::Receiver); fn split(self) -> (Self::Sender, Self::Receiver);
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self; fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self;
} }
/// No-op [`Bi`] transport for tests and manual actor usage. /// No-op [`Bi`] transport for tests and manual actor usage.
/// ///
/// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore /// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore
/// does not busy-wait or spuriously close the stream. /// does not busy-wait or spuriously close the stream.
pub struct DummyTransport<Inbound, Outbound> { pub struct DummyTransport<Inbound, Outbound> {
_marker: PhantomData<(Inbound, Outbound)>, _marker: PhantomData<(Inbound, Outbound)>,
} }
impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> { impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> {
fn default() -> Self { fn default() -> Self {
Self { Self {
_marker: PhantomData, _marker: PhantomData,
} }
} }
} }
#[async_trait] #[async_trait]
impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound> impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn send(&mut self, _item: Outbound) -> Result<(), Error> { async fn send(&mut self, _item: Outbound) -> Result<(), Error> {
Ok(()) Ok(())
} }
} }
#[async_trait] #[async_trait]
impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound> impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn recv(&mut self) -> Option<Inbound> { async fn recv(&mut self) -> Option<Inbound> {
std::future::pending::<()>().await; std::future::pending::<()>().await;
None None
} }
} }
impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound> impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
} }
pub mod grpc; pub mod grpc;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum ForwardError<I> { pub enum ForwardError<I> {
#[error("Transport error: {0}")] #[error("Transport error: {0}")]
Transport(#[from] Error), Transport(#[from] Error),
#[error("Actor delivery error: {0}")] #[error("Actor delivery error: {0}")]
Actor(SendError<I>), Actor(SendError<I>),
} }
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>( pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
transport: &mut Transport, transport: &mut Transport,
actor: &ActorRef<Handler>, actor: &ActorRef<Handler>,
) -> Result<(), ForwardError<Inbound>> ) -> Result<(), ForwardError<Inbound>>
where where
Transport: Bi<Inbound, <Outbound as Reply>::Ok>, Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
Handler: Actor + Message<Inbound, Reply = Outbound>, Handler: Actor + Message<Inbound, Reply = Outbound>,
Inbound: Send + 'static, Inbound: Send + 'static,
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
{ {
while let Some(request) = transport.recv().await { while let Some(request) = transport.recv().await {
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?; let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
transport.send(resp).await? transport.send(resp).await?
} }
Err(Error::ChannelClosed.into()) Err(Error::ChannelClosed.into())
} }

View File

@@ -1,106 +1,106 @@
use super::{Bi, Receiver, Sender}; use super::{Bi, Receiver, Sender};
use async_trait::async_trait; use async_trait::async_trait;
use futures::StreamExt; use futures::StreamExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
pub struct GrpcSender<Outbound> { pub struct GrpcSender<Outbound> {
tx: mpsc::Sender<Result<Outbound, tonic::Status>>, tx: mpsc::Sender<Result<Outbound, tonic::Status>>,
} }
#[async_trait] #[async_trait]
impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound> impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound>
where where
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> { async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
self.tx self.tx
.send(item) .send(item)
.await .await
.map_err(|_| super::Error::ChannelClosed) .map_err(|_| super::Error::ChannelClosed)
} }
} }
pub struct GrpcReceiver<Inbound> { pub struct GrpcReceiver<Inbound> {
rx: tonic::Streaming<Inbound>, rx: tonic::Streaming<Inbound>,
} }
#[async_trait] #[async_trait]
impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound> impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
{ {
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> { async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
self.rx.next().await self.rx.next().await
} }
} }
pub struct GrpcBi<Inbound, Outbound> { pub struct GrpcBi<Inbound, Outbound> {
sender: GrpcSender<Outbound>, sender: GrpcSender<Outbound>,
receiver: GrpcReceiver<Inbound>, receiver: GrpcReceiver<Inbound>,
} }
impl<Inbound, Outbound> GrpcBi<Inbound, Outbound> impl<Inbound, Outbound> GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
pub fn from_bi_stream( pub fn from_bi_stream(
receiver: tonic::Streaming<Inbound>, receiver: tonic::Streaming<Inbound>,
) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) { ) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) {
let (tx, rx) = mpsc::channel(10); let (tx, rx) = mpsc::channel(10);
let sender = GrpcSender { tx }; let sender = GrpcSender { tx };
let receiver = GrpcReceiver { rx: receiver }; let receiver = GrpcReceiver { rx: receiver };
let bi = GrpcBi { sender, receiver }; let bi = GrpcBi { sender, receiver };
(bi, ReceiverStream::new(rx)) (bi, ReceiverStream::new(rx))
} }
} }
#[async_trait] #[async_trait]
impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound> impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> { async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
self.sender.send(item).await self.sender.send(item).await
} }
} }
#[async_trait] #[async_trait]
impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound> impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> { async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
self.receiver.recv().await self.receiver.recv().await
} }
} }
impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>> impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
for GrpcBi<Inbound, Outbound> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
} }
impl<Inbound, Outbound> impl<Inbound, Outbound>
super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>> super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
for GrpcBi<Inbound, Outbound> for GrpcBi<Inbound, Outbound>
where where
Inbound: Send + Sync + 'static, Inbound: Send + Sync + 'static,
Outbound: Send + Sync + 'static, Outbound: Send + Sync + 'static,
{ {
type Sender = GrpcSender<Outbound>; type Sender = GrpcSender<Outbound>;
type Receiver = GrpcReceiver<Inbound>; type Receiver = GrpcReceiver<Inbound>;
fn split(self) -> (Self::Sender, Self::Receiver) { fn split(self) -> (Self::Sender, Self::Receiver) {
(self.sender, self.receiver) (self.sender, self.receiver)
} }
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self { fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self {
GrpcBi { sender, receiver } GrpcBi { sender, receiver }
} }
} }

View File

@@ -1,128 +1,128 @@
use base64::{Engine as _, prelude::BASE64_URL_SAFE}; use base64::{Engine as _, prelude::BASE64_URL_SAFE};
use rustls_pki_types::CertificateDer; use rustls_pki_types::CertificateDer;
use std::fmt::Display; use std::fmt::Display;
const ARBITER_URL_SCHEME: &str = "arbiter"; const ARBITER_URL_SCHEME: &str = "arbiter";
const CERT_QUERY_KEY: &str = "cert"; const CERT_QUERY_KEY: &str = "cert";
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token"; const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ArbiterUrl { pub struct ArbiterUrl {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub ca_cert: CertificateDer<'static>, pub ca_cert: CertificateDer<'static>,
pub bootstrap_token: Option<String>, pub bootstrap_token: Option<String>,
} }
impl Display for ArbiterUrl { impl Display for ArbiterUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut base = format!( let mut base = format!(
"{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}", "{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}",
self.host, self.host,
self.port, self.port,
BASE64_URL_SAFE.encode(&self.ca_cert) BASE64_URL_SAFE.encode(&self.ca_cert)
); );
if let Some(token) = &self.bootstrap_token { if let Some(token) = &self.bootstrap_token {
base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token)); base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token));
} }
f.write_str(&base) f.write_str(&base)
} }
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error { pub enum Error {
#[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")] #[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")]
#[diagnostic( #[diagnostic(
code(arbiter::url::invalid_scheme), code(arbiter::url::invalid_scheme),
help("The URL must start with '{ARBITER_URL_SCHEME}://'") help("The URL must start with '{ARBITER_URL_SCHEME}://'")
)] )]
InvalidScheme, InvalidScheme,
#[error("Missing host in URL")] #[error("Missing host in URL")]
#[diagnostic( #[diagnostic(
code(arbiter::url::missing_host), code(arbiter::url::missing_host),
help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'") help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'")
)] )]
MissingHost, MissingHost,
#[error("Missing port in URL")] #[error("Missing port in URL")]
#[diagnostic( #[diagnostic(
code(arbiter::url::missing_port), code(arbiter::url::missing_port),
help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'") help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'")
)] )]
MissingPort, MissingPort,
#[error("Missing 'cert' query parameter in URL")] #[error("Missing 'cert' query parameter in URL")]
#[diagnostic( #[diagnostic(
code(arbiter::url::missing_cert), code(arbiter::url::missing_cert),
help("The URL must include a 'cert' query parameter") help("The URL must include a 'cert' query parameter")
)] )]
MissingCert, MissingCert,
#[error("Invalid base64 in 'cert' query parameter: {0}")] #[error("Invalid base64 in 'cert' query parameter: {0}")]
#[diagnostic(code(arbiter::url::invalid_cert_base64))] #[diagnostic(code(arbiter::url::invalid_cert_base64))]
InvalidCertBase64(#[from] base64::DecodeError), InvalidCertBase64(#[from] base64::DecodeError),
} }
impl<'a> TryFrom<&'a str> for ArbiterUrl { impl<'a> TryFrom<&'a str> for ArbiterUrl {
type Error = Error; type Error = Error;
fn try_from(value: &'a str) -> Result<Self, Self::Error> { fn try_from(value: &'a str) -> Result<Self, Self::Error> {
let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?; let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?;
if url.scheme() != ARBITER_URL_SCHEME { if url.scheme() != ARBITER_URL_SCHEME {
return Err(Error::InvalidScheme); return Err(Error::InvalidScheme);
} }
let host = url.host_str().ok_or(Error::MissingHost)?.to_string(); let host = url.host_str().ok_or(Error::MissingHost)?.to_string();
let port = url.port().ok_or(Error::MissingPort)?; let port = url.port().ok_or(Error::MissingPort)?;
let cert_str = url let cert_str = url
.query_pairs() .query_pairs()
.find(|(k, _)| k == CERT_QUERY_KEY) .find(|(k, _)| k == CERT_QUERY_KEY)
.ok_or(Error::MissingCert)? .ok_or(Error::MissingCert)?
.1; .1;
let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?; let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?;
let cert = CertificateDer::from_slice(&cert).into_owned(); let cert = CertificateDer::from_slice(&cert).into_owned();
let bootstrap_token = url let bootstrap_token = url
.query_pairs() .query_pairs()
.find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY) .find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY)
.map(|(_, v)| v.to_string()); .map(|(_, v)| v.to_string());
Ok(ArbiterUrl { Ok(ArbiterUrl {
host, host,
port, port,
ca_cert: cert, ca_cert: cert,
bootstrap_token, bootstrap_token,
}) })
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use rcgen::generate_simple_self_signed; use rcgen::generate_simple_self_signed;
use rstest::rstest; use rstest::rstest;
use super::*; use super::*;
#[rstest] #[rstest]
fn parsing_correctness( fn parsing_correctness(
#[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str, #[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str,
#[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>, #[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>,
) { ) {
let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap(); let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap();
let cert = cert.cert.der(); let cert = cert.cert.der();
let url = ArbiterUrl { let url = ArbiterUrl {
host: host.to_string(), host: host.to_string(),
port: 1234, port: 1234,
ca_cert: cert.clone().into_owned(), ca_cert: cert.clone().into_owned(),
bootstrap_token, bootstrap_token,
}; };
let url_str = url.to_string(); let url_str = url.to_string();
let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap(); let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap();
assert_eq!(url.host, parsed_url.host); assert_eq!(url.host, parsed_url.host);
assert_eq!(url.port, parsed_url.port); assert_eq!(url.port, parsed_url.port);
assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec()); assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec());
assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token); assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token);
} }
} }

View File

@@ -1,62 +1,61 @@
[package] [package]
name = "arbiter-server" name = "arbiter-server"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://git.markettakers.org/MarketTakers/arbiter" repository = "https://git.markettakers.org/MarketTakers/arbiter"
license = "Apache-2.0" license = "Apache-2.0"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] } diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.9.0", features = [ diesel-async = { version = "0.9.0", features = [
"bb8", "bb8",
"migrations", "migrations",
"sqlite", "sqlite",
"tokio", "tokio",
] } ] }
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto" arbiter-crypto.path = "../arbiter-crypto"
arbiter-macros.path = "../arbiter-macros" arbiter-macros.path = "../arbiter-macros"
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tonic.workspace = true tonic.workspace = true
tonic.features = ["tls-aws-lc"] tonic.features = ["tls-aws-lc"]
tokio.workspace = true tokio.workspace = true
rustls.workspace = true rustls.workspace = true
smlang.workspace = true smlang.workspace = true
thiserror.workspace = true thiserror.workspace = true
diesel_migrations = { version = "2.3.2", features = ["sqlite"] } diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
async-trait.workspace = true async-trait.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
rand.workspace = true rand.workspace = true
rand_core.workspace = true rcgen.workspace = true
rcgen.workspace = true chrono.workspace = true
chrono.workspace = true kameo.workspace = true
kameo.workspace = true chacha20poly1305 = { version = "0.10.1", features = ["std"] }
chacha20poly1305 = { version = "0.10.1", features = ["std"] } argon2 = { version = "0.5.3", features = ["zeroize"] }
argon2 = { version = "0.5.3", features = ["zeroize"] } restructed = "0.2.2"
restructed = "0.2.2" strum = { version = "0.28.0", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] } pem = "3.0.6"
pem = "3.0.6" sha2.workspace = true
sha2.workspace = true hmac.workspace = true
hmac.workspace = true alloy.workspace = true
alloy.workspace = true prost-types.workspace = true
prost-types.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry"
arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102"
anyhow = "1.0.102" mutants.workspace = true
mutants.workspace = true subtle = "2.6.1"
subtle = "2.6.1" x25519-dalek.workspace = true
x25519-dalek.workspace = true k256.workspace = true
k256.workspace = true kameo_actors.workspace = true
kameo_actors.workspace = true
[dev-dependencies]
[dev-dependencies] proptest = "1.11.0"
proptest = "1.11.0" rstest.workspace = true
rstest.workspace = true test-log = { version = "0.2", default-features = false, features = ["trace"] }
test-log = { version = "0.2", default-features = false, features = ["trace"] } ml-dsa.workspace = true
ml-dsa.workspace = true
[lib]
[lib] doctest = false
doctest = false

View File

@@ -1,9 +1,9 @@
# For documentation on how to configure this file, # For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli # see https://diesel.rs/guides/configuring-diesel-cli
[print_schema] [print_schema]
file = "src/db/schema.rs" file = "src/db/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory] [migrations_directory]
dir = "migrations" dir = "migrations"

View File

@@ -1 +1 @@
-- This file should undo anything in `up.sql` -- This file should undo anything in `up.sql`

View File

@@ -1,206 +1,206 @@
create table if not exists root_key_history ( create table if not exists root_key_history (
id INTEGER not null PRIMARY KEY, id INTEGER not null PRIMARY KEY,
-- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password) -- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password)
root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key
data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself
ciphertext blob not null, ciphertext blob not null,
tag blob not null, tag blob not null,
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
salt blob not null -- for key deriviation salt blob not null -- for key deriviation
) STRICT; ) STRICT;
create table if not exists aead_encrypted ( create table if not exists aead_encrypted (
id INTEGER not null PRIMARY KEY, id INTEGER not null PRIMARY KEY,
current_nonce blob not null default(1), -- if re-encrypted, this should be incremented current_nonce blob not null default(1), -- if re-encrypted, this should be incremented
ciphertext blob not null, ciphertext blob not null,
tag blob not null, tag blob not null,
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT, associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT,
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_nonce_per_root_key on aead_encrypted ( create unique index if not exists uniq_nonce_per_root_key on aead_encrypted (
current_nonce, current_nonce,
associated_root_key_id associated_root_key_id
); );
create table if not exists tls_history ( create table if not exists tls_history (
id INTEGER not null PRIMARY KEY, id INTEGER not null PRIMARY KEY,
cert text not null, cert text not null,
cert_key text not null, -- PEM Encoded private key cert_key text not null, -- PEM Encoded private key
ca_cert text not null, ca_cert text not null,
ca_key text not null, -- PEM Encoded private key ca_key text not null, -- PEM Encoded private key
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
-- This is a singleton -- This is a singleton
create table if not exists arbiter_settings ( create table if not exists arbiter_settings (
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1 id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
tls_id integer references tls_history (id) on delete RESTRICT tls_id integer references tls_history (id) on delete RESTRICT
) STRICT; ) STRICT;
insert into arbiter_settings (id) values (1) on conflict do nothing; insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists -- ensure singleton row exists
create table if not exists operator_client ( create table if not exists operator_client (
id integer not null primary key, id integer not null primary key,
public_key blob not null, public_key blob not null,
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_operator_client_public_key on operator_client (public_key); create unique index if not exists uniq_operator_client_public_key on operator_client (public_key);
create table if not exists client_metadata ( create table if not exists client_metadata (
id integer not null primary key, id integer not null primary key,
name text not null, -- human-readable name for the client name text not null, -- human-readable name for the client
description text, -- optional description for the client description text, -- optional description for the client
version text, -- client version for tracking and debugging version text, -- client version for tracking and debugging
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
-- created to track history of changes -- created to track history of changes
create table if not exists client_metadata_history ( create table if not exists client_metadata_history (
id integer not null primary key, id integer not null primary key,
metadata_id integer not null references client_metadata (id) on delete cascade, metadata_id integer not null references client_metadata (id) on delete cascade,
client_id integer not null references program_client (id) on delete cascade, client_id integer not null references program_client (id) on delete cascade,
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id); create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id);
create table if not exists program_client ( create table if not exists program_client (
id integer not null primary key, id integer not null primary key,
public_key blob not null, public_key blob not null,
metadata_id integer not null references client_metadata (id) on delete cascade, metadata_id integer not null references client_metadata (id) on delete cascade,
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists program_client_public_key_unique create unique index if not exists program_client_public_key_unique
on program_client (public_key); on program_client (public_key);
create unique index if not exists uniq_program_client_public_key on program_client (public_key); create unique index if not exists uniq_program_client_public_key on program_client (public_key);
create table if not exists evm_wallet ( create table if not exists evm_wallet (
id integer not null primary key, id integer not null primary key,
address blob not null, -- 20-byte Ethereum address address blob not null, -- 20-byte Ethereum address
aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT, aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT,
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address); create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id); create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
create table if not exists evm_wallet_access ( create table if not exists evm_wallet_access (
id integer not null primary key, id integer not null primary key,
wallet_id integer not null references evm_wallet (id) on delete cascade, wallet_id integer not null references evm_wallet (id) on delete cascade,
client_id integer not null references program_client (id) on delete cascade, client_id integer not null references program_client (id) on delete cascade,
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id); create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id);
create table if not exists evm_ether_transfer_limit ( create table if not exists evm_ether_transfer_limit (
id integer not null primary key, id integer not null primary key,
window_secs integer not null, -- window duration in seconds window_secs integer not null, -- window duration in seconds
max_volume blob not null -- big-endian 32-byte U256 max_volume blob not null -- big-endian 32-byte U256
) STRICT; ) STRICT;
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit -- Shared grant properties: client scope, timeframe, fee caps, and rate limit
create table if not exists evm_basic_grant ( create table if not exists evm_basic_grant (
id integer not null primary key, id integer not null primary key,
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID chain_id integer not null, -- EIP-155 chain ID
valid_from integer, -- unix timestamp (seconds), null = no lower bound valid_from integer, -- unix timestamp (seconds), null = no lower bound
valid_until integer, -- unix timestamp (seconds), null = no upper bound valid_until integer, -- unix timestamp (seconds), null = no upper bound
max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
rate_limit_count integer, -- max transactions in window, null = unlimited rate_limit_count integer, -- max transactions in window, null = unlimited
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
revoked_at integer, -- unix timestamp when revoked, null = still active revoked_at integer, -- unix timestamp when revoked, null = still active
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
-- Shared transaction log for all EVM grants, used for rate limit tracking and auditing -- Shared transaction log for all EVM grants, used for rate limit tracking and auditing
create table if not exists evm_transaction_log ( create table if not exists evm_transaction_log (
id integer not null primary key, id integer not null primary key,
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
grant_id integer not null references evm_basic_grant (id) on delete restrict, grant_id integer not null references evm_basic_grant (id) on delete restrict,
chain_id integer not null, chain_id integer not null,
eth_value blob not null, -- always present on any EVM tx eth_value blob not null, -- always present on any EVM tx
signed_at integer not null default(unixepoch ('now')) signed_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id); create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id);
-- =============================== -- ===============================
-- ERC20 token transfer grant -- ERC20 token transfer grant
-- =============================== -- ===============================
create table if not exists evm_token_transfer_grant ( create table if not exists evm_token_transfer_grant (
id integer not null primary key, id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade, basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
token_contract blob not null, -- 20-byte ERC20 contract address token_contract blob not null, -- 20-byte ERC20 contract address
receiver blob -- 20-byte recipient address or null if every recipient allowed receiver blob -- 20-byte recipient address or null if every recipient allowed
) STRICT; ) STRICT;
-- Per-window volume limits for token transfer grants -- Per-window volume limits for token transfer grants
create table if not exists evm_token_transfer_volume_limit ( create table if not exists evm_token_transfer_volume_limit (
id integer not null primary key, id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant (id) on delete cascade, grant_id integer not null references evm_token_transfer_grant (id) on delete cascade,
window_secs integer not null, -- window duration in seconds window_secs integer not null, -- window duration in seconds
max_volume blob not null -- big-endian 32-byte U256 max_volume blob not null -- big-endian 32-byte U256
) STRICT; ) STRICT;
-- Log table for token transfer grant usage -- Log table for token transfer grant usage
create table if not exists evm_token_transfer_log ( create table if not exists evm_token_transfer_log (
id integer not null primary key, id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant (id) on delete restrict, grant_id integer not null references evm_token_transfer_grant (id) on delete restrict,
log_id integer not null references evm_transaction_log (id) on delete restrict, log_id integer not null references evm_transaction_log (id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID chain_id integer not null, -- EIP-155 chain ID
token_contract blob not null, -- 20-byte ERC20 contract address token_contract blob not null, -- 20-byte ERC20 contract address
recipient_address blob not null, -- 20-byte recipient address recipient_address blob not null, -- 20-byte recipient address
value blob not null, -- big-endian 32-byte U256 value blob not null, -- big-endian 32-byte U256
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id); create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id);
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id); create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id);
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id); create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id);
-- =============================== -- ===============================
-- Ether transfer grant (uses base log) -- Ether transfer grant (uses base log)
-- =============================== -- ===============================
create table if not exists evm_ether_transfer_grant ( create table if not exists evm_ether_transfer_grant (
id integer not null primary key, id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade, basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict
) STRICT; ) STRICT;
-- Specific recipient addresses for an ether transfer grant -- Specific recipient addresses for an ether transfer grant
create table if not exists evm_ether_transfer_grant_target ( create table if not exists evm_ether_transfer_grant_target (
id integer not null primary key, id integer not null primary key,
grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade, grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade,
address blob not null -- 20-byte recipient address address blob not null -- 20-byte recipient address
) STRICT; ) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
-- =============================== -- ===============================
-- Integrity Envelopes -- Integrity Envelopes
-- =============================== -- ===============================
create table if not exists integrity_envelope ( create table if not exists integrity_envelope (
id integer not null primary key, id integer not null primary key,
entity_kind text not null, entity_kind text not null,
entity_id blob not null, entity_id blob not null,
payload_version integer not null, payload_version integer not null,
key_version integer not null, key_version integer not null,
mac blob not null, -- 20-byte recipient address mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')), signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now')) created_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id); create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);

View File

@@ -1,123 +1,98 @@
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl;
use diesel::QueryDsl; use diesel_async::RunQueryDsl;
use diesel_async::RunQueryDsl; use kameo::{Actor, messages};
use kameo::{Actor, messages}; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use rand::{RngExt, distr::Alphanumeric, rngs::SysRng}; use subtle::ConstantTimeEq as _;
use rand_core::UnwrapErr; use thiserror::Error;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _; const TOKEN_LENGTH: usize = 64;
use thiserror::Error;
use tracing::warn; pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng();
const TOKEN_LENGTH: usize = 64;
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> { String::default(),
tokio::fs::write(path, content.as_bytes()).await?; |mut accum, char| {
accum += char.to_string().as_str();
#[cfg(unix)] accum
{ },
use std::os::unix::fs::PermissionsExt as _; );
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
} tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
Ok(()) Ok(token)
} }
async fn generate_token(path: &Path) -> Result<SafeCell<[u8; TOKEN_LENGTH]>, std::io::Error> { #[derive(Error, Debug)]
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]); pub enum Error {
{ #[error("Database error: {0}")]
let mut buf = cell.write(); Database(#[from] db::PoolError),
for (slot, b) in buf
.iter_mut() #[error("I/O error: {0}")]
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric)) Io(#[from] std::io::Error),
{
*slot = b; #[error("Database query error: {0}")]
} Query(#[from] diesel::result::Error),
} }
let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()); #[derive(Actor)]
pub struct Bootstrapper {
write_token_file(path, &token_str).await?; token: Option<String>,
}
Ok(cell)
} impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
#[derive(Error, Debug)] let row_count: i64 = {
pub enum Error { let mut conn = db.get().await?;
#[error("Database error: {0}")]
Database(#[from] db::PoolError), schema::operator_client::table
.count()
#[error("I/O error: {0}")] .get_result(&mut conn)
Io(#[from] std::io::Error), .await?
};
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error), let token = if row_count == 0 {
} let token = generate_token().await?;
Some(token)
#[derive(Actor)] } else {
pub struct Bootstrapper { None
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>, };
token_path: Option<PathBuf>,
} Ok(Self { token })
}
impl Bootstrapper { }
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let row_count: i64 = { #[messages]
let mut conn = db.get().await?; impl Bootstrapper {
#[message]
schema::operator_client::table pub fn is_correct_token(&self, token: String) -> bool {
.count() self.token.as_ref().is_some_and(|expected| {
.get_result(&mut conn) let expected_bytes = expected.as_bytes();
.await? let token_bytes = token.as_bytes();
};
let choice = expected_bytes.ct_eq(token_bytes);
let (token, token_path) = if row_count == 0 { bool::from(choice)
let path = home_path()?.join(BOOTSTRAP_PATH); })
let token = generate_token(&path).await?; }
(Some(token), Some(path))
} else { #[message]
(None, None) pub fn consume_token(&mut self, token: String) -> bool {
}; if self.is_correct_token(token) {
self.token = None;
Ok(Self { token, token_path }) true
} } else {
} false
}
impl Bootstrapper { }
fn is_correct_token(&mut self, token: &[u8]) -> bool { }
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token))) #[messages]
}) impl Bootstrapper {
} #[message]
} pub fn get_token(&self) -> Option<String> {
self.token.clone()
#[messages] }
impl Bootstrapper { }
#[message]
pub async fn consume_token(&mut self, token: Vec<u8>) -> bool {
if self.is_correct_token(&token) {
self.token = None;
if let Some(path) = self.token_path.take()
&& let Err(e) = tokio::fs::remove_file(&path).await
{
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption");
}
true
} else {
false
}
}
}
#[messages]
impl Bootstrapper {
#[message]
pub fn get_token(&mut self) -> Option<String> {
self.token
.as_mut()
.map(|cell| cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()))
}
}

View File

@@ -1,341 +1,260 @@
use crate::{ use crate::{
actors::vault::{CreateNew, Decrypt, Vault}, actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity::{self, Integrable}, crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self}, models::{self},
schema, schema,
}, },
evm::{ evm::{
self, ListError, RunKind, self, ListError, RunKind,
policies::{ policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, },
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{ use alloy::{
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature, consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
}; };
use diesel::{ use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
}; };
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use tracing::error;
pub use crate::evm::safe_signer;
pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error)]
/// Integrity guard that binds a wallet's encrypted key ID to its Ethereum address. pub enum SignTransactionError {
/// Both fields are included in the HMAC — swapping `aead_encrypted_id` in the DB #[error("Wallet not found")]
/// invalidates the envelope MAC, and the AEAD ciphertext is also bound to `address` WalletNotFound,
/// as AAD, so decryption fails too.
#[derive(arbiter_macros::Hashable)] #[error("Database error: {0}")]
struct EvmWalletIntegrity { Database(#[from] DatabaseError),
aead_encrypted_id: i32,
address: Address, #[error("Vault error: {0}")]
} Vault(#[from] crate::actors::vault::Error),
impl Integrable for EvmWalletIntegrity { #[error("Vault mailbox error")]
const KIND: &'static str = "evm_wallet"; VaultSend,
}
#[error("Signing error: {0}")]
#[derive(Debug, thiserror::Error)] Signing(#[from] alloy::signers::Error),
pub enum SignTransactionError {
#[error("Wallet not found")] #[error("Policy error: {0}")]
WalletNotFound, Vet(#[from] evm::VetError),
}
#[error("Decrypted key does not match requested wallet address")]
KeyAddressMismatch, #[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Internal signing error")] #[error("Vault error: {0}")]
Internal, Vault(#[from] crate::actors::vault::Error),
#[error("Database error: {0}")] #[error("Vault mailbox error")]
Database(#[from] DatabaseError), VaultSend,
#[error("Vault error: {0}")] #[error("Database error: {0}")]
Vault(#[from] crate::actors::vault::Error), Database(#[from] DatabaseError),
#[error("Vault mailbox error")] #[error("Integrity violation: {0}")]
VaultSend, Integrity(#[from] integrity::Error),
}
#[error("Signing error: {0}")]
Signing(#[from] alloy::signers::Error), #[derive(Actor)]
pub struct EvmActor {
#[error("Policy error: {0}")] pub vault: ActorRef<Vault>,
Vet(#[from] evm::VetError), pub db: DatabasePool,
} pub rng: StdRng,
pub engine: evm::Engine,
#[derive(Debug, thiserror::Error)] }
pub enum Error {
#[error("Vault error: {0}")] impl EvmActor {
Vault(#[from] crate::actors::vault::Error), pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self {
// is it safe to seed rng from system once?
#[error("Vault mailbox error")] // todo: audit
VaultSend, let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone(), vault.clone());
#[error("Database error: {0}")] Self {
Database(#[from] DatabaseError), vault,
db,
#[error("Integrity violation: {0}")] rng,
Integrity(#[from] integrity::Error), engine,
} }
}
impl From<diesel::result::Error> for Error { }
fn from(e: diesel::result::Error) -> Self {
Self::Database(DatabaseError::from(e)) #[messages]
} impl EvmActor {
} #[message]
pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
#[derive(Actor)] let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
pub struct EvmActor {
pub vault: ActorRef<Vault>, let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
pub db: DatabasePool,
pub rng: StdRng, let aead_id: i32 = self
pub engine: evm::Engine, .vault
} .ask(CreateNew { plaintext })
.await
impl EvmActor { .map_err(|_| Error::VaultSend)?;
pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self {
// is it safe to seed rng from system once? let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// todo: audit let wallet_id = insert_into(schema::evm_wallet::table)
let rng = StdRng::from_rng(&mut rng()); .values(&models::NewEvmWallet {
let engine = evm::Engine::new(db.clone(), vault.clone()); address: address.as_slice().to_vec(),
Self { aead_encrypted_id: aead_id,
vault, })
db, .returning(schema::evm_wallet::id)
rng, .get_result(&mut conn)
engine, .await
} .map_err(DatabaseError::from)?;
}
} Ok((wallet_id, address))
}
#[messages]
impl EvmActor { #[message]
#[message] pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
pub async fn generate(&mut self) -> Result<(i32, Address), Error> { let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let (mut key_cell, address) = safe_signer::generate(&mut self.rng); let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); .load(&mut conn)
.await
let aead_id: i32 = self .map_err(DatabaseError::from)?;
.vault
.ask(CreateNew { Ok(rows
plaintext, .into_iter()
aad: address.as_slice().to_vec(), .map(|w| (w.id, Address::from_slice(&w.address)))
}) .collect())
.await }
.map_err(|_| Error::VaultSend)?; }
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; #[messages]
let wallet_id = conn impl EvmActor {
.exclusive_transaction(async |conn| { #[message]
let wallet_id: i32 = insert_into(schema::evm_wallet::table) pub async fn operator_create_grant(
.values(&models::NewEvmWallet { &mut self,
address: address.as_slice().to_vec(), basic: SharedGrantSettings,
aead_encrypted_id: aead_id, grant: SpecificGrant,
}) ) -> Result<i32, Error> {
.returning(schema::evm_wallet::id) match grant {
.get_result(conn) SpecificGrant::EtherTransfer(settings) => self
.await .engine
.map_err(DatabaseError::from) .create_grant::<EtherTransfer>(CombinedSettings {
.map_err(Error::Database)?; shared: basic,
specific: settings,
integrity::sign_entity( })
conn, .await
&self.vault, .map_err(Error::from),
&EvmWalletIntegrity { address, aead_encrypted_id: aead_id }, SpecificGrant::TokenTransfer(settings) => self
wallet_id, .engine
) .create_grant::<TokenTransfer>(CombinedSettings {
.await shared: basic,
.map_err(Error::Integrity)?; specific: settings,
})
Ok::<i32, Error>(wallet_id) .await
}) .map_err(Error::from),
.await?; }
}
Ok((wallet_id, address))
} #[message]
pub async fn useragent_delete_grant(
#[message] &mut self,
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> { grant_id: i32,
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; ) -> Result<(), Error> {
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table self.engine
.select(models::EvmWallet::as_select()) .revoke_grant(grant_id)
.load(&mut conn) .await
.await .map_err(Error::from)
.map_err(DatabaseError::from)?; }
Ok(rows #[message]
.into_iter() pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
.map(|w| (w.id, Address::from_slice(&w.address))) match self.engine.list_all_grants().await {
.collect()) Ok(grants) => Ok(grants),
} Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
} Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
}
#[messages] }
impl EvmActor {
#[message] #[message]
pub async fn operator_create_grant( pub async fn shared_analyze_transaction(
&mut self, &mut self,
basic: SharedGrantSettings, client_id: i32,
grant: SpecificGrant, wallet_address: Address,
) -> Result<i32, Error> { transaction: TxEip1559,
match grant { ) -> Result<SpecificMeaning, SignTransactionError> {
SpecificGrant::EtherTransfer(settings) => self let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
.engine let wallet = schema::evm_wallet::table
.create_grant::<EtherTransfer>(CombinedSettings { .select(models::EvmWallet::as_select())
shared: basic, .filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
specific: settings, .first(&mut conn)
}) .await
.await .optional()
.map_err(Error::from), .map_err(DatabaseError::from)?
SpecificGrant::TokenTransfer(settings) => self .ok_or(SignTransactionError::WalletNotFound)?;
.engine let wallet_access = schema::evm_wallet_access::table
.create_grant::<TokenTransfer>(CombinedSettings { .select(models::EvmWalletAccess::as_select())
shared: basic, .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
specific: settings, .filter(schema::evm_wallet_access::client_id.eq(client_id))
}) .first(&mut conn)
.await .await
.map_err(Error::from), .optional()
} .map_err(DatabaseError::from)?
} .ok_or(SignTransactionError::WalletNotFound)?;
drop(conn);
#[message]
pub async fn useragent_delete_grant( let meaning = self
&mut self, .engine
grant_id: i32, .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
) -> Result<(), Error> { .await?;
self.engine
.revoke_grant(grant_id) Ok(meaning)
.await }
.map_err(Error::from)
} #[message]
pub async fn client_sign_transaction(
#[message] &mut self,
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { client_id: i32,
match self.engine.list_all_grants().await { wallet_address: Address,
Ok(grants) => Ok(grants), mut transaction: TxEip1559,
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)), ) -> Result<Signature, SignTransactionError> {
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)), let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
} let wallet = schema::evm_wallet::table
} .select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
#[message] .first(&mut conn)
pub async fn shared_analyze_transaction( .await
&mut self, .optional()
client_id: i32, .map_err(DatabaseError::from)?
wallet_address: Address, .ok_or(SignTransactionError::WalletNotFound)?;
transaction: TxEip1559, let wallet_access = schema::evm_wallet_access::table
) -> Result<SpecificMeaning, SignTransactionError> { .select(models::EvmWalletAccess::as_select())
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
let wallet = schema::evm_wallet::table .filter(schema::evm_wallet_access::client_id.eq(client_id))
.select(models::EvmWallet::as_select()) .first(&mut conn)
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .await
.first(&mut conn) .optional()
.await .map_err(DatabaseError::from)?
.optional() .ok_or(SignTransactionError::WalletNotFound)?;
.map_err(DatabaseError::from)? drop(conn);
.ok_or(SignTransactionError::WalletNotFound)?;
let wallet_access = schema::evm_wallet_access::table let raw_key: SafeCell<Vec<u8>> = self
.select(models::EvmWalletAccess::as_select()) .vault
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) .ask(Decrypt {
.filter(schema::evm_wallet_access::client_id.eq(client_id)) aead_id: wallet.aead_encrypted_id,
.first(&mut conn) })
.await .await
.optional() .map_err(|_| SignTransactionError::VaultSend)?;
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?; let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
drop(conn);
self.engine
let meaning = self .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.engine .await?;
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?; Ok(signer.sign_transaction_sync(&mut transaction)?)
}
Ok(meaning) }
}
#[message]
pub async fn client_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
mut transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
.first(&mut conn)
.await
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn)
.await
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
drop(conn);
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let attestation = integrity::verify_entity(
&mut conn,
&self.vault,
&EvmWalletIntegrity {
address: wallet_address,
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id,
)
.await
.map_err(|e| {
error!(?e, wallet_id = wallet.id, "EVM wallet integrity check failed");
SignTransactionError::Internal
})?;
drop(conn);
if attestation != integrity::AttestationStatus::Attested {
error!(
wallet_id = wallet.id,
"EVM wallet integrity unavailable; refusing to sign"
);
return Err(SignTransactionError::Internal);
}
let raw_key: SafeCell<Vec<u8>> = self
.vault
.ask(Decrypt {
aead_id: wallet.aead_encrypted_id,
aad: wallet.address.clone(),
})
.await
.map_err(|_| SignTransactionError::VaultSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
if signer.address() != wallet_address {
error!(
expected = %wallet_address,
actual = %signer.address(),
"Decrypted private key address does not match requested wallet"
);
return Err(SignTransactionError::KeyAddressMismatch);
}
self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}

View File

@@ -1,127 +1,127 @@
use crate::{ use crate::{
actors::flow_coordinator::ApprovalError, actors::flow_coordinator::ApprovalError,
peers::{ peers::{
client::ClientProfile, client::ClientProfile,
operator::{OperatorSession, session::BeginNewClientApproval}, operator::{OperatorSession, session::BeginNewClientApproval},
}, },
}; };
use kameo::{ use kameo::{
Actor, messages, Actor, messages,
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef}, prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender, reply::ReplySender,
}; };
use std::{ops::ControlFlow, time::Duration}; use std::{ops::ControlFlow, time::Duration};
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30); const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
pub operators: Vec<ActorRef<OperatorSession>>, pub operators: Vec<ActorRef<OperatorSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>, pub reply: ReplySender<Result<bool, ApprovalError>>,
} }
pub struct ClientApprovalController { pub struct ClientApprovalController {
/// Number of operators 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, pending: usize,
/// Number of approvals received so far. /// Number of approvals received so far.
approved: usize, approved: usize,
reply: Option<ReplySender<Result<bool, ApprovalError>>>, reply: Option<ReplySender<Result<bool, ApprovalError>>>,
} }
impl ClientApprovalController { impl ClientApprovalController {
fn send_reply(&mut self, result: Result<bool, ApprovalError>) { fn send_reply(&mut self, result: Result<bool, ApprovalError>) {
if let Some(reply) = self.reply.take() { if let Some(reply) = self.reply.take() {
reply.send(result); reply.send(result);
} }
} }
} }
impl Actor for ClientApprovalController { impl Actor for ClientApprovalController {
type Args = Args; type Args = Args;
type Error = (); type Error = ();
async fn on_start( async fn on_start(
Args { Args {
client, client,
operators, operators,
reply, reply,
}: Self::Args, }: Self::Args,
actor_ref: ActorRef<Self>, actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let this = Self { let this = Self {
pending: operators.len(), pending: operators.len(),
approved: 0, approved: 0,
reply: Some(reply), reply: Some(reply),
}; };
for operator in operators { for operator in operators {
actor_ref.link(&operator).await; actor_ref.link(&operator).await;
let _ = operator let _ = operator
.tell(BeginNewClientApproval { .tell(BeginNewClientApproval {
client: client.clone(), client: client.clone(),
controller: actor_ref.clone(), controller: actor_ref.clone(),
}) })
.await; .await;
} }
let weak = actor_ref.downgrade(); let weak = actor_ref.downgrade();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(APPROVAL_TIMEOUT).await; tokio::time::sleep(APPROVAL_TIMEOUT).await;
if let Some(r) = weak.upgrade() { if let Some(r) = weak.upgrade() {
let _ = r.tell(OnApprovalTimeout {}).await; let _ = r.tell(OnApprovalTimeout {}).await;
} }
}); });
Ok(this) Ok(this)
} }
async fn on_link_died( async fn on_link_died(
&mut self, &mut self,
_: WeakActorRef<Self>, _: WeakActorRef<Self>,
_: ActorId, _: ActorId,
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
// A linked operator 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); self.pending = self.pending.saturating_sub(1);
if self.pending == 0 { if self.pending == 0 {
// At least one operator didn't approve: deny. // At least one operator didn't approve: deny.
self.send_reply(Ok(false)); self.send_reply(Ok(false));
return Ok(ControlFlow::Break(ActorStopReason::Normal)); return Ok(ControlFlow::Break(ActorStopReason::Normal));
} }
Ok(ControlFlow::Continue(())) Ok(ControlFlow::Continue(()))
} }
} }
#[messages] #[messages]
impl ClientApprovalController { impl ClientApprovalController {
#[message(ctx)] #[message(ctx)]
pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) { pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
if !approved { if !approved {
// Denial wins immediately regardless of other pending responses. // Denial wins immediately regardless of other pending responses.
self.send_reply(Ok(false)); self.send_reply(Ok(false));
ctx.stop(); ctx.stop();
return; return;
} }
self.approved += 1; self.approved += 1;
self.pending = self.pending.saturating_sub(1); self.pending = self.pending.saturating_sub(1);
if self.pending == 0 { if self.pending == 0 {
// Every connected operator approved. // Every connected operator approved.
self.send_reply(Ok(true)); self.send_reply(Ok(true));
ctx.stop(); ctx.stop();
} }
} }
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded /// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
/// by then is treated as a denial to prevent zombie sessions from blocking the flow. /// by then is treated as a denial to prevent zombie sessions from blocking the flow.
#[message(ctx)] #[message(ctx)]
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) { pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
if self.pending > 0 { if self.pending > 0 {
self.send_reply(Ok(false)); self.send_reply(Ok(false));
ctx.stop(); ctx.stop();
} }
} }
} }

View File

@@ -1,132 +1,114 @@
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::client_connect_approval::ClientApprovalController, flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::{GetConnected, OperatorRegistry}, operator_registry::{GetConnected, OperatorRegistry},
}, },
peers::client::{ClientProfile, session::ClientSession}, peers::client::{ClientProfile, session::ClientSession},
}; };
use kameo::{ use kameo::{
Actor, Actor,
actor::{ActorId, ActorRef, Spawn}, actor::{ActorId, ActorRef, Spawn},
messages, messages,
prelude::{ActorStopReason, Context, WeakActorRef}, prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply, reply::DelegatedReply,
}; };
use std::{collections::HashMap, ops::ControlFlow}; use std::{collections::HashMap, ops::ControlFlow};
use tracing::info; use tracing::info;
pub mod client_connect_approval; pub mod client_connect_approval;
pub struct FlowCoordinator { pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>, pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
/// Maps DB `client_id` → `ActorId` for fast connected-client lookup. operator_registry: ActorRef<OperatorRegistry>,
client_ids: HashMap<i32, ActorId>, }
operator_registry: ActorRef<OperatorRegistry>,
} impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
impl FlowCoordinator { Self {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self { clients: HashMap::default(),
Self { operator_registry,
clients: HashMap::default(), }
client_ids: HashMap::default(), }
operator_registry, }
}
} impl Actor for FlowCoordinator {
} type Args = Self;
impl Actor for FlowCoordinator { type Error = ();
type Args = Self;
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
type Error = (); Ok(args)
}
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args) async fn on_link_died(
} &mut self,
_: WeakActorRef<Self>,
async fn on_link_died( id: ActorId,
&mut self, _: ActorStopReason,
_: WeakActorRef<Self>, ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
id: ActorId, if self.clients.remove(&id).is_some() {
_: ActorStopReason, info!(
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ?id,
if self.clients.remove(&id).is_some() { actor = "FlowCoordinator",
self.client_ids.retain(|_, actor_id| *actor_id != id); event = "client.disconnected"
info!( );
?id, } else {
actor = "FlowCoordinator", info!(
event = "client.disconnected" ?id,
); actor = "FlowCoordinator",
} else { event = "unknown.actor.disconnected"
info!( );
?id, }
actor = "FlowCoordinator", Ok(ControlFlow::Continue(()))
event = "unknown.actor.disconnected" }
); }
}
Ok(ControlFlow::Continue(())) #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
} pub enum ApprovalError {
} #[error("No operators connected")]
NoOperatorsConnected,
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)] }
pub enum ApprovalError {
#[error("No operators connected")] #[messages]
NoOperatorsConnected, impl FlowCoordinator {
} #[message(ctx)]
pub async fn register_client(
#[messages] &mut self,
impl FlowCoordinator { actor: ActorRef<ClientSession>,
#[message(ctx)] ctx: &mut Context<Self, ()>,
pub async fn register_client( ) {
&mut self, info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
client_id: i32, ctx.actor_ref().link(&actor).await;
actor: ActorRef<ClientSession>, self.clients.insert(actor.id(), actor);
ctx: &mut Context<Self, ()>, }
) {
info!(id = %actor.id(), client_id, actor = "FlowCoordinator", event = "client.connected"); #[message(ctx)]
ctx.actor_ref().link(&actor).await; pub async fn request_client_approval(
self.client_ids.insert(client_id, actor.id()); &mut self,
self.clients.insert(actor.id(), actor); client: ClientProfile,
} ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> {
#[message] let (reply, Some(reply_sender)) = ctx.reply_sender() else {
pub fn is_client_connected(&self, client_id: i32) -> bool { unreachable!("Expected `request_client_approval` to have callback channel");
self.client_ids.contains_key(&client_id) };
}
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
/// Returns the DB `client_ids` of all currently connected SDK clients. reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
/// Used by operator sessions on startup to seed their approved-client set. return reply;
#[message] };
pub fn get_connected_client_ids(&self) -> Vec<i32> {
self.client_ids.keys().copied().collect() if refs.is_empty() {
} reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
#[message(ctx)] }
pub async fn request_client_approval(
&mut self, ClientApprovalController::spawn(client_connect_approval::Args {
client: ClientProfile, client,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>, operators: refs,
) -> DelegatedReply<Result<bool, ApprovalError>> { reply: reply_sender,
let (reply, Some(reply_sender)) = ctx.reply_sender() else { });
unreachable!("Expected `request_client_approval` to have callback channel");
}; reply
}
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::NoOperatorsConnected));
return reply;
}
ClientApprovalController::spawn(client_connect_approval::Args {
client,
operators: refs,
reply: reply_sender,
});
reply
}
}

View File

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

View File

@@ -1,61 +1,61 @@
use crate::peers::operator::OperatorSession; use crate::peers::operator::OperatorSession;
use kameo::{ use kameo::{
Actor, Actor,
actor::{ActorId, ActorRef}, actor::{ActorId, ActorRef},
error::Infallible, error::Infallible,
messages, messages,
prelude::{ActorStopReason, Context, WeakActorRef}, prelude::{ActorStopReason, Context, WeakActorRef},
}; };
use std::{collections::HashMap, ops::ControlFlow}; use std::{collections::HashMap, ops::ControlFlow};
use tracing::info; use tracing::info;
#[derive(Default)] #[derive(Default)]
pub struct OperatorRegistry { pub struct OperatorRegistry {
connected: HashMap<ActorId, ActorRef<OperatorSession>>, connected: HashMap<ActorId, ActorRef<OperatorSession>>,
} }
impl Actor for OperatorRegistry { impl Actor for OperatorRegistry {
type Args = Self; type Args = Self;
type Error = Infallible; type Error = Infallible;
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> { async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args) Ok(args)
} }
async fn on_link_died( async fn on_link_died(
&mut self, &mut self,
_: WeakActorRef<Self>, _: WeakActorRef<Self>,
id: ActorId, id: ActorId,
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.connected.remove(&id).is_some() { if self.connected.remove(&id).is_some() {
info!( info!(
?id, ?id,
actor = "OperatorRegistry", actor = "OperatorRegistry",
event = "operator.disconnected" event = "operator.disconnected"
); );
} }
Ok(ControlFlow::Continue(())) Ok(ControlFlow::Continue(()))
} }
} }
#[messages] #[messages]
impl OperatorRegistry { impl OperatorRegistry {
#[message(ctx)] #[message(ctx)]
pub async fn connect_operator( pub async fn connect_operator(
&mut self, &mut self,
actor: ActorRef<OperatorSession>, actor: ActorRef<OperatorSession>,
ctx: &mut Context<Self, ()>, ctx: &mut Context<Self, ()>,
) { ) {
info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected"); info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected");
ctx.actor_ref().link(&actor).await; ctx.actor_ref().link(&actor).await;
self.connected.insert(actor.id(), actor); self.connected.insert(actor.id(), actor);
} }
#[message] #[message]
pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> { pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
self.connected.values().cloned().collect() self.connected.values().cloned().collect()
} }
} }

View File

@@ -1,495 +1,462 @@
use crate::{ use crate::{
crypto::{ crypto::{
KeyCell, derive_key, KeyCell, derive_key,
encryption::v1::{self, Nonce}, encryption::v1::{self, Nonce},
integrity::v1::HmacSha256, integrity::v1::HmacSha256,
}, },
db::{ db::{
self, self,
models::{self, RootKeyHistory}, models::{self, RootKeyHistory},
schema::{self}, schema::{self},
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper, ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
dsl::{insert_into, update}, dsl::{insert_into, update},
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{KeyInit as _, Mac as _}; use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish}; use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info, warn}; use tracing::{error, info};
pub mod events { pub mod events {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Bootstrapped; pub struct Bootstrapped;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Unsealed; pub struct Unsealed;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct VaultResealed; pub struct VaultResealed;
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Vault is already bootstrapped")] #[error("Vault is already bootstrapped")]
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Vault is not bootstrapped")] #[error("Vault is not bootstrapped")]
NotBootstrapped, NotBootstrapped,
#[error("Vault is sealed")] #[error("Vault is sealed")]
Sealed, Sealed,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut, #[error("Requested aead entry not found")]
NotFound,
#[error("Requested aead entry not found")]
NotFound, #[error("Encryption error: {0}")]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Encryption error: {0}")]
Encryption(#[from] chacha20poly1305::aead::Error), #[error("Database error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database error: {0}")]
DatabaseConnection(#[from] db::PoolError), #[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error), #[error("Broken database")]
BrokenDatabase,
#[error("Broken database")] }
BrokenDatabase,
struct Unsealed {
#[error("Integrity key version mismatch: envelope uses key {envelope}, current key is {current}")] root_key_history_id: i32,
KeyVersionMismatch { envelope: i32, current: i32 }, root_key: KeyCell,
} }
struct Unsealed { #[derive(Default, EnumDiscriminants)]
root_key_history_id: i32, #[strum_discriminants(derive(Reply), vis(pub), name(VaultState))]
root_key: KeyCell, enum State {
} #[default]
Unbootstrapped,
#[derive(Default, EnumDiscriminants)] Sealed {
#[strum_discriminants(derive(Reply), vis(pub), name(VaultState))] root_key_history_id: i32,
enum State { },
#[default] Unsealed(Unsealed),
Unbootstrapped, }
Sealed {
root_key_history_id: i32, /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
}, ///
Unsealed(Unsealed), /// Provides API for encrypting and decrypting data using the vault root key.
} /// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)]
const MAX_UNSEAL_ATTEMPTS: u32 = 5; pub struct Vault {
db: db::DatabasePool,
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). state: State,
/// events: ActorRef<MessageBus>,
/// Provides API for encrypting and decrypting data using the vault root key. }
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)] #[messages]
pub struct Vault { impl Vault {
db: db::DatabasePool, pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
state: State, let state = {
events: ActorRef<MessageBus>, let mut conn = db.get().await?;
unseal_failures: u32,
} let (root_key_history,) = schema::arbiter_settings::table
.left_join(schema::root_key_history::table)
#[messages] .select((Option::<RootKeyHistory>::as_select(),))
impl Vault { .get_result::<(Option<RootKeyHistory>,)>(&mut conn)
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> { .await?;
let state = {
let mut conn = db.get().await?; match root_key_history {
Some(root_key_history) => State::Sealed {
let (root_key_history,) = schema::arbiter_settings::table root_key_history_id: root_key_history.id,
.left_join(schema::root_key_history::table) },
.select((Option::<RootKeyHistory>::as_select(),)) None => State::Unbootstrapped,
.get_result::<(Option<RootKeyHistory>,)>(&mut conn) }
.await?; };
match root_key_history { Ok(Self { db, state, events })
Some(root_key_history) => State::Sealed { }
root_key_history_id: root_key_history.id,
}, // Exclusive transaction to avoid race condtions if multiple vaults write
None => State::Unbootstrapped, // additional layer of protection against nonce-reuse
} async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
}; let mut conn = pool.get().await?;
Ok(Self { db, state, events, unseal_failures: 0 }) let nonce = conn
} .exclusive_transaction(async |conn| {
let current_nonce: Vec<u8> = schema::root_key_history::table
// Exclusive transaction to avoid race condtions if multiple vaults write .filter(schema::root_key_history::id.eq(root_key_id))
// additional layer of protection against nonce-reuse .select(schema::root_key_history::data_encryption_nonce)
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> { .first(&mut *conn)
let mut conn = pool.get().await?; .await?;
let nonce = conn let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
.exclusive_transaction(async |conn| { error!(
let current_nonce: Vec<u8> = schema::root_key_history::table "Broken database: invalid nonce for root key history id={}",
.filter(schema::root_key_history::id.eq(root_key_id)) root_key_id
.select(schema::root_key_history::data_encryption_nonce) );
.first(&mut *conn) Error::BrokenDatabase
.await?; })?;
nonce.increment();
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!( update(schema::root_key_history::table)
"Broken database: invalid nonce for root key history id={}", .filter(schema::root_key_history::id.eq(root_key_id))
root_key_id .set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
); .execute(&mut *conn)
Error::BrokenDatabase .await?;
})?;
nonce.increment(); Result::<_, Error>::Ok(nonce)
})
update(schema::root_key_history::table) .await?;
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec())) Ok(nonce)
.execute(&mut *conn) }
.await?;
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
Result::<_, Error>::Ok(nonce) match state {
}) State::Unsealed(unsealed) => Ok(unsealed),
.await?; State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
Ok(nonce) }
} }
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> { #[message]
match state { pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
State::Unsealed(unsealed) => Ok(unsealed), if !matches!(self.state, State::Unbootstrapped) {
State::Unbootstrapped => Err(Error::NotBootstrapped), return Err(Error::AlreadyBootstrapped);
State::Sealed { .. } => Err(Error::Sealed), }
} let salt = v1::generate_salt();
} let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random();
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { // Zero nonces are fine because they are one-time
if !matches!(self.state, State::Unbootstrapped) { let root_key_nonce = Nonce::default();
return Err(Error::AlreadyBootstrapped); let data_encryption_nonce = Nonce::default();
}
let salt = v1::generate_salt(); let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let mut seal_key = derive_key(seal_key_raw, &salt); let root_key_reader = reader.as_slice();
let mut root_key = KeyCell::new_secure_random(); seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
// Zero nonces are fine because they are one-time .map_err(|err| {
let root_key_nonce = Nonce::default(); error!(?err, "Fatal bootstrap error");
let data_encryption_nonce = Nonce::default(); Error::Encryption(err)
})
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { })?;
let root_key_reader = reader.as_slice();
seal_key let mut conn = self.db.get().await?;
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.map_err(|err| { let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
error!(?err, "Fatal bootstrap error"); let root_key_history_id = conn
Error::Encryption(err) .transaction(async |conn| {
}) let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
})?; .values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(),
let mut conn = self.db.get().await?; tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); data_encryption_nonce: data_encryption_nonce_bytes.clone(),
let root_key_history_id = conn schema_version: 1,
.transaction(async |conn| { salt: salt.to_vec(),
let root_key_history_id: i32 = insert_into(schema::root_key_history::table) })
.values(&models::NewRootKeyHistory { .returning(schema::root_key_history::id)
ciphertext: root_key_ciphertext.clone(), .get_result(&mut *conn)
tag: v1::ROOT_KEY_TAG.to_vec(), .await?;
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(), update(schema::arbiter_settings::table)
schema_version: 1, .set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
salt: salt.to_vec(), .execute(&mut *conn)
}) .await?;
.returning(schema::root_key_history::id)
.get_result(&mut *conn) Result::<_, diesel::result::Error>::Ok(root_key_history_id)
.await?; })
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id)) self.state = State::Unsealed(Unsealed {
.execute(&mut *conn) root_key,
.await?; root_key_history_id,
});
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
}) info!("Vault bootstrapped successfully");
.await?; let _ = self.events.tell(Publish(events::Bootstrapped)).await;
self.state = State::Unsealed(Unsealed { Ok(())
root_key, }
root_key_history_id,
}); #[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
info!("Vault bootstrapped successfully"); let State::Sealed {
let _ = self.events.tell(Publish(events::Bootstrapped)).await; root_key_history_id,
} = &self.state
Ok(()) else {
} return Err(Error::NotBootstrapped);
};
#[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { // We don't want to hold connection while doing expensive KDF work
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS { let current_key = {
return Err(Error::LockedOut); let mut conn = self.db.get().await?;
} schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id))
let State::Sealed { .select(RootKeyHistory::as_select())
root_key_history_id, .first(&mut conn)
} = &self.state .await?
else { };
return Err(Error::NotBootstrapped);
}; let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
// We don't want to hold connection while doing expensive KDF work error!("Broken database: invalid salt for root key");
let current_key = { Error::BrokenDatabase
let mut conn = self.db.get().await?; })?;
schema::root_key_history::table let mut seal_key = derive_key(seal_key_raw, &salt);
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.select(RootKeyHistory::as_select()) let mut root_key = SafeCell::new(current_key.ciphertext.clone());
.first(&mut conn)
.await? let nonce =
}; Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
error!("Broken database: invalid nonce for root key");
let salt = &current_key.salt; Error::BrokenDatabase
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| { })?;
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase seal_key
})?; .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
let mut seal_key = derive_key(seal_key_raw, &salt); .map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key");
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); Error::InvalidKey
})?;
let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { self.state = State::Unsealed(Unsealed {
error!("Broken database: invalid nonce for root key"); root_key_history_id: current_key.id,
Error::BrokenDatabase root_key: KeyCell::try_from(root_key).map_err(|err| {
})?; error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
if seal_key })?,
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) });
.is_err()
{ info!("Vault unsealed successfully");
self.unseal_failures += 1; let _ = self.events.tell(Publish(events::Unsealed)).await;
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
error!( Ok(())
attempts = self.unseal_failures, }
"Vault locked: maximum failed unseal attempts reached"
); #[message]
} else { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
warn!( let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
attempts = self.unseal_failures,
remaining = MAX_UNSEAL_ATTEMPTS - self.unseal_failures, let row: models::AeadEncrypted = {
"Failed unseal attempt" let mut conn = self.db.get().await?;
); schema::aead_encrypted::table
} .select(models::AeadEncrypted::as_select())
return Err(Error::InvalidKey); .filter(schema::aead_encrypted::id.eq(aead_id))
} .first(&mut conn)
.await
self.unseal_failures = 0; .optional()?
self.state = State::Unsealed(Unsealed { .ok_or(Error::NotFound)?
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"); let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
Error::BrokenDatabase error!(
})?, "Broken database: invalid nonce for aead_encrypted id={}",
}); aead_id
);
info!("Vault unsealed successfully"); Error::BrokenDatabase
let _ = self.events.tell(Publish(events::Unsealed)).await; })?;
let mut output = SafeCell::new(row.ciphertext);
Ok(()) root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
} Ok(output)
}
/// Decrypts an AEAD entry. The `aad` must match the value used at encryption time;
/// a mismatch causes authentication failure, preventing cross-wallet key swaps. // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; let Unsealed {
root_key,
let row: models::AeadEncrypted = { root_key_history_id,
let mut conn = self.db.get().await?; } = Self::expect_unsealed(&mut self.state)?;
schema::aead_encrypted::table
.select(models::AeadEncrypted::as_select()) // Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
.filter(schema::aead_encrypted::id.eq(aead_id)) // Borrow checker note: &mut borrow a few lines above is disjoint from this field
.first(&mut conn) let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?;
.await
.optional()? let mut ciphertext_buffer = plaintext.write();
.ok_or(Error::NotFound)? let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
}; root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| { let ciphertext = std::mem::take(ciphertext_buffer);
error!(
"Broken database: invalid nonce for aead_encrypted id={}", let mut conn = self.db.get().await?;
aead_id let aead_id: i32 = insert_into(schema::aead_encrypted::table)
); .values(&models::NewAeadEncrypted {
Error::BrokenDatabase ciphertext,
})?; tag: v1::TAG.to_vec(),
let mut output = SafeCell::new(row.ciphertext); current_nonce: nonce.to_vec(),
root_key.decrypt_in_place(&nonce, &aad, &mut output)?; schema_version: 1,
Ok(output) associated_root_key_id: *root_key_history_id,
} created_at: Utc::now().into(),
})
/// Creates a new `aead_encrypted` entry and returns its ID. .returning(schema::aead_encrypted::id)
/// The `aad` is bound into the ciphertext and must be reproduced exactly at decryption time. .get_result(&mut conn)
// Creates new `aead_encrypted` entry in the database and returns it's ID .await?;
#[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>, aad: Vec<u8>) -> Result<i32, Error> { Ok(aead_id)
let Unsealed { }
root_key,
root_key_history_id, #[message]
} = Self::expect_unsealed(&mut self.state)?; pub fn get_state(&self) -> VaultState {
self.state.discriminant()
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire }
// Borrow checker note: &mut borrow a few lines above is disjoint from this field
let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?; #[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
let mut ciphertext_buffer = plaintext.write(); let Unsealed {
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut(); root_key,
root_key.encrypt_in_place(&nonce, &aad, &mut *ciphertext_buffer)?; root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let ciphertext = std::mem::take(ciphertext_buffer);
let mut hmac = root_key.0.read_inline(|k| {
let mut conn = self.db.get().await?; HmacSha256::new_from_slice(k)
let aead_id: i32 = insert_into(schema::aead_encrypted::table) .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
.values(&models::NewAeadEncrypted { });
ciphertext, hmac.update(&root_key_history_id.to_be_bytes());
tag: v1::TAG.to_vec(), hmac.update(&mac_input);
current_nonce: nonce.to_vec(),
schema_version: 1, let mac = hmac.finalize().into_bytes().to_vec();
associated_root_key_id: *root_key_history_id, Ok((*root_key_history_id, mac))
created_at: Utc::now().into(), }
})
.returning(schema::aead_encrypted::id) #[message]
.get_result(&mut conn) pub fn verify_integrity(
.await?; &mut self,
mac_input: Vec<u8>,
Ok(aead_id) expected_mac: Vec<u8>,
} key_version: i32,
) -> Result<bool, Error> {
#[message] let Unsealed {
pub fn get_state(&self) -> VaultState { root_key,
self.state.discriminant() root_key_history_id,
} } = Self::expect_unsealed(&mut self.state)?;
#[message] if *root_key_history_id != key_version {
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> { return Ok(false);
let Unsealed { }
root_key,
root_key_history_id, let mut hmac = root_key.0.read_inline(|k| {
} = Self::expect_unsealed(&mut self.state)?; HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
let mut hmac = root_key.0.read_inline(|k| { });
HmacSha256::new_from_slice(k) hmac.update(&key_version.to_be_bytes());
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) hmac.update(&mac_input);
});
hmac.update(&root_key_history_id.to_be_bytes()); Ok(hmac.verify_slice(&expected_mac).is_ok())
hmac.update(&mac_input); }
let mac = hmac.finalize().into_bytes().to_vec(); #[message]
Ok((*root_key_history_id, mac)) pub async fn seal(&mut self) -> Result<(), Error> {
} let Unsealed {
root_key_history_id,
#[message] ..
pub fn verify_integrity( } = Self::expect_unsealed(&mut self.state)?;
&mut self,
mac_input: Vec<u8>, self.state = State::Sealed {
expected_mac: Vec<u8>, root_key_history_id: *root_key_history_id,
key_version: i32, };
) -> Result<bool, Error> { let _ = self.events.tell(Publish(events::VaultResealed)).await;
let Unsealed { Ok(())
root_key, }
root_key_history_id, }
} = Self::expect_unsealed(&mut self.state)?;
#[cfg(test)]
if *root_key_history_id != key_version { mod tests {
return Err(Error::KeyVersionMismatch { use crate::actors::GlobalActors;
envelope: key_version, use arbiter_crypto::safecell::SafeCellHandle as _;
current: *root_key_history_id,
}); use super::*;
}
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
let mut hmac = root_key.0.read_inline(|k| { let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
HmacSha256::new_from_slice(k) .await
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) .unwrap();
}); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
hmac.update(&key_version.to_be_bytes()); actor.bootstrap(seal_key).await.unwrap();
hmac.update(&mac_input); actor
}
Ok(hmac.verify_slice(&expected_mac).is_ok())
} #[tokio::test]
#[test_log::test]
#[message] async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
pub async fn seal(&mut self) -> Result<(), Error> { let db = db::create_test_pool().await;
let Unsealed { let mut actor = bootstrapped_actor(&db).await;
root_key_history_id,
.. let State::Unsealed(Unsealed {
} = Self::expect_unsealed(&mut self.state)?; root_key_history_id,
..
self.state = State::Sealed { }) = actor.state
root_key_history_id: *root_key_history_id, else {
}; panic!("expected unsealed state")
let _ = self.events.tell(Publish(events::VaultResealed)).await; };
Ok(())
} let n1 = Vault::get_new_nonce(&db, root_key_history_id)
} .await
.unwrap();
#[cfg(test)] let n2 = Vault::get_new_nonce(&db, root_key_history_id)
mod tests { .await
use crate::actors::GlobalActors; .unwrap();
use arbiter_crypto::safecell::SafeCellHandle as _; assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
use super::*; let mut conn = db.get().await.unwrap();
let root_row: RootKeyHistory = schema::root_key_history::table
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault { .select(RootKeyHistory::as_select())
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .first(&mut conn)
.await .await
.unwrap(); .unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor let id = actor
} .create_new(SafeCell::new(b"post-interleave".to_vec()))
.await
#[tokio::test] .unwrap();
#[test_log::test] let row: models::AeadEncrypted = schema::aead_encrypted::table
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { .filter(schema::aead_encrypted::id.eq(id))
let db = db::create_test_pool().await; .select(models::AeadEncrypted::as_select())
let mut actor = bootstrapped_actor(&db).await; .first(&mut conn)
.await
let State::Unsealed(Unsealed { .unwrap();
root_key_history_id, assert!(
.. row.current_nonce > n2.to_vec(),
}) = actor.state "next write must advance nonce"
else { );
panic!("expected unsealed state") }
}; }
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
.await
.unwrap();
let n2 = Vault::get_new_nonce(&db, root_key_history_id)
.await
.unwrap();
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap();
let root_row: RootKeyHistory = schema::root_key_history::table
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await
.unwrap();
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec()), b"test-aad".to_vec())
.await
.unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table
.filter(schema::aead_encrypted::id.eq(id))
.select(models::AeadEncrypted::as_select())
.first(&mut conn)
.await
.unwrap();
assert!(
row.current_nonce > n2.to_vec(),
"next write must advance nonce"
);
}
}

View File

@@ -1,57 +1,57 @@
use crate::{ use crate::{
actors::GlobalActors, actors::GlobalActors,
context::tls::TlsManager, context::tls::TlsManager,
db::{self}, db::{self},
}; };
use std::sync::Arc; use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
pub mod tls; pub mod tls;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum InitError { pub enum InitError {
#[error("Database setup failed: {0}")] #[error("Database setup failed: {0}")]
DatabaseSetup(#[from] db::DatabaseSetupError), DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Connection acquire failed: {0}")] #[error("Connection acquire failed: {0}")]
DatabasePool(#[from] db::PoolError), DatabasePool(#[from] db::PoolError),
#[error("Database query error: {0}")] #[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::Error), DatabaseQuery(#[from] diesel::result::Error),
#[error("TLS initialization failed: {0}")] #[error("TLS initialization failed: {0}")]
Tls(#[from] tls::InitError), Tls(#[from] tls::InitError),
#[error("Actor spawn failed: {0}")] #[error("Actor spawn failed: {0}")]
ActorSpawn(#[from] crate::actors::SpawnError), ActorSpawn(#[from] crate::actors::SpawnError),
#[error("I/O Error: {0}")] #[error("I/O Error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
pub struct __ServerContextInner { pub struct __ServerContextInner {
pub db: db::DatabasePool, pub db: db::DatabasePool,
pub tls: TlsManager, pub tls: TlsManager,
pub actors: GlobalActors, pub actors: GlobalActors,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct ServerContext(Arc<__ServerContextInner>); pub struct ServerContext(Arc<__ServerContextInner>);
impl std::ops::Deref for ServerContext { impl std::ops::Deref for ServerContext {
type Target = __ServerContextInner; type Target = __ServerContextInner;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.0
} }
} }
impl ServerContext { impl ServerContext {
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> { pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
Ok(Self(Arc::new(__ServerContextInner { Ok(Self(Arc::new(__ServerContextInner {
actors: GlobalActors::spawn(db.clone()).await?, actors: GlobalActors::spawn(db.clone()).await?,
tls: TlsManager::new(db.clone()).await?, tls: TlsManager::new(db.clone()).await?,
db, db,
}))) })))
} }
} }

View File

@@ -1,257 +1,257 @@
use crate::db::{ use crate::db::{
self, self,
models::{NewTlsHistory, TlsHistory}, models::{NewTlsHistory, TlsHistory},
schema::{ schema::{
arbiter_settings, arbiter_settings,
tls_history::{self}, tls_history::{self},
}, },
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use pem::Pem; use pem::Pem;
use rcgen::{ use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
}; };
use rustls::pki_types::pem::PemObject; use rustls::pki_types::pem::PemObject;
use std::{net::Ipv4Addr, string::FromUtf8Error}; use std::{net::Ipv4Addr, string::FromUtf8Error};
use thiserror::Error; use thiserror::Error;
use tonic::transport::CertificateDer; use tonic::transport::CertificateDer;
const ENCODE_CONFIG: pem::EncodeConfig = { const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = if cfg!(target_family = "windows") { let line_ending = if cfg!(target_family = "windows") {
pem::LineEnding::CRLF pem::LineEnding::CRLF
} else { } else {
pem::LineEnding::LF pem::LineEnding::LF
}; };
pem::EncodeConfig::new().set_line_ending(line_ending) pem::EncodeConfig::new().set_line_ending(line_ending)
}; };
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum InitError { pub enum InitError {
#[error("Key generation error during TLS initialization: {0}")] #[error("Key generation error during TLS initialization: {0}")]
KeyGeneration(#[from] rcgen::Error), KeyGeneration(#[from] rcgen::Error),
#[error("Key invalid format: {0}")] #[error("Key invalid format: {0}")]
KeyInvalidFormat(#[from] FromUtf8Error), KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key deserialization error: {0}")] #[error("Key deserialization error: {0}")]
KeyDeserializationError(rcgen::Error), KeyDeserializationError(rcgen::Error),
#[error("Database error during TLS initialization: {0}")] #[error("Database error during TLS initialization: {0}")]
DatabaseError(#[from] diesel::result::Error), DatabaseError(#[from] diesel::result::Error),
#[error("Pem deserialization error during TLS initialization: {0}")] #[error("Pem deserialization error during TLS initialization: {0}")]
PemDeserializationError(#[from] rustls::pki_types::pem::Error), PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[error("Database pool acquire error during TLS initialization: {0}")] #[error("Database pool acquire error during TLS initialization: {0}")]
DatabasePoolAcquire(#[from] db::PoolError), DatabasePoolAcquire(#[from] db::PoolError),
} }
pub type PemCert = String; pub type PemCert = String;
pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert { pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG) pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
} }
#[expect( #[expect(
unused, unused,
reason = "may be needed for future cert rotation implementation" reason = "may be needed for future cert rotation implementation"
)] )]
struct SerializedTls { struct SerializedTls {
cert_pem: PemCert, cert_pem: PemCert,
cert_key_pem: String, cert_key_pem: String,
} }
struct TlsCa { struct TlsCa {
issuer: Issuer<'static, KeyPair>, issuer: Issuer<'static, KeyPair>,
cert: CertificateDer<'static>, cert: CertificateDer<'static>,
} }
impl TlsCa { impl TlsCa {
fn generate() -> Result<Self, InitError> { fn generate() -> Result<Self, InitError> {
let keypair = KeyPair::generate()?; let keypair = KeyPair::generate()?;
let mut params = CertificateParams::new(["Arbiter Instance CA".into()])?; let mut params = CertificateParams::new(["Arbiter Instance CA".into()])?;
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![ params.key_usages = vec![
KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign, KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::DigitalSignature,
]; ];
let mut dn = DistinguishedName::new(); let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance CA"); dn.push(DnType::CommonName, "Arbiter Instance CA");
params.distinguished_name = dn; params.distinguished_name = dn;
let certified_issuer = CertifiedIssuer::self_signed(params, keypair)?; let certified_issuer = CertifiedIssuer::self_signed(params, keypair)?;
let cert_key_pem = certified_issuer.key().serialize_pem(); let cert_key_pem = certified_issuer.key().serialize_pem();
#[expect( #[expect(
clippy::unwrap_used, clippy::unwrap_used,
reason = "Broken cert couldn't bootstrap server anyway" reason = "Broken cert couldn't bootstrap server anyway"
)] )]
let issuer = Issuer::from_ca_cert_pem( let issuer = Issuer::from_ca_cert_pem(
&certified_issuer.pem(), &certified_issuer.pem(),
KeyPair::from_pem(cert_key_pem.as_ref()).unwrap(), KeyPair::from_pem(cert_key_pem.as_ref()).unwrap(),
) )
.unwrap(); .unwrap();
Ok(Self { Ok(Self {
issuer, issuer,
cert: certified_issuer.der().clone(), cert: certified_issuer.der().clone(),
}) })
} }
fn generate_leaf(&self) -> Result<TlsCert, InitError> { fn generate_leaf(&self) -> Result<TlsCert, InitError> {
let cert_key = KeyPair::generate()?; let cert_key = KeyPair::generate()?;
let mut params = CertificateParams::new(["Arbiter Instance Leaf".into()])?; let mut params = CertificateParams::new(["Arbiter Instance Leaf".into()])?;
params.is_ca = IsCa::NoCa; params.is_ca = IsCa::NoCa;
params.key_usages = vec![ params.key_usages = vec![
KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment, KeyUsagePurpose::KeyEncipherment,
]; ];
params params
.subject_alt_names .subject_alt_names
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into())); .push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
let mut dn = DistinguishedName::new(); let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf"); dn.push(DnType::CommonName, "Arbiter Instance Leaf");
params.distinguished_name = dn; params.distinguished_name = dn;
let new_cert = params.signed_by(&cert_key, &self.issuer)?; let new_cert = params.signed_by(&cert_key, &self.issuer)?;
Ok(TlsCert { Ok(TlsCert {
cert: new_cert, cert: new_cert,
cert_key, cert_key,
}) })
} }
#[expect( #[expect(
unused, unused,
clippy::unnecessary_wraps, clippy::unnecessary_wraps,
reason = "may be needed for future cert rotation implementation" reason = "may be needed for future cert rotation implementation"
)] )]
fn serialize(&self) -> Result<SerializedTls, InitError> { fn serialize(&self) -> Result<SerializedTls, InitError> {
let cert_key_pem = self.issuer.key().serialize_pem(); let cert_key_pem = self.issuer.key().serialize_pem();
Ok(SerializedTls { Ok(SerializedTls {
cert_pem: encode_cert_to_pem(&self.cert), cert_pem: encode_cert_to_pem(&self.cert),
cert_key_pem, cert_key_pem,
}) })
} }
#[expect( #[expect(
unused, unused,
reason = "may be needed for future cert rotation implementation" reason = "may be needed for future cert rotation implementation"
)] )]
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> { fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
let keypair = let keypair =
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?; KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
let issuer = Issuer::from_ca_cert_pem(cert_pem, keypair)?; let issuer = Issuer::from_ca_cert_pem(cert_pem, keypair)?;
Ok(Self { Ok(Self {
issuer, issuer,
cert: CertificateDer::from_pem_slice(cert_pem.as_bytes())?, cert: CertificateDer::from_pem_slice(cert_pem.as_bytes())?,
}) })
} }
} }
struct TlsCert { struct TlsCert {
cert: Certificate, cert: Certificate,
cert_key: KeyPair, cert_key: KeyPair,
} }
// TODO: Implement cert rotation // TODO: Implement cert rotation
pub struct TlsManager { pub struct TlsManager {
cert: CertificateDer<'static>, cert: CertificateDer<'static>,
keypair: KeyPair, keypair: KeyPair,
ca_cert: CertificateDer<'static>, ca_cert: CertificateDer<'static>,
_db: db::DatabasePool, _db: db::DatabasePool,
} }
impl TlsManager { impl TlsManager {
pub async fn generate_new(db: &db::DatabasePool) -> Result<Self, InitError> { pub async fn generate_new(db: &db::DatabasePool) -> Result<Self, InitError> {
let ca = TlsCa::generate()?; let ca = TlsCa::generate()?;
let new_cert = ca.generate_leaf()?; let new_cert = ca.generate_leaf()?;
{ {
let mut conn = db.get().await?; let mut conn = db.get().await?;
conn.transaction(async |conn| { conn.transaction(async |conn| {
let new_tls_history = NewTlsHistory { let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(), cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(), cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert), ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(), ca_key: ca.issuer.key().serialize_pem(),
}; };
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table) let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history) .values(&new_tls_history)
.returning(tls_history::id) .returning(tls_history::id)
.get_result(&mut *conn) .get_result(&mut *conn)
.await?; .await?;
diesel::update(arbiter_settings::table) diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history)) .set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
Result::<_, diesel::result::Error>::Ok(()) Result::<_, diesel::result::Error>::Ok(())
}) })
.await?; .await?;
} }
Ok(Self { Ok(Self {
cert: new_cert.cert.der().clone(), cert: new_cert.cert.der().clone(),
keypair: new_cert.cert_key, keypair: new_cert.cert_key,
ca_cert: ca.cert, ca_cert: ca.cert,
_db: db.clone(), _db: db.clone(),
}) })
} }
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> { pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
let cert_data: Option<TlsHistory> = { let cert_data: Option<TlsHistory> = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
arbiter_settings::table arbiter_settings::table
.left_join(tls_history::table) .left_join(tls_history::table)
.select(Option::<TlsHistory>::as_select()) .select(Option::<TlsHistory>::as_select())
.first(&mut conn) .first(&mut conn)
.await? .await?
}; };
match cert_data { match cert_data {
Some(data) => { Some(data) => {
let try_load = || -> Result<_, Box<dyn std::error::Error>> { let try_load = || -> Result<_, Box<dyn std::error::Error>> {
let keypair = KeyPair::from_pem(&data.cert_key)?; let keypair = KeyPair::from_pem(&data.cert_key)?;
let cert = CertificateDer::from_pem_slice(data.cert.as_bytes())?; let cert = CertificateDer::from_pem_slice(data.cert.as_bytes())?;
let ca_cert = CertificateDer::from_pem_slice(data.ca_cert.as_bytes())?; let ca_cert = CertificateDer::from_pem_slice(data.ca_cert.as_bytes())?;
Ok(Self { Ok(Self {
cert, cert,
keypair, keypair,
ca_cert, ca_cert,
_db: db.clone(), _db: db.clone(),
}) })
}; };
match try_load() { match try_load() {
Ok(manager) => Ok(manager), Ok(manager) => Ok(manager),
Err(e) => { Err(e) => {
eprintln!("Failed to load existing TLS certs: {e}. Generating new ones."); eprintln!("Failed to load existing TLS certs: {e}. Generating new ones.");
Self::generate_new(&db).await Self::generate_new(&db).await
} }
} }
} }
None => Self::generate_new(&db).await, None => Self::generate_new(&db).await,
} }
} }
pub const fn cert(&self) -> &CertificateDer<'static> { pub const fn cert(&self) -> &CertificateDer<'static> {
&self.cert &self.cert
} }
pub const fn ca_cert(&self) -> &CertificateDer<'static> { pub const fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert &self.ca_cert
} }
pub fn cert_pem(&self) -> PemCert { pub fn cert_pem(&self) -> PemCert {
encode_cert_to_pem(&self.cert) encode_cert_to_pem(&self.cert)
} }
pub fn key_pem(&self) -> String { pub fn key_pem(&self) -> String {
self.keypair.serialize_pem() self.keypair.serialize_pem()
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,379 +1,334 @@
use crate::{ use crate::{
actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity}, actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity},
db::{ db::{
self, self,
models::{IntegrityEnvelope, NewIntegrityEnvelope}, models::{IntegrityEnvelope, NewIntegrityEnvelope},
schema::integrity_envelope, schema::integrity_envelope,
}, },
}; };
use arbiter_crypto::hashing::Hashable; use arbiter_crypto::hashing::Hashable;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Hmac; use hmac::Hmac;
use kameo::{actor::ActorRef, error::SendError}; use kameo::{actor::ActorRef, error::SendError};
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] db::DatabaseError), Database(#[from] db::DatabaseError),
#[error("Vault error: {0}")] #[error("Vault error: {0}")]
Vault(#[from] vault::Error), Vault(#[from] vault::Error),
#[error("Vault mailbox error")] #[error("Vault mailbox error")]
VaultSend, VaultSend,
#[error("Integrity envelope is missing for entity {entity_kind}")] #[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str }, MissingEnvelope { entity_kind: &'static str },
#[error( #[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}" "Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)] )]
PayloadVersionMismatch { PayloadVersionMismatch {
entity_kind: &'static str, entity_kind: &'static str,
expected: i32, expected: i32,
found: i32, found: i32,
}, },
#[error("Integrity MAC mismatch for entity {entity_kind}")] #[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str }, MacMismatch { entity_kind: &'static str },
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus { pub enum AttestationStatus {
Attested, Attested,
Unavailable, Unavailable,
} }
pub const CURRENT_PAYLOAD_VERSION: i32 = 1; pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1"; pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>; pub type HmacSha256 = Hmac<Sha256>;
pub trait Integrable: Hashable { pub trait Integrable: Hashable {
const KIND: &'static str; const KIND: &'static str;
const VERSION: i32 = 1; const VERSION: i32 = 1;
} }
fn payload_hash(payload: &impl Hashable) -> [u8; 32] { fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
payload.hash(&mut hasher); payload.hash(&mut hasher);
hasher.finalize().into() hasher.finalize().into()
} }
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) { fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
#[expect( #[expect(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::as_conversions, clippy::as_conversions,
reason = "fixme! #85" reason = "fixme! #85"
)] )]
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes); out.extend_from_slice(bytes);
} }
fn build_mac_input( fn build_mac_input(
entity_kind: &str, entity_kind: &str,
entity_id: &[u8], entity_id: &[u8],
payload_version: i32, payload_version: i32,
payload_hash: &[u8; 32], payload_hash: &[u8; 32],
) -> Vec<u8> { ) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32); let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes()); push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id); push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes()); out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash); out.extend_from_slice(payload_hash);
out out
} }
pub trait IntoId { pub trait IntoId {
fn into_id(self) -> Vec<u8>; fn into_id(self) -> Vec<u8>;
} }
impl IntoId for i32 { impl IntoId for i32 {
fn into_id(self) -> Vec<u8> { fn into_id(self) -> Vec<u8> {
self.to_be_bytes().to_vec() self.to_be_bytes().to_vec()
} }
} }
impl IntoId for &'_ [u8] { impl IntoId for &'_ [u8] {
fn into_id(self) -> Vec<u8> { fn into_id(self) -> Vec<u8> {
self.to_vec() self.to_vec()
} }
} }
pub async fn sign_entity<E: Integrable>( pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>, vault: &ActorRef<Vault>,
entity: &E, entity: &E,
entity_id: impl IntoId, entity_id: impl IntoId,
) -> Result<(), Error> { ) -> Result<(), Error> {
let payload_hash = payload_hash(&entity); let payload_hash = payload_hash(&entity);
let entity_id = entity_id.into_id(); let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) = let (key_version, mac) =
vault vault
.ask(SignIntegrity { mac_input }) .ask(SignIntegrity { mac_input })
.await .await
.map_err(|err| match err { .map_err(|err| match err {
SendError::HandlerError(inner) => Error::Vault(inner), SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend, _ => Error::VaultSend,
})?; })?;
insert_into(integrity_envelope::table) insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope { .values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(), entity_kind: E::KIND.to_owned(),
entity_id, entity_id,
payload_version: E::VERSION, payload_version: E::VERSION,
key_version, key_version,
mac: mac.clone(), mac: mac.clone(),
}) })
.on_conflict(( .on_conflict((
integrity_envelope::entity_id, integrity_envelope::entity_id,
integrity_envelope::entity_kind, integrity_envelope::entity_kind,
)) ))
.do_update() .do_update()
.set(( .set((
integrity_envelope::payload_version.eq(E::VERSION), integrity_envelope::payload_version.eq(E::VERSION),
integrity_envelope::key_version.eq(key_version), integrity_envelope::key_version.eq(key_version),
integrity_envelope::mac.eq(mac), integrity_envelope::mac.eq(mac),
)) ))
.execute(conn) .execute(conn)
.await .await
.map_err(db::DatabaseError::from)?; .map_err(db::DatabaseError::from)?;
Ok(()) Ok(())
} }
pub async fn verify_entity<E: Integrable>( pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>, vault: &ActorRef<Vault>,
entity: &E, entity: &E,
entity_id: impl IntoId, entity_id: impl IntoId,
) -> Result<AttestationStatus, Error> { ) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into_id(); let entity_id = entity_id.into_id();
let envelope: IntegrityEnvelope = integrity_envelope::table let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND)) .filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&entity_id)) .filter(integrity_envelope::entity_id.eq(&entity_id))
.first(conn) .first(conn)
.await .await
.map_err(|err| match err { .map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope { diesel::result::Error::NotFound => Error::MissingEnvelope {
entity_kind: E::KIND, entity_kind: E::KIND,
}, },
other => Error::Database(db::DatabaseError::from(other)), other => Error::Database(db::DatabaseError::from(other)),
})?; })?;
if envelope.payload_version != E::VERSION { if envelope.payload_version != E::VERSION {
return Err(Error::PayloadVersionMismatch { return Err(Error::PayloadVersionMismatch {
entity_kind: E::KIND, entity_kind: E::KIND,
expected: E::VERSION, expected: E::VERSION,
found: envelope.payload_version, found: envelope.payload_version,
}); });
} }
let payload_hash = payload_hash(&entity); let payload_hash = payload_hash(&entity);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash); let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let result = vault let result = vault
.ask(VerifyIntegrity { .ask(VerifyIntegrity {
mac_input, mac_input,
expected_mac: envelope.mac, expected_mac: envelope.mac,
key_version: envelope.key_version, key_version: envelope.key_version,
}) })
.await; .await;
match result { match result {
Ok(true) => Ok(AttestationStatus::Attested), Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch { Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND, entity_kind: E::KIND,
}), }),
Err(SendError::HandlerError( Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
vault::Error::Sealed | vault::Error::KeyVersionMismatch { .. }, Err(_) => Err(Error::VaultSend),
)) => Ok(AttestationStatus::Unavailable), }
Err(_) => Err(Error::VaultSend), }
}
} pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> { Ok(matches!(state, vault::VaultState::Unsealed))
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?; }
Ok(matches!(state, vault::VaultState::Unsealed))
} #[cfg(test)]
mod tests {
#[cfg(test)] use diesel::{ExpressionMethods as _, QueryDsl};
mod tests { use diesel_async::RunQueryDsl;
use diesel::{ExpressionMethods as _, QueryDsl}; use kameo::{actor::ActorRef, prelude::Spawn};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn}; use crate::{
actors::{
use crate::{ GlobalActors,
actors::{ vault::{Bootstrap, Vault},
GlobalActors, },
vault::{Bootstrap, Vault}, db::{self, schema},
}, };
db::{self, schema}, use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, arbiter_macros::Hashable)]
use super::{Error, Integrable, sign_entity, verify_entity}; struct DummyEntity {
#[derive(Clone, arbiter_macros::Hashable)] payload_version: i32,
struct DummyEntity { payload: Vec<u8>,
payload_version: i32, }
payload: Vec<u8>, impl Integrable for DummyEntity {
} const KIND: &'static str = "dummy_entity";
impl Integrable for DummyEntity { }
const KIND: &'static str = "dummy_entity";
} async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> { Vault::new(db.clone(), GlobalActors::spawn_message_bus())
let actor = Vault::spawn( .await
Vault::new(db.clone(), GlobalActors::spawn_message_bus()) .unwrap(),
.await );
.unwrap(), actor
); .ask(Bootstrap {
actor seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
.ask(Bootstrap { })
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), .await
}) .unwrap();
.await actor
.unwrap(); }
actor
} #[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
#[tokio::test] const ENTITY_ID: &[u8] = b"entity-id-7";
async fn sign_writes_envelope_and_verify_passes() {
const ENTITY_ID: &[u8] = b"entity-id-7"; let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let db = db::create_test_pool().await; let mut conn = db.get().await.unwrap();
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let entity = DummyEntity {
payload_version: 1,
let entity = DummyEntity { payload: b"payload-v1".to_vec(),
payload_version: 1, };
payload: b"payload-v1".to_vec(),
}; sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
sign_entity(&mut conn, &vault, &entity, ENTITY_ID) .unwrap();
.await
.unwrap(); let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
let count: i64 = schema::integrity_envelope::table .filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) .count()
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID)) .get_result(&mut conn)
.count() .await
.get_result(&mut conn) .unwrap();
.await
.unwrap(); assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
assert_eq!(count, 1, "envelope row must be created exactly once"); .await
verify_entity(&mut conn, &vault, &entity, ENTITY_ID) .unwrap();
.await }
.unwrap();
} #[tokio::test]
async fn tampered_mac_fails_verification() {
#[tokio::test] const ENTITY_ID: &[u8] = b"entity-id-11";
async fn tampered_mac_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-11"; let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let db = db::create_test_pool().await; let mut conn = db.get().await.unwrap();
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let entity = DummyEntity {
payload_version: 1,
let entity = DummyEntity { payload: b"payload-v1".to_vec(),
payload_version: 1, };
payload: b"payload-v1".to_vec(),
}; sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
sign_entity(&mut conn, &vault, &entity, ENTITY_ID) .unwrap();
.await
.unwrap(); diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
diesel::update(schema::integrity_envelope::table) .filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) .set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID)) .execute(&mut conn)
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32])) .await
.execute(&mut conn) .unwrap();
.await
.unwrap(); let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID) .unwrap_err();
.await assert!(matches!(err, Error::MacMismatch { .. }));
.unwrap_err(); }
assert!(matches!(err, Error::MacMismatch { .. }));
} #[tokio::test]
async fn changed_payload_fails_verification() {
#[tokio::test] const ENTITY_ID: &[u8] = b"entity-id-21";
async fn changed_payload_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-21"; let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let db = db::create_test_pool().await; let mut conn = db.get().await.unwrap();
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let entity = DummyEntity {
payload_version: 1,
let entity = DummyEntity { payload: b"payload-v1".to_vec(),
payload_version: 1, };
payload: b"payload-v1".to_vec(),
}; sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
sign_entity(&mut conn, &vault, &entity, ENTITY_ID) .unwrap();
.await
.unwrap(); let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
let tampered = DummyEntity { ..entity
payload: b"payload-v1-but-tampered".to_vec(), };
..entity
}; let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID)
.await
let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID) .unwrap_err();
.await assert!(matches!(err, Error::MacMismatch { .. }));
.unwrap_err(); }
assert!(matches!(err, Error::MacMismatch { .. })); }
}
#[tokio::test]
async fn key_version_mismatch_returns_unavailable_not_mac_mismatch() {
use crate::db::schema::integrity_envelope;
use super::AttestationStatus;
const ENTITY_ID: &[u8] = b"entity-id-rotation-test";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
// Simulate key rotation: update the stored key_version to a stale value.
// After real rotation the vault's root_key_history_id would advance, but
// here we achieve the same mismatch by back-dating the envelope's key_version.
diesel::update(integrity_envelope::table)
.filter(integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(integrity_envelope::entity_id.eq(ENTITY_ID))
.set(integrity_envelope::key_version.eq(0))
.execute(&mut conn)
.await
.unwrap();
// Must NOT error — version mismatch is Unavailable, not tampered.
let status = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.expect("key version mismatch must not be treated as an error");
assert_eq!(
status,
AttestationStatus::Unavailable,
"stale key_version must yield Unavailable, not MacMismatch"
);
}
}

View File

@@ -1,155 +1,155 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use encryption::v1::{Nonce, Salt}; use encryption::v1::{Nonce, Salt};
use argon2::{Algorithm, Argon2}; use argon2::{Algorithm, Argon2};
use chacha20poly1305::{ use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce, AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload}, aead::{AeadMut, Error, Payload},
}; };
use rand::{ use rand::{
Rng as _, SeedableRng as _, Rng as _, SeedableRng as _,
rngs::{StdRng, SysRng}, rngs::{StdRng, SysRng},
}; };
pub mod encryption; pub mod encryption;
pub mod integrity; pub mod integrity;
pub struct KeyCell(pub SafeCell<Key>); pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell { impl From<SafeCell<Key>> for KeyCell {
fn from(value: SafeCell<Key>) -> Self { fn from(value: SafeCell<Key>) -> Self {
Self(value) Self(value)
} }
} }
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell { impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = (); type Error = ();
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> { fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
let value = value.read(); let value = value.read();
if value.len() != size_of::<Key>() { if value.len() != size_of::<Key>() {
return Err(()); return Err(());
} }
let cell = SafeCell::new_inline(|cell_write: &mut Key| { let cell = SafeCell::new_inline(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value); cell_write.copy_from_slice(&value);
}); });
Ok(Self(cell)) Ok(Self(cell))
} }
} }
impl KeyCell { impl KeyCell {
pub fn new_secure_random() -> Self { pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| { let key = SafeCell::new_inline(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng) let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic"); .expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer); rng.fill_bytes(key_buffer);
}); });
key.into() key.into()
} }
pub fn encrypt_in_place( pub fn encrypt_in_place(
&mut self, &mut self,
nonce: &Nonce, nonce: &Nonce,
associated_data: &[u8], associated_data: &[u8],
mut buffer: impl AsMut<Vec<u8>>, mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let key_reader = self.0.read(); let key_reader = self.0.read();
let cipher = XChaCha20Poly1305::new(&key_reader); let cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref()); let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut(); let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer) cipher.encrypt_in_place(nonce, associated_data, buffer)
} }
pub fn decrypt_in_place( pub fn decrypt_in_place(
&mut self, &mut self,
nonce: &Nonce, nonce: &Nonce,
associated_data: &[u8], associated_data: &[u8],
buffer: &mut SafeCell<Vec<u8>>, buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let key_reader = self.0.read(); let key_reader = self.0.read();
let cipher = XChaCha20Poly1305::new(&key_reader); let cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref()); let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write(); let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut(); let buffer: &mut Vec<u8> = buffer.as_mut();
cipher.decrypt_in_place(nonce, associated_data, buffer) cipher.decrypt_in_place(nonce, associated_data, buffer)
} }
pub fn encrypt( pub fn encrypt(
&mut self, &mut self,
nonce: &Nonce, nonce: &Nonce,
associated_data: &[u8], associated_data: &[u8],
plaintext: impl AsRef<[u8]>, plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> { ) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read(); let key_reader = self.0.read();
let mut cipher = XChaCha20Poly1305::new(&key_reader); let mut cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref()); let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt( let ciphertext = cipher.encrypt(
nonce, nonce,
Payload { Payload {
msg: plaintext.as_ref(), msg: plaintext.as_ref(),
aad: associated_data, aad: associated_data,
}, },
)?; )?;
Ok(ciphertext) Ok(ciphertext)
} }
} }
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell { pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = { let params = {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
argon2::Params::new(8, 1, 1, None).unwrap() argon2::Params::new(8, 1, 1, None).unwrap()
} }
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
{ {
argon2::Params::new(262_144, 3, 4, None).unwrap() argon2::Params::new(262_144, 3, 4, None).unwrap()
} }
}; };
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params); let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default()); let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| { password.read_inline(|password_source| {
let mut key_buffer = key.write(); let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut(); let key_buffer: &mut [u8] = key_buffer.as_mut();
hasher hasher
.hash_password_into(password_source, salt, key_buffer) .hash_password_into(password_source, salt, key_buffer)
.expect("Better fail completely than return a weak key"); .expect("Better fail completely than return a weak key");
}); });
key.into() key.into()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
derive_key, derive_key,
encryption::v1::{Nonce, generate_salt}, encryption::v1::{Nonce, generate_salt},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[test] #[test]
fn encrypt_decrypt() { fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password"; static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); let salt = generate_salt();
let mut key = derive_key(password, &salt); let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data"; let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec(); let mut buffer = b"secret data".to_vec();
key.encrypt_in_place(&nonce, associated_data, &mut buffer) key.encrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap(); .unwrap();
assert_ne!(buffer, b"secret data"); assert_ne!(buffer, b"secret data");
let mut buffer = SafeCell::new(buffer); let mut buffer = SafeCell::new(buffer);
key.decrypt_in_place(&nonce, associated_data, &mut buffer) key.decrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap(); .unwrap();
let buffer = buffer.read(); let buffer = buffer.read();
assert_eq!(*buffer, b"secret data"); assert_eq!(*buffer, b"secret data");
} }
} }

View File

@@ -1,156 +1,156 @@
use diesel::{Connection as _, SqliteConnection, connection::SimpleConnection as _}; use diesel::{Connection as _, SqliteConnection, connection::SimpleConnection as _};
use diesel_async::{ use diesel_async::{
AsyncConnection, SimpleAsyncConnection, AsyncConnection, SimpleAsyncConnection,
pooled_connection::{AsyncDieselConnectionManager, ManagerConfig}, pooled_connection::{AsyncDieselConnectionManager, ManagerConfig},
sync_connection_wrapper::SyncConnectionWrapper, sync_connection_wrapper::SyncConnectionWrapper,
}; };
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::info;
pub mod models; pub mod models;
pub mod schema; pub mod schema;
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>; pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
pub type DatabasePool = diesel_async::pooled_connection::bb8::Pool<DatabaseConnection>; pub type DatabasePool = diesel_async::pooled_connection::bb8::Pool<DatabaseConnection>;
pub type PoolInitError = diesel_async::pooled_connection::PoolError; pub type PoolInitError = diesel_async::pooled_connection::PoolError;
pub type PoolError = diesel_async::pooled_connection::bb8::RunError; pub type PoolError = diesel_async::pooled_connection::bb8::RunError;
static DB_FILE: &str = "arbiter.sqlite"; static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DatabaseSetupError { pub enum DatabaseSetupError {
#[error(transparent)] #[error(transparent)]
ConcurrencySetup(diesel::result::Error), ConcurrencySetup(diesel::result::Error),
#[error(transparent)] #[error(transparent)]
Connection(diesel::ConnectionError), Connection(diesel::ConnectionError),
#[error("Failed to determine home directory")] #[error("Failed to determine home directory")]
HomeDir(std::io::Error), HomeDir(std::io::Error),
#[error(transparent)] #[error(transparent)]
Migration(Box<dyn std::error::Error + Send + Sync>), Migration(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)] #[error(transparent)]
Pool(#[from] PoolInitError), Pool(#[from] PoolInitError),
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DatabaseError { pub enum DatabaseError {
#[error("Database query error")] #[error("Database query error")]
Connection(#[from] diesel::result::Error), Connection(#[from] diesel::result::Error),
#[error("Database connection error")] #[error("Database connection error")]
Pool(#[from] PoolError), Pool(#[from] PoolError),
} }
#[tracing::instrument(level = "info")] #[tracing::instrument(level = "info")]
fn database_path() -> Result<std::path::PathBuf, DatabaseSetupError> { fn database_path() -> Result<std::path::PathBuf, DatabaseSetupError> {
let arbiter_home = arbiter_proto::home_path().map_err(DatabaseSetupError::HomeDir)?; let arbiter_home = arbiter_proto::home_path().map_err(DatabaseSetupError::HomeDir)?;
let db_path = arbiter_home.join(DB_FILE); let db_path = arbiter_home.join(DB_FILE);
Ok(db_path) Ok(db_path)
} }
#[tracing::instrument(level = "info", skip(conn))] #[tracing::instrument(level = "info", skip(conn))]
fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> { fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> {
// fsync only in critical moments // fsync only in critical moments
conn.batch_execute("PRAGMA synchronous = NORMAL;")?; conn.batch_execute("PRAGMA synchronous = NORMAL;")?;
// write WAL changes back every 1000 pages, for an in average 1MB WAL file. // write WAL changes back every 1000 pages, for an in average 1MB WAL file.
// May affect readers if number is increased // May affect readers if number is increased
conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?; conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?;
// free some space by truncating possibly massive WAL files from the last run // free some space by truncating possibly massive WAL files from the last run
conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?; conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?;
// sqlite foreign keys are disabled by default, enable them for safety // sqlite foreign keys are disabled by default, enable them for safety
conn.batch_execute("PRAGMA foreign_keys = ON;")?; conn.batch_execute("PRAGMA foreign_keys = ON;")?;
// better space reclamation // better space reclamation
conn.batch_execute("PRAGMA auto_vacuum = FULL;")?; conn.batch_execute("PRAGMA auto_vacuum = FULL;")?;
// secure delete, overwrite deleted content with zeros to prevent recovery // secure delete, overwrite deleted content with zeros to prevent recovery
conn.batch_execute("PRAGMA secure_delete = ON;")?; conn.batch_execute("PRAGMA secure_delete = ON;")?;
Ok(()) Ok(())
} }
#[tracing::instrument(level = "info", skip(url))] #[tracing::instrument(level = "info", skip(url))]
fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> { fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
let mut conn = SqliteConnection::establish(url).map_err(DatabaseSetupError::Connection)?; let mut conn = SqliteConnection::establish(url).map_err(DatabaseSetupError::Connection)?;
db_config(&mut conn).map_err(DatabaseSetupError::ConcurrencySetup)?; db_config(&mut conn).map_err(DatabaseSetupError::ConcurrencySetup)?;
conn.run_pending_migrations(MIGRATIONS) conn.run_pending_migrations(MIGRATIONS)
.map_err(DatabaseSetupError::Migration)?; .map_err(DatabaseSetupError::Migration)?;
info!(%url, "Database initialized successfully"); info!(%url, "Database initialized successfully");
Ok(()) Ok(())
} }
#[tracing::instrument(level = "info")] #[tracing::instrument(level = "info")]
/// Creates a connection pool for the `SQLite` database. /// Creates a connection pool for the `SQLite` database.
/// ///
/// # Panics /// # Panics
/// Panics if the database path is not valid UTF-8. /// Panics if the database path is not valid UTF-8.
pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> { pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> {
let database_url = url.map(String::from).unwrap_or( let database_url = url.map(String::from).unwrap_or(
database_path()? database_path()?
.to_str() .to_str()
.expect("database path is not valid UTF-8") .expect("database path is not valid UTF-8")
.to_owned(), .to_owned(),
); );
initialize_database(&database_url)?; initialize_database(&database_url)?;
let mut config = ManagerConfig::default(); let mut config = ManagerConfig::default();
config.custom_setup = Box::new(|url| { config.custom_setup = Box::new(|url| {
Box::pin(async move { Box::pin(async move {
let mut conn = DatabaseConnection::establish(url).await?; let mut conn = DatabaseConnection::establish(url).await?;
// see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/ // see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/
// sleep if the database is busy, this corresponds to up to 9 seconds sleeping time. // sleep if the database is busy, this corresponds to up to 9 seconds sleeping time.
conn.batch_execute("PRAGMA busy_timeout = 9000;") conn.batch_execute("PRAGMA busy_timeout = 9000;")
.await .await
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?; .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
// better write-concurrency // better write-concurrency
conn.batch_execute("PRAGMA journal_mode = WAL;") conn.batch_execute("PRAGMA journal_mode = WAL;")
.await .await
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?; .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
Ok(conn) Ok(conn)
}) })
}); });
let pool = DatabasePool::builder() let pool = DatabasePool::builder()
.build(AsyncDieselConnectionManager::new_with_config( .build(AsyncDieselConnectionManager::new_with_config(
database_url, database_url,
config, config,
)) ))
.await?; .await?;
Ok(pool) Ok(pool)
} }
#[mutants::skip] #[mutants::skip]
#[expect(clippy::missing_panics_doc, reason = "Tests oriented function")] #[expect(clippy::missing_panics_doc, reason = "Tests oriented function")]
/// Creates a test database pool with a temporary `SQLite` database file. /// Creates a test database pool with a temporary `SQLite` database file.
pub async fn create_test_pool() -> DatabasePool { pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _}; use rand::distr::{Alphanumeric, SampleString as _};
let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16); let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let file = std::env::temp_dir().join(tempfile_name); let file = std::env::temp_dir().join(tempfile_name);
let url = file let url = file
.to_str() .to_str()
.expect("temp file path is not valid UTF-8") .expect("temp file path is not valid UTF-8")
.to_owned(); .to_owned();
create_pool(Some(&url)) create_pool(Some(&url))
.await .await
.expect("Failed to create test database pool") .expect("Failed to create test database pool")
} }

View File

@@ -1,406 +1,406 @@
#![allow( #![allow(
clippy::duplicated_attributes, clippy::duplicated_attributes,
reason = "restructed's #[view] causes false positives" reason = "restructed's #[view] causes false positives"
)] )]
use crate::db::schema::{ use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
integrity_envelope, root_key_history, tls_history, integrity_envelope, root_key_history, tls_history,
}; };
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models; use restructed::Models;
pub mod types { pub mod types {
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::{ use diesel::{
deserialize::{FromSql, FromSqlRow}, deserialize::{FromSql, FromSqlRow},
expression::AsExpression, expression::AsExpression,
serialize::{IsNull, ToSql}, serialize::{IsNull, ToSql},
sql_types::Integer, sql_types::Integer,
sqlite::{Sqlite, SqliteType}, sqlite::{Sqlite, SqliteType},
}; };
#[derive(Debug, FromSqlRow, AsExpression, Clone)] #[derive(Debug, FromSqlRow, AsExpression, Clone)]
#[diesel(sql_type = Integer)] #[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away #[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct SqliteTimestamp(pub DateTime<Utc>); pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp { impl SqliteTimestamp {
pub fn now() -> Self { pub fn now() -> Self {
Self(Utc::now()) Self(Utc::now())
} }
} }
impl From<DateTime<Utc>> for SqliteTimestamp { impl From<DateTime<Utc>> for SqliteTimestamp {
fn from(dt: DateTime<Utc>) -> Self { fn from(dt: DateTime<Utc>) -> Self {
Self(dt) Self(dt)
} }
} }
impl From<SqliteTimestamp> for DateTime<Utc> { impl From<SqliteTimestamp> for DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self { fn from(ts: SqliteTimestamp) -> Self {
ts.0 ts.0
} }
} }
impl ToSql<Integer, Sqlite> for SqliteTimestamp { impl ToSql<Integer, Sqlite> for SqliteTimestamp {
fn to_sql<'b>( fn to_sql<'b>(
&'b self, &'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>, out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result { ) -> diesel::serialize::Result {
#[expect( #[expect(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::as_conversions, clippy::as_conversions,
reason = "fixme! #84; this will break up in 2038 :3" reason = "fixme! #84; this will break up in 2038 :3"
)] )]
let unix_timestamp = self.0.timestamp() as i32; let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp); out.set_value(unix_timestamp);
Ok(IsNull::No) Ok(IsNull::No)
} }
} }
impl FromSql<Integer, Sqlite> for SqliteTimestamp { impl FromSql<Integer, Sqlite> for SqliteTimestamp {
fn from_sql( fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>, mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> { ) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else { let Some(SqliteType::Long) = bytes.value_type() else {
return Err(format!( return Err(format!(
"Expected Integer type for SqliteTimestamp, got {:?}", "Expected Integer type for SqliteTimestamp, got {:?}",
bytes.value_type() bytes.value_type()
) )
.into()); .into());
}; };
let unix_timestamp = bytes.read_long(); let unix_timestamp = bytes.read_long();
let datetime = let datetime =
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?; DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
Ok(Self(datetime)) Ok(Self(datetime))
} }
} }
#[derive(Debug, FromSqlRow, AsExpression, Clone)] #[derive(Debug, FromSqlRow, AsExpression, Clone)]
#[diesel(sql_type = Integer)] #[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away #[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct ChainId(pub i32); pub struct ChainId(pub i32);
#[expect( #[expect(
clippy::cast_sign_loss, clippy::cast_sign_loss,
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::as_conversions, clippy::as_conversions,
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants" reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
)] )]
const _: () = { const _: () = {
impl From<ChainId> for alloy::primitives::ChainId { impl From<ChainId> for alloy::primitives::ChainId {
fn from(chain_id: ChainId) -> Self { fn from(chain_id: ChainId) -> Self {
chain_id.0 as Self chain_id.0 as Self
} }
} }
impl From<alloy::primitives::ChainId> for ChainId { impl From<alloy::primitives::ChainId> for ChainId {
fn from(chain_id: alloy::primitives::ChainId) -> Self { fn from(chain_id: alloy::primitives::ChainId) -> Self {
Self(chain_id as _) Self(chain_id as _)
} }
} }
}; };
impl FromSql<Integer, Sqlite> for ChainId { impl FromSql<Integer, Sqlite> for ChainId {
fn from_sql( fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>, bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> { ) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self) FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
} }
} }
impl ToSql<Integer, Sqlite> for ChainId { impl ToSql<Integer, Sqlite> for ChainId {
fn to_sql<'b>( fn to_sql<'b>(
&'b self, &'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>, out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result { ) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out) ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
} }
} }
} }
pub use types::*; pub use types::*;
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[view( #[view(
NewAeadEncrypted, NewAeadEncrypted,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))] #[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted { pub struct AeadEncrypted {
pub id: i32, pub id: i32,
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub tag: Vec<u8>, pub tag: Vec<u8>,
pub current_nonce: Vec<u8>, pub current_nonce: Vec<u8>,
pub schema_version: i32, pub schema_version: i32,
pub associated_root_key_id: i32, // references root_key_history.id pub associated_root_key_id: i32, // references root_key_history.id
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = root_key_history, check_for_backend(Sqlite))] #[diesel(table_name = root_key_history, check_for_backend(Sqlite))]
#[view( #[view(
NewRootKeyHistory, NewRootKeyHistory,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct RootKeyHistory { pub struct RootKeyHistory {
pub id: i32, pub id: i32,
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub tag: Vec<u8>, pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>, pub root_key_encryption_nonce: Vec<u8>,
pub data_encryption_nonce: Vec<u8>, pub data_encryption_nonce: Vec<u8>,
pub schema_version: i32, pub schema_version: i32,
pub salt: Vec<u8>, pub salt: Vec<u8>,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = tls_history, check_for_backend(Sqlite))] #[diesel(table_name = tls_history, check_for_backend(Sqlite))]
#[view( #[view(
NewTlsHistory, NewTlsHistory,
derive(Insertable), derive(Insertable),
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct TlsHistory { pub struct TlsHistory {
pub id: i32, pub id: i32,
pub cert: String, pub cert: String,
pub cert_key: String, // PEM Encoded private key pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing pub ca_cert: String, // PEM Encoded certificate for cert signing
pub ca_key: String, // PEM Encoded public key for cert signing pub ca_key: String, // PEM Encoded public key for cert signing
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Queryable, Debug, Insertable, Selectable)] #[derive(Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = arbiter_settings, check_for_backend(Sqlite))] #[diesel(table_name = arbiter_settings, check_for_backend(Sqlite))]
pub struct ArbiterSettings { pub struct ArbiterSettings {
pub id: i32, pub id: i32,
pub root_key_id: Option<i32>, // references root_key_history.id pub root_key_id: Option<i32>, // references root_key_history.id
pub tls_id: Option<i32>, // references tls_history.id pub tls_id: Option<i32>, // references tls_history.id
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_wallet, check_for_backend(Sqlite))] #[diesel(table_name = evm_wallet, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmWallet, NewEvmWallet,
derive(Insertable), derive(Insertable),
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmWallet { pub struct EvmWallet {
pub id: i32, pub id: i32,
pub address: Vec<u8>, pub address: Vec<u8>,
pub aead_encrypted_id: i32, pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)] #[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)]
#[diesel(table_name = schema::evm_wallet_access, check_for_backend(Sqlite))] #[diesel(table_name = schema::evm_wallet_access, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmWalletAccess, NewEvmWalletAccess,
derive(Insertable), derive(Insertable),
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
#[view( #[view(
CoreEvmWalletAccess, CoreEvmWalletAccess,
derive(Insertable), derive(Insertable),
omit(created_at), omit(created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmWalletAccess { pub struct EvmWalletAccess {
pub id: i32, pub id: i32,
pub wallet_id: i32, pub wallet_id: i32,
pub client_id: i32, pub client_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::client_metadata, check_for_backend(Sqlite))] #[diesel(table_name = schema::client_metadata, check_for_backend(Sqlite))]
pub struct ProgramClientMetadata { pub struct ProgramClientMetadata {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub version: Option<String>, pub version: Option<String>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::client_metadata_history, check_for_backend(Sqlite))] #[diesel(table_name = schema::client_metadata_history, check_for_backend(Sqlite))]
pub struct ProgramClientMetadataHistory { pub struct ProgramClientMetadataHistory {
pub id: i32, pub id: i32,
pub metadata_id: i32, pub metadata_id: i32,
pub client_id: i32, pub client_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient { pub struct ProgramClient {
pub id: i32, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub metadata_id: i32, pub metadata_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
} }
#[derive(Queryable, Debug)] #[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient { pub struct OperatorClient {
pub id: i32, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))] #[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmEtherTransferLimit, NewEvmEtherTransferLimit,
derive(Insertable), derive(Insertable),
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmEtherTransferLimit { pub struct EvmEtherTransferLimit {
pub id: i32, pub id: i32,
pub window_secs: i32, pub window_secs: i32,
pub max_volume: Vec<u8>, pub max_volume: Vec<u8>,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_basic_grant, check_for_backend(Sqlite))] #[diesel(table_name = evm_basic_grant, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmBasicGrant, NewEvmBasicGrant,
derive(Insertable), derive(Insertable),
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmBasicGrant { pub struct EvmBasicGrant {
pub id: i32, pub id: i32,
pub wallet_access_id: i32, // references evm_wallet_access.id pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: ChainId, pub chain_id: ChainId,
pub valid_from: Option<SqliteTimestamp>, pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>, pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>, pub max_gas_fee_per_gas: Option<Vec<u8>>,
pub max_priority_fee_per_gas: Option<Vec<u8>>, pub max_priority_fee_per_gas: Option<Vec<u8>>,
pub rate_limit_count: Option<i32>, pub rate_limit_count: Option<i32>,
pub rate_limit_window_secs: Option<i32>, pub rate_limit_window_secs: Option<i32>,
pub revoked_at: Option<SqliteTimestamp>, pub revoked_at: Option<SqliteTimestamp>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_transaction_log, check_for_backend(Sqlite))] #[diesel(table_name = evm_transaction_log, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmTransactionLog, NewEvmTransactionLog,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmTransactionLog { pub struct EvmTransactionLog {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain_id: ChainId, pub chain_id: ChainId,
pub eth_value: Vec<u8>, pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_grant, check_for_backend(Sqlite))] #[diesel(table_name = evm_ether_transfer_grant, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmEtherTransferGrant, NewEvmEtherTransferGrant,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmEtherTransferGrant { pub struct EvmEtherTransferGrant {
pub id: i32, pub id: i32,
pub basic_grant_id: i32, pub basic_grant_id: i32,
pub limit_id: i32, // references evm_ether_transfer_limit.id pub limit_id: i32, // references evm_ether_transfer_limit.id
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_grant_target, check_for_backend(Sqlite))] #[diesel(table_name = evm_ether_transfer_grant_target, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmEtherTransferGrantTarget, NewEvmEtherTransferGrantTarget,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmEtherTransferGrantTarget { pub struct EvmEtherTransferGrantTarget {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub address: Vec<u8>, pub address: Vec<u8>,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_grant, check_for_backend(Sqlite))] #[diesel(table_name = evm_token_transfer_grant, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmTokenTransferGrant, NewEvmTokenTransferGrant,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmTokenTransferGrant { pub struct EvmTokenTransferGrant {
pub id: i32, pub id: i32,
pub basic_grant_id: i32, pub basic_grant_id: i32,
pub token_contract: Vec<u8>, pub token_contract: Vec<u8>,
pub receiver: Option<Vec<u8>>, pub receiver: Option<Vec<u8>>,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_volume_limit, check_for_backend(Sqlite))] #[diesel(table_name = evm_token_transfer_volume_limit, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmTokenTransferVolumeLimit, NewEvmTokenTransferVolumeLimit,
derive(Insertable), derive(Insertable),
omit(id), omit(id),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmTokenTransferVolumeLimit { pub struct EvmTokenTransferVolumeLimit {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub window_secs: i32, pub window_secs: i32,
pub max_volume: Vec<u8>, pub max_volume: Vec<u8>,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_token_transfer_log, check_for_backend(Sqlite))] #[diesel(table_name = evm_token_transfer_log, check_for_backend(Sqlite))]
#[view( #[view(
NewEvmTokenTransferLog, NewEvmTokenTransferLog,
derive(Insertable), derive(Insertable),
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmTokenTransferLog { pub struct EvmTokenTransferLog {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub log_id: i32, pub log_id: i32,
pub chain_id: ChainId, pub chain_id: ChainId,
pub token_contract: Vec<u8>, pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>, pub recipient_address: Vec<u8>,
pub value: Vec<u8>, pub value: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))] #[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
#[view( #[view(
NewIntegrityEnvelope, NewIntegrityEnvelope,
derive(Insertable), derive(Insertable),
omit(id, signed_at, created_at), omit(id, signed_at, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct IntegrityEnvelope { pub struct IntegrityEnvelope {
pub id: i32, pub id: i32,
pub entity_kind: String, pub entity_kind: String,
pub entity_id: Vec<u8>, pub entity_id: Vec<u8>,
pub payload_version: i32, pub payload_version: i32,
pub key_version: i32, pub key_version: i32,
pub mac: Vec<u8>, pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }

View File

@@ -1,237 +1,237 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
diesel::table! { diesel::table! {
aead_encrypted (id) { aead_encrypted (id) {
id -> Integer, id -> Integer,
current_nonce -> Binary, current_nonce -> Binary,
ciphertext -> Binary, ciphertext -> Binary,
tag -> Binary, tag -> Binary,
schema_version -> Integer, schema_version -> Integer,
associated_root_key_id -> Integer, associated_root_key_id -> Integer,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
arbiter_settings (id) { arbiter_settings (id) {
id -> Integer, id -> Integer,
root_key_id -> Nullable<Integer>, root_key_id -> Nullable<Integer>,
tls_id -> Nullable<Integer>, tls_id -> Nullable<Integer>,
} }
} }
diesel::table! { diesel::table! {
client_metadata (id) { client_metadata (id) {
id -> Integer, id -> Integer,
name -> Text, name -> Text,
description -> Nullable<Text>, description -> Nullable<Text>,
version -> Nullable<Text>, version -> Nullable<Text>,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
client_metadata_history (id) { client_metadata_history (id) {
id -> Integer, id -> Integer,
metadata_id -> Integer, metadata_id -> Integer,
client_id -> Integer, client_id -> Integer,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
evm_basic_grant (id) { evm_basic_grant (id) {
id -> Integer, id -> Integer,
wallet_access_id -> Integer, wallet_access_id -> Integer,
chain_id -> Integer, chain_id -> Integer,
valid_from -> Nullable<Integer>, valid_from -> Nullable<Integer>,
valid_until -> Nullable<Integer>, valid_until -> Nullable<Integer>,
max_gas_fee_per_gas -> Nullable<Binary>, max_gas_fee_per_gas -> Nullable<Binary>,
max_priority_fee_per_gas -> Nullable<Binary>, max_priority_fee_per_gas -> Nullable<Binary>,
rate_limit_count -> Nullable<Integer>, rate_limit_count -> Nullable<Integer>,
rate_limit_window_secs -> Nullable<Integer>, rate_limit_window_secs -> Nullable<Integer>,
revoked_at -> Nullable<Integer>, revoked_at -> Nullable<Integer>,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
evm_ether_transfer_grant (id) { evm_ether_transfer_grant (id) {
id -> Integer, id -> Integer,
basic_grant_id -> Integer, basic_grant_id -> Integer,
limit_id -> Integer, limit_id -> Integer,
} }
} }
diesel::table! { diesel::table! {
evm_ether_transfer_grant_target (id) { evm_ether_transfer_grant_target (id) {
id -> Integer, id -> Integer,
grant_id -> Integer, grant_id -> Integer,
address -> Binary, address -> Binary,
} }
} }
diesel::table! { diesel::table! {
evm_ether_transfer_limit (id) { evm_ether_transfer_limit (id) {
id -> Integer, id -> Integer,
window_secs -> Integer, window_secs -> Integer,
max_volume -> Binary, max_volume -> Binary,
} }
} }
diesel::table! { diesel::table! {
evm_token_transfer_grant (id) { evm_token_transfer_grant (id) {
id -> Integer, id -> Integer,
basic_grant_id -> Integer, basic_grant_id -> Integer,
token_contract -> Binary, token_contract -> Binary,
receiver -> Nullable<Binary>, receiver -> Nullable<Binary>,
} }
} }
diesel::table! { diesel::table! {
evm_token_transfer_log (id) { evm_token_transfer_log (id) {
id -> Integer, id -> Integer,
grant_id -> Integer, grant_id -> Integer,
log_id -> Integer, log_id -> Integer,
chain_id -> Integer, chain_id -> Integer,
token_contract -> Binary, token_contract -> Binary,
recipient_address -> Binary, recipient_address -> Binary,
value -> Binary, value -> Binary,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
evm_token_transfer_volume_limit (id) { evm_token_transfer_volume_limit (id) {
id -> Integer, id -> Integer,
grant_id -> Integer, grant_id -> Integer,
window_secs -> Integer, window_secs -> Integer,
max_volume -> Binary, max_volume -> Binary,
} }
} }
diesel::table! { diesel::table! {
evm_transaction_log (id) { evm_transaction_log (id) {
id -> Integer, id -> Integer,
wallet_access_id -> Integer, wallet_access_id -> Integer,
grant_id -> Integer, grant_id -> Integer,
chain_id -> Integer, chain_id -> Integer,
eth_value -> Binary, eth_value -> Binary,
signed_at -> Integer, signed_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
evm_wallet (id) { evm_wallet (id) {
id -> Integer, id -> Integer,
address -> Binary, address -> Binary,
aead_encrypted_id -> Integer, aead_encrypted_id -> Integer,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
evm_wallet_access (id) { evm_wallet_access (id) {
id -> Integer, id -> Integer,
wallet_id -> Integer, wallet_id -> Integer,
client_id -> Integer, client_id -> Integer,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
integrity_envelope (id) { integrity_envelope (id) {
id -> Integer, id -> Integer,
entity_kind -> Text, entity_kind -> Text,
entity_id -> Binary, entity_id -> Binary,
payload_version -> Integer, payload_version -> Integer,
key_version -> Integer, key_version -> Integer,
mac -> Binary, mac -> Binary,
signed_at -> Integer, signed_at -> Integer,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
public_key -> Binary, public_key -> Binary,
metadata_id -> Integer, metadata_id -> Integer,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
root_key_history (id) { root_key_history (id) {
id -> Integer, id -> Integer,
root_key_encryption_nonce -> Binary, root_key_encryption_nonce -> Binary,
data_encryption_nonce -> Binary, data_encryption_nonce -> Binary,
ciphertext -> Binary, ciphertext -> Binary,
tag -> Binary, tag -> Binary,
schema_version -> Integer, schema_version -> Integer,
salt -> Binary, salt -> Binary,
} }
} }
diesel::table! { diesel::table! {
tls_history (id) { tls_history (id) {
id -> Integer, id -> Integer,
cert -> Text, cert -> Text,
cert_key -> Text, cert_key -> Text,
ca_cert -> Text, ca_cert -> Text,
ca_key -> Text, ca_key -> Text,
created_at -> Integer, created_at -> Integer,
} }
} }
diesel::table! { diesel::table! {
operator_client (id) { operator_client (id) {
id -> Integer, id -> Integer,
public_key -> Binary, public_key -> Binary,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,
} }
} }
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id)); 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 -> root_key_history (root_key_id));
diesel::joinable!(arbiter_settings -> tls_history (tls_id)); diesel::joinable!(arbiter_settings -> tls_history (tls_id));
diesel::joinable!(client_metadata_history -> client_metadata (metadata_id)); diesel::joinable!(client_metadata_history -> client_metadata (metadata_id));
diesel::joinable!(client_metadata_history -> program_client (client_id)); diesel::joinable!(client_metadata_history -> program_client (client_id));
diesel::joinable!(evm_basic_grant -> evm_wallet_access (wallet_access_id)); diesel::joinable!(evm_basic_grant -> evm_wallet_access (wallet_access_id));
diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id)); diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id));
diesel::joinable!(evm_ether_transfer_grant -> evm_ether_transfer_limit (limit_id)); diesel::joinable!(evm_ether_transfer_grant -> evm_ether_transfer_limit (limit_id));
diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id)); diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id));
diesel::joinable!(evm_token_transfer_grant -> evm_basic_grant (basic_grant_id)); diesel::joinable!(evm_token_transfer_grant -> evm_basic_grant (basic_grant_id));
diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id)); diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id));
diesel::joinable!(evm_token_transfer_log -> evm_transaction_log (log_id)); diesel::joinable!(evm_token_transfer_log -> evm_transaction_log (log_id));
diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id)); diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id));
diesel::joinable!(evm_transaction_log -> evm_basic_grant (grant_id)); diesel::joinable!(evm_transaction_log -> evm_basic_grant (grant_id));
diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id)); diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id)); diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id)); diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(evm_wallet_access -> program_client (client_id));
diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted, aead_encrypted,
arbiter_settings, arbiter_settings,
client_metadata, client_metadata,
client_metadata_history, client_metadata_history,
evm_basic_grant, evm_basic_grant,
evm_ether_transfer_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_grant_target,
evm_ether_transfer_limit, evm_ether_transfer_limit,
evm_token_transfer_grant, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_log,
evm_token_transfer_volume_limit, evm_token_transfer_volume_limit,
evm_transaction_log, evm_transaction_log,
evm_wallet, evm_wallet,
evm_wallet_access, evm_wallet_access,
integrity_envelope, integrity_envelope,
program_client, program_client,
root_key_history, root_key_history,
tls_history, tls_history,
operator_client, operator_client,
); );

View File

@@ -1,84 +1,84 @@
use alloy::sol; use alloy::sol;
sol! { sol! {
interface IERC20 { interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value); event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256); function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256); function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool); function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool); function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool);
} }
} }
sol! { sol! {
/// ERC-721: Non-Fungible Token Standard. /// ERC-721: Non-Fungible Token Standard.
#[derive(Debug)] #[derive(Debug)]
interface IERC721 { interface IERC721 {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved); event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
function balanceOf(address owner) external view returns (uint256 balance); function balanceOf(address owner) external view returns (uint256 balance);
function ownerOf(uint256 tokenId) external view returns (address owner); function ownerOf(uint256 tokenId) external view returns (address owner);
function safeTransferFrom(address from, address to, uint256 tokenId) external; function safeTransferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function transferFrom(address from, address to, uint256 tokenId) external; function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external; function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool approved) external; function setApprovalForAll(address operator, bool approved) external;
function getApproved(uint256 tokenId) external view returns (address operator); function getApproved(uint256 tokenId) external view returns (address operator);
function isApprovedForAll(address owner, address operator) external view returns (bool); function isApprovedForAll(address owner, address operator) external view returns (bool);
} }
} }
sol! { sol! {
/// Wrapped Ether — the only functions beyond ERC-20 that matter. /// Wrapped Ether — the only functions beyond ERC-20 that matter.
#[derive(Debug)] #[derive(Debug)]
interface IWETH { interface IWETH {
function deposit() external payable; function deposit() external payable;
function withdraw(uint256 wad) external; function withdraw(uint256 wad) external;
} }
} }
sol! { sol! {
/// Permit2 — Uniswap's canonical token approval manager. /// Permit2 — Uniswap's canonical token approval manager.
/// Replaces per-contract ERC-20 `approve()` with a single approval hub. /// Replaces per-contract ERC-20 `approve()` with a single approval hub.
#[derive(Debug)] #[derive(Debug)]
interface IPermit2 { interface IPermit2 {
struct TokenPermissions { struct TokenPermissions {
address token; address token;
uint256 amount; uint256 amount;
} }
struct PermitSingle { struct PermitSingle {
TokenPermissions details; TokenPermissions details;
address spender; address spender;
uint256 sigDeadline; uint256 sigDeadline;
} }
struct PermitBatch { struct PermitBatch {
TokenPermissions[] details; TokenPermissions[] details;
address spender; address spender;
uint256 sigDeadline; uint256 sigDeadline;
} }
struct AllowanceTransferDetails { struct AllowanceTransferDetails {
address from; address from;
address to; address to;
uint160 amount; uint160 amount;
address token; address token;
} }
function approve(address token, address spender, uint160 amount, uint48 expiration) external; function approve(address token, address spender, uint160 amount, uint48 expiration) external;
function permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) external; function permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) external;
function permit(address owner, PermitBatch calldata permitBatch, bytes calldata signature) external; function permit(address owner, PermitBatch calldata permitBatch, bytes calldata signature) external;
function transferFrom(address from, address to, uint160 amount, address token) external; function transferFrom(address from, address to, uint160 amount, address token) external;
function transferFrom(AllowanceTransferDetails[] calldata transferDetails) external; function transferFrom(AllowanceTransferDetails[] calldata transferDetails) external;
function allowance(address user, address token, address spender) function allowance(address user, address token, address spender)
external view returns (uint160 amount, uint48 expiration, uint48 nonce); external view returns (uint160 amount, uint48 expiration, uint48 nonce);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,222 +1,222 @@
use crate::{ use crate::{
crypto::integrity::v1::Integrable, crypto::integrity::v1::Integrable,
db::models::{EvmBasicGrant, EvmWalletAccess}, db::models::{EvmBasicGrant, EvmWalletAccess},
evm::utils, evm::utils,
}; };
use alloy::primitives::{Address, Bytes, ChainId, U256}; use alloy::primitives::{Address, Bytes, ChainId, U256};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use diesel::{ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite, ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use std::fmt::Display; use std::fmt::Display;
use thiserror::Error; use thiserror::Error;
pub mod ether_transfer; pub mod ether_transfer;
pub mod token_transfers; pub mod token_transfers;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EvalContext { pub struct EvalContext {
// Which wallet is this transaction for and who requested it // Which wallet is this transaction for and who requested it
pub target: EvmWalletAccess, pub target: EvmWalletAccess,
// The transaction data // The transaction data
pub chain: ChainId, pub chain: ChainId,
pub to: Address, pub to: Address,
pub value: U256, pub value: U256,
pub calldata: Bytes, pub calldata: Bytes,
// Gas pricing (EIP-1559) // Gas pricing (EIP-1559)
pub max_fee_per_gas: u128, pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128, pub max_priority_fee_per_gas: u128,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum EvalViolation { pub enum EvalViolation {
#[error("This grant doesn't allow transactions to the target address {target}")] #[error("This grant doesn't allow transactions to the target address {target}")]
InvalidTarget { target: Address }, InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")] #[error("Gas limit exceeded for this grant")]
GasLimitExceeded { GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>, max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>, max_priority_fee_per_gas: Option<U256>,
}, },
#[error("Rate limit exceeded for this grant")] #[error("Rate limit exceeded for this grant")]
RateLimitExceeded, RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")] #[error("Transaction exceeds volumetric limits of the grant")]
VolumetricLimitExceeded, VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")] #[error("Transaction is outside of the grant's validity period")]
InvalidTime, InvalidTime,
#[error("Transaction type is not allowed by this grant")] #[error("Transaction type is not allowed by this grant")]
InvalidTransactionType, InvalidTransactionType,
#[error("Mismatching chain ID")] #[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId }, MismatchingChainId { expected: ChainId, actual: ChainId },
} }
pub type DatabaseID = i32; pub type DatabaseID = i32;
#[derive(Debug)] #[derive(Debug)]
pub struct Grant<PolicySettings> { pub struct Grant<PolicySettings> {
pub id: DatabaseID, pub id: DatabaseID,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub settings: CombinedSettings<PolicySettings>, pub settings: CombinedSettings<PolicySettings>,
} }
pub trait Policy: Sized { pub trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant> + Integrable; type Settings: Send + Sync + 'static + Into<SpecificGrant> + Integrable;
type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>; fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
// Evaluate whether a transaction with the given meaning complies with the provided grant, and return any violations if not // Evaluate whether a transaction with the given meaning complies with the provided grant, and return any violations if not
// Empty vector means transaction is compliant with the grant // Empty vector means transaction is compliant with the grant
fn evaluate( fn evaluate(
context: &EvalContext, context: &EvalContext,
meaning: &Self::Meaning, meaning: &Self::Meaning,
grant: &Grant<Self::Settings>, grant: &Grant<Self::Settings>,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<Vec<EvalViolation>>> + Send; ) -> impl Future<Output = QueryResult<Vec<EvalViolation>>> + Send;
// Create a new grant in the database based on the provided grant details, and return its ID // Create a new grant in the database based on the provided grant details, and return its ID
fn create_grant( fn create_grant(
basic: &EvmBasicGrant, basic: &EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<DatabaseID>> + Send; ) -> impl Future<Output = QueryResult<DatabaseID>> + Send;
// Try to find an existing grant that matches the transaction context, and return its details if found // Try to find an existing grant that matches the transaction context, and return its details if found
// Additionally, return ID of basic grant for shared-logic checks like rate limits and validity periods // Additionally, return ID of basic grant for shared-logic checks like rate limits and validity periods
fn try_find_grant( fn try_find_grant(
context: &EvalContext, context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<Option<Grant<Self::Settings>>>> + Send; ) -> impl Future<Output = QueryResult<Option<Grant<Self::Settings>>>> + Send;
// Return all non-revoked grants, eagerly loading policy-specific settings // Return all non-revoked grants, eagerly loading policy-specific settings
fn find_all_grants( fn find_all_grants(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<Vec<Grant<Self::Settings>>>> + Send; ) -> impl Future<Output = QueryResult<Vec<Grant<Self::Settings>>>> + Send;
// Records, updates or deletes rate limits // Records, updates or deletes rate limits
// In other words, records grant-specific things after transaction is executed // In other words, records grant-specific things after transaction is executed
fn record_transaction( fn record_transaction(
context: &EvalContext, context: &EvalContext,
meaning: &Self::Meaning, meaning: &Self::Meaning,
log_id: i32, log_id: i32,
grant: &Grant<Self::Settings>, grant: &Grant<Self::Settings>,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<()>> + Send; ) -> impl Future<Output = QueryResult<()>> + Send;
} }
pub enum ReceiverTarget { pub enum ReceiverTarget {
Specific(Vec<Address>), // only allow transfers to these addresses Specific(Vec<Address>), // only allow transfers to these addresses
Any, // allow transfers to any address Any, // allow transfers to any address
} }
// Classification of what transaction does // Classification of what transaction does
#[derive(Debug)] #[derive(Debug)]
pub enum SpecificMeaning { pub enum SpecificMeaning {
EtherTransfer(ether_transfer::Meaning), EtherTransfer(ether_transfer::Meaning),
TokenTransfer(token_transfers::Meaning), TokenTransfer(token_transfers::Meaning),
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)]
pub struct TransactionRateLimit { pub struct TransactionRateLimit {
pub count: u32, pub count: u32,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)]
pub struct VolumeRateLimit { pub struct VolumeRateLimit {
pub max_volume: U256, pub max_volume: U256,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, arbiter_macros::Hashable)] #[derive(Clone, Debug, PartialEq, Eq, Hash, arbiter_macros::Hashable)]
pub struct SharedGrantSettings { pub struct SharedGrantSettings {
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain: ChainId, pub chain: ChainId,
pub valid_from: Option<DateTime<Utc>>, pub valid_from: Option<DateTime<Utc>>,
pub valid_until: Option<DateTime<Utc>>, pub valid_until: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>, pub revoked_at: Option<DateTime<Utc>>,
pub max_gas_fee_per_gas: Option<U256>, pub max_gas_fee_per_gas: Option<U256>,
pub max_priority_fee_per_gas: Option<U256>, pub max_priority_fee_per_gas: Option<U256>,
pub rate_limit: Option<TransactionRateLimit>, pub rate_limit: Option<TransactionRateLimit>,
} }
impl SharedGrantSettings { impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> { pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self { Ok(Self {
wallet_access_id: model.wallet_access_id, wallet_access_id: model.wallet_access_id,
chain: model.chain_id.into(), chain: model.chain_id.into(),
valid_from: model.valid_from.map(Into::into), valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into), valid_until: model.valid_until.map(Into::into),
revoked_at: model.revoked_at.map(Into::into), revoked_at: model.revoked_at.map(Into::into),
max_gas_fee_per_gas: model max_gas_fee_per_gas: model
.max_gas_fee_per_gas .max_gas_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b)) .map(|b| utils::try_bytes_to_u256(&b))
.transpose()?, .transpose()?,
max_priority_fee_per_gas: model max_priority_fee_per_gas: model
.max_priority_fee_per_gas .max_priority_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b)) .map(|b| utils::try_bytes_to_u256(&b))
.transpose()?, .transpose()?,
#[expect(clippy::cast_sign_loss, clippy::as_conversions, reason = "fixme! #86")] #[expect(clippy::cast_sign_loss, clippy::as_conversions, reason = "fixme! #86")]
rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) { rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) {
(Some(count), Some(window_secs)) => Some(TransactionRateLimit { (Some(count), Some(window_secs)) => Some(TransactionRateLimit {
count: count as u32, count: count as u32,
window: Duration::seconds(window_secs.into()), window: Duration::seconds(window_secs.into()),
}), }),
_ => None, _ => None,
}, },
}) })
} }
pub async fn query_by_id( pub async fn query_by_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
id: i32, id: i32,
) -> QueryResult<Self> { ) -> QueryResult<Self> {
use crate::db::schema::evm_basic_grant; use crate::db::schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = evm_basic_grant::table let basic_grant: EvmBasicGrant = evm_basic_grant::table
.select(EvmBasicGrant::as_select()) .select(EvmBasicGrant::as_select())
.filter(evm_basic_grant::id.eq(id)) .filter(evm_basic_grant::id.eq(id))
.first::<EvmBasicGrant>(conn) .first::<EvmBasicGrant>(conn)
.await?; .await?;
Self::try_from_model(basic_grant) Self::try_from_model(basic_grant)
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SpecificGrant { pub enum SpecificGrant {
EtherTransfer(ether_transfer::Settings), EtherTransfer(ether_transfer::Settings),
TokenTransfer(token_transfers::Settings), TokenTransfer(token_transfers::Settings),
} }
#[derive(Debug, arbiter_macros::Hashable)] #[derive(Debug, arbiter_macros::Hashable)]
pub struct CombinedSettings<PolicyGrant> { pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings, pub shared: SharedGrantSettings,
pub specific: PolicyGrant, pub specific: PolicyGrant,
} }
impl<P> CombinedSettings<P> { impl<P> CombinedSettings<P> {
pub fn generalize<Y: From<P>>(self) -> CombinedSettings<Y> { pub fn generalize<Y: From<P>>(self) -> CombinedSettings<Y> {
CombinedSettings { CombinedSettings {
shared: self.shared, shared: self.shared,
specific: self.specific.into(), specific: self.specific.into(),
} }
} }
} }
impl<P: Integrable> Integrable for CombinedSettings<P> { impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND; const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION; const VERSION: i32 = P::VERSION;
} }

View File

@@ -1,359 +1,359 @@
use super::{DatabaseID, EvalContext, EvalViolation}; use super::{DatabaseID, EvalContext, EvalViolation};
use crate::{ use crate::{
crypto::integrity::v1::Integrable, crypto::integrity::v1::Integrable,
db::models::{ db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit, EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp, NewEvmEtherTransferLimit, SqliteTimestamp,
}, },
db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log}, db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log},
db::{ db::{
models::{NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget}, models::{NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target}, schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target},
}, },
evm::policies::{ evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
VolumeRateLimit, VolumeRateLimit,
}, },
evm::{policies::Policy, utils}, evm::{policies::Policy, utils},
}; };
use alloy::primitives::{Address, U256}; use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use diesel::{ use diesel::{
dsl::{auto_type, insert_into}, dsl::{auto_type, insert_into},
prelude::*, prelude::*,
sqlite::Sqlite, sqlite::Sqlite,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use std::{collections::HashMap, fmt::Display}; use std::{collections::HashMap, fmt::Display};
#[auto_type] #[auto_type]
fn grant_join() -> _ { fn grant_join() -> _ {
evm_ether_transfer_grant::table.inner_join( evm_ether_transfer_grant::table.inner_join(
evm_basic_grant::table.on(evm_ether_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)), evm_basic_grant::table.on(evm_ether_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)),
) )
} }
// Plain ether transfer // Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
pub(crate) to: Address, pub(crate) to: Address,
pub(crate) value: U256, pub(crate) value: U256,
} }
impl Display for Meaning { impl Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Ether transfer of {} to {}", self.value, self.to) write!(f, "Ether transfer of {} to {}", self.value, self.to)
} }
} }
impl From<Meaning> for SpecificMeaning { impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> Self { fn from(val: Meaning) -> Self {
Self::EtherTransfer(val) Self::EtherTransfer(val)
} }
} }
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits // A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, arbiter_macros::Hashable)] #[derive(Debug, Clone, arbiter_macros::Hashable)]
pub struct Settings { pub struct Settings {
pub target: Vec<Address>, pub target: Vec<Address>,
pub limit: VolumeRateLimit, pub limit: VolumeRateLimit,
} }
impl Integrable for Settings { impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer"; const KIND: &'static str = "EtherTransfer";
} }
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> Self { fn from(val: Settings) -> Self {
Self::EtherTransfer(val) Self::EtherTransfer(val)
} }
} }
async fn query_relevant_past_transaction( async fn query_relevant_past_transaction(
grant_id: i32, grant_id: i32,
longest_window: Duration, longest_window: Duration,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> { ) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table
.filter(evm_transaction_log::grant_id.eq(grant_id)) .filter(evm_transaction_log::grant_id.eq(grant_id))
.filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window))) .filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select(( .select((
evm_transaction_log::eth_value, evm_transaction_log::eth_value,
evm_transaction_log::signed_at, evm_transaction_log::signed_at,
)) ))
.load(db) .load(db)
.await?; .await?;
let past_transaction: Vec<(U256, DateTime<Utc>)> = past_transactions let past_transaction: Vec<(U256, DateTime<Utc>)> = past_transactions
.into_iter() .into_iter()
.filter_map(|(value_bytes, timestamp)| { .filter_map(|(value_bytes, timestamp)| {
let value = utils::bytes_to_u256(&value_bytes)?; let value = utils::bytes_to_u256(&value_bytes)?;
Some((value, timestamp.0)) Some((value, timestamp.0))
}) })
.collect(); .collect();
Ok(past_transaction) Ok(past_transaction)
} }
async fn check_rate_limits( async fn check_rate_limits(
grant: &Grant<Settings>, grant: &Grant<Settings>,
current_transfer_value: U256, current_transfer_value: U256,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let window = grant.settings.specific.limit.window; let window = grant.settings.specific.limit.window;
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?; let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = Utc::now() - grant.settings.specific.limit.window; let window_start = Utc::now() - grant.settings.specific.limit.window;
let prospective_cumulative_volume: U256 = past_transaction let prospective_cumulative_volume: U256 = past_transaction
.iter() .iter()
.filter(|(_, timestamp)| timestamp >= &window_start) .filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value); .fold(current_transfer_value, |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > grant.settings.specific.limit.max_volume { if prospective_cumulative_volume > grant.settings.specific.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded); violations.push(EvalViolation::VolumetricLimitExceeded);
} }
Ok(violations) Ok(violations)
} }
pub struct EtherTransfer; pub struct EtherTransfer;
impl Policy for EtherTransfer { impl Policy for EtherTransfer {
type Settings = Settings; type Settings = Settings;
type Meaning = Meaning; type Meaning = Meaning;
fn analyze(context: &EvalContext) -> Option<Self::Meaning> { fn analyze(context: &EvalContext) -> Option<Self::Meaning> {
if !context.calldata.is_empty() { if !context.calldata.is_empty() {
return None; return None;
} }
Some(Meaning { Some(Meaning {
to: context.to, to: context.to,
value: context.value, value: context.value,
}) })
} }
async fn evaluate( async fn evaluate(
_: &EvalContext, _: &EvalContext,
meaning: &Self::Meaning, meaning: &Self::Meaning,
grant: &Grant<Self::Settings>, grant: &Grant<Self::Settings>,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets // Check if the target address is within the grant's allowed targets
if !grant.settings.specific.target.contains(&meaning.to) { if !grant.settings.specific.target.contains(&meaning.to) {
violations.push(EvalViolation::InvalidTarget { target: meaning.to }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
} }
let rate_violations = check_rate_limits(grant, meaning.value, db).await?; let rate_violations = check_rate_limits(grant, meaning.value, db).await?;
violations.extend(rate_violations); violations.extend(rate_violations);
Ok(violations) Ok(violations)
} }
async fn create_grant( async fn create_grant(
basic: &EvmBasicGrant, basic: &EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<DatabaseID> { ) -> QueryResult<DatabaseID> {
#[expect( #[expect(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::as_conversions, clippy::as_conversions,
reason = "fixme! #86" reason = "fixme! #86"
)] )]
let limit_id: i32 = insert_into(evm_ether_transfer_limit::table) let limit_id: i32 = insert_into(evm_ether_transfer_limit::table)
.values(NewEvmEtherTransferLimit { .values(NewEvmEtherTransferLimit {
window_secs: grant.limit.window.num_seconds() as i32, window_secs: grant.limit.window.num_seconds() as i32,
max_volume: utils::u256_to_bytes(grant.limit.max_volume).to_vec(), max_volume: utils::u256_to_bytes(grant.limit.max_volume).to_vec(),
}) })
.returning(evm_ether_transfer_limit::id) .returning(evm_ether_transfer_limit::id)
.get_result(conn) .get_result(conn)
.await?; .await?;
let grant_id: i32 = insert_into(evm_ether_transfer_grant::table) let grant_id: i32 = insert_into(evm_ether_transfer_grant::table)
.values(&NewEvmEtherTransferGrant { .values(&NewEvmEtherTransferGrant {
basic_grant_id: basic.id, basic_grant_id: basic.id,
limit_id, limit_id,
}) })
.returning(evm_ether_transfer_grant::id) .returning(evm_ether_transfer_grant::id)
.get_result(conn) .get_result(conn)
.await?; .await?;
for target in &grant.target { for target in &grant.target {
insert_into(evm_ether_transfer_grant_target::table) insert_into(evm_ether_transfer_grant_target::table)
.values(NewEvmEtherTransferGrantTarget { .values(NewEvmEtherTransferGrantTarget {
grant_id, grant_id,
address: target.to_vec(), address: target.to_vec(),
}) })
.execute(conn) .execute(conn)
.await?; .await?;
} }
Ok(grant_id) Ok(grant_id)
} }
async fn try_find_grant( async fn try_find_grant(
context: &EvalContext, context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Option<Grant<Self::Settings>>> { ) -> QueryResult<Option<Grant<Self::Settings>>> {
let target_bytes = context.to.to_vec(); let target_bytes = context.to.to_vec();
// Find a grant where: // Find a grant where:
// 1. The basic grant's wallet_id and client_id match the context // 1. The basic grant's wallet_id and client_id match the context
// 2. Any of the grant's targets match the context's `to` address // 2. Any of the grant's targets match the context's `to` address
let grant: Option<(EvmBasicGrant, EvmEtherTransferGrant)> = evm_ether_transfer_grant::table let grant: Option<(EvmBasicGrant, EvmEtherTransferGrant)> = evm_ether_transfer_grant::table
.inner_join(evm_basic_grant::table) .inner_join(evm_basic_grant::table)
.inner_join(evm_ether_transfer_grant_target::table) .inner_join(evm_ether_transfer_grant_target::table)
.filter( .filter(
evm_basic_grant::wallet_access_id evm_basic_grant::wallet_access_id
.eq(context.target.id) .eq(context.target.id)
.and(evm_basic_grant::revoked_at.is_null()) .and(evm_basic_grant::revoked_at.is_null())
.and(evm_ether_transfer_grant_target::address.eq(&target_bytes)), .and(evm_ether_transfer_grant_target::address.eq(&target_bytes)),
) )
.select(( .select((
EvmBasicGrant::as_select(), EvmBasicGrant::as_select(),
EvmEtherTransferGrant::as_select(), EvmEtherTransferGrant::as_select(),
)) ))
.first(conn) .first(conn)
.await .await
.optional()?; .optional()?;
let Some((basic_grant, grant)) = grant else { let Some((basic_grant, grant)) = grant else {
return Ok(None); return Ok(None);
}; };
let target_bytes: Vec<EvmEtherTransferGrantTarget> = evm_ether_transfer_grant_target::table let target_bytes: Vec<EvmEtherTransferGrantTarget> = evm_ether_transfer_grant_target::table
.select(EvmEtherTransferGrantTarget::as_select()) .select(EvmEtherTransferGrantTarget::as_select())
.filter(evm_ether_transfer_grant_target::grant_id.eq(grant.id)) .filter(evm_ether_transfer_grant_target::grant_id.eq(grant.id))
.load(conn) .load(conn)
.await?; .await?;
let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq(grant.limit_id)) .filter(evm_ether_transfer_limit::id.eq(grant.limit_id))
.select(EvmEtherTransferLimit::as_select()) .select(EvmEtherTransferLimit::as_select())
.first::<EvmEtherTransferLimit>(conn) .first::<EvmEtherTransferLimit>(conn)
.await?; .await?;
// Convert bytes back to Address // Convert bytes back to Address
let targets: Vec<Address> = target_bytes let targets: Vec<Address> = target_bytes
.into_iter() .into_iter()
.filter_map(|target| { .filter_map(|target| {
// TODO: Handle invalid addresses more gracefully // TODO: Handle invalid addresses more gracefully
let arr: [u8; 20] = target.address.try_into().ok()?; let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr)) Some(Address::from(arr))
}) })
.collect(); .collect();
let settings = Settings { let settings = Settings {
target: targets, target: targets,
limit: VolumeRateLimit { limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume) max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?, .map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: Duration::seconds(limit.window_secs.into()), window: Duration::seconds(limit.window_secs.into()),
}, },
}; };
Ok(Some(Grant { Ok(Some(Grant {
id: grant.id, id: grant.id,
common_settings_id: grant.basic_grant_id, common_settings_id: grant.basic_grant_id,
settings: CombinedSettings { settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?, shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings, specific: settings,
}, },
})) }))
} }
async fn record_transaction( async fn record_transaction(
_context: &EvalContext, _context: &EvalContext,
_: &Self::Meaning, _: &Self::Meaning,
_log_id: i32, _log_id: i32,
_grant: &Grant<Self::Settings>, _grant: &Grant<Self::Settings>,
_conn: &mut impl AsyncConnection<Backend = Sqlite>, _conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<()> { ) -> QueryResult<()> {
// Basic log is sufficient // Basic log is sufficient
Ok(()) Ok(())
} }
async fn find_all_grants( async fn find_all_grants(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<Grant<Self::Settings>>> { ) -> QueryResult<Vec<Grant<Self::Settings>>> {
let grants: Vec<(EvmBasicGrant, EvmEtherTransferGrant)> = grant_join() let grants: Vec<(EvmBasicGrant, EvmEtherTransferGrant)> = grant_join()
.filter(evm_basic_grant::revoked_at.is_null()) .filter(evm_basic_grant::revoked_at.is_null())
.select(( .select((
EvmBasicGrant::as_select(), EvmBasicGrant::as_select(),
EvmEtherTransferGrant::as_select(), EvmEtherTransferGrant::as_select(),
)) ))
.load(conn) .load(conn)
.await?; .await?;
if grants.is_empty() { if grants.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let grant_ids: Vec<i32> = grants.iter().map(|(_, g)| g.id).collect(); let grant_ids: Vec<i32> = grants.iter().map(|(_, g)| g.id).collect();
let limit_ids: Vec<i32> = grants.iter().map(|(_, g)| g.limit_id).collect(); let limit_ids: Vec<i32> = grants.iter().map(|(_, g)| g.limit_id).collect();
let all_targets: Vec<EvmEtherTransferGrantTarget> = evm_ether_transfer_grant_target::table let all_targets: Vec<EvmEtherTransferGrantTarget> = evm_ether_transfer_grant_target::table
.filter(evm_ether_transfer_grant_target::grant_id.eq_any(&grant_ids)) .filter(evm_ether_transfer_grant_target::grant_id.eq_any(&grant_ids))
.select(EvmEtherTransferGrantTarget::as_select()) .select(EvmEtherTransferGrantTarget::as_select())
.load(conn) .load(conn)
.await?; .await?;
let all_limits: Vec<EvmEtherTransferLimit> = evm_ether_transfer_limit::table let all_limits: Vec<EvmEtherTransferLimit> = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq_any(&limit_ids)) .filter(evm_ether_transfer_limit::id.eq_any(&limit_ids))
.select(EvmEtherTransferLimit::as_select()) .select(EvmEtherTransferLimit::as_select())
.load(conn) .load(conn)
.await?; .await?;
let mut targets_by_grant: HashMap<i32, Vec<EvmEtherTransferGrantTarget>> = HashMap::new(); let mut targets_by_grant: HashMap<i32, Vec<EvmEtherTransferGrantTarget>> = HashMap::new();
for target in all_targets { for target in all_targets {
targets_by_grant targets_by_grant
.entry(target.grant_id) .entry(target.grant_id)
.or_default() .or_default()
.push(target); .push(target);
} }
let limits_by_id: HashMap<i32, EvmEtherTransferLimit> = let limits_by_id: HashMap<i32, EvmEtherTransferLimit> =
all_limits.into_iter().map(|l| (l.id, l)).collect(); all_limits.into_iter().map(|l| (l.id, l)).collect();
grants grants
.into_iter() .into_iter()
.map(|(basic, specific)| { .map(|(basic, specific)| {
let targets: Vec<Address> = targets_by_grant let targets: Vec<Address> = targets_by_grant
.get(&specific.id) .get(&specific.id)
.map(Vec::as_slice) .map(Vec::as_slice)
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.filter_map(|t| { .filter_map(|t| {
let arr: [u8; 20] = t.address.clone().try_into().ok()?; let arr: [u8; 20] = t.address.clone().try_into().ok()?;
Some(Address::from(arr)) Some(Address::from(arr))
}) })
.collect(); .collect();
let limit = limits_by_id let limit = limits_by_id
.get(&specific.limit_id) .get(&specific.limit_id)
.ok_or(diesel::result::Error::NotFound)?; .ok_or(diesel::result::Error::NotFound)?;
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
common_settings_id: specific.basic_grant_id, common_settings_id: specific.basic_grant_id,
settings: CombinedSettings { settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?, shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings { specific: Settings {
target: targets, target: targets,
limit: VolumeRateLimit { limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|e| diesel::result::Error::DeserializationError(Box::new(e)), |e| diesel::result::Error::DeserializationError(Box::new(e)),
)?, )?,
window: Duration::seconds(limit.window_secs.into()), window: Duration::seconds(limit.window_secs.into()),
}, },
}, },
}, },
}) })
}) })
.collect() .collect()
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -1,430 +1,430 @@
use super::{EtherTransfer, Settings}; use super::{EtherTransfer, Settings};
use crate::{ use crate::{
db::{ db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
}, },
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}, },
evm::{ evm::{
policies::{ policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit, VolumeRateLimit,
}, },
utils, utils,
}, },
}; };
use alloy::primitives::{Address, Bytes, U256, address}; use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into}; use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
const WALLET_ACCESS_ID: i32 = 1; const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1; const CHAIN_ID: alloy::primitives::ChainId = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111"); const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222"); const OTHER: Address = address!("2222222222222222222222222222222222222222");
fn ctx(to: Address, value: U256) -> EvalContext { fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: 10, wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
chain: CHAIN_ID, chain: CHAIN_ID,
to, to,
value, value,
calldata: Bytes::new(), calldata: Bytes::new(),
max_fee_per_gas: 0, max_fee_per_gas: 0,
max_priority_fee_per_gas: 0, max_priority_fee_per_gas: 0,
} }
} }
async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant {
insert_into(evm_basic_grant::table) insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant { .values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID.into(),
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit_count: None, rate_limit_count: None,
rate_limit_window_secs: None, rate_limit_window_secs: None,
revoked_at: revoked.then(|| SqliteTimestamp(Utc::now())), revoked_at: revoked.then(|| SqliteTimestamp(Utc::now())),
}) })
.returning(EvmBasicGrant::as_select()) .returning(EvmBasicGrant::as_select())
.get_result(conn) .get_result(conn)
.await .await
.unwrap() .unwrap()
} }
fn make_settings(targets: Vec<Address>, max_volume: u64) -> Settings { fn make_settings(targets: Vec<Address>, max_volume: u64) -> Settings {
Settings { Settings {
target: targets, target: targets,
limit: VolumeRateLimit { limit: VolumeRateLimit {
max_volume: U256::from(max_volume), max_volume: U256::from(max_volume),
window: Duration::hours(1), window: Duration::hours(1),
}, },
} }
} }
fn shared() -> SharedGrantSettings { fn shared() -> SharedGrantSettings {
SharedGrantSettings { SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None, revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,
} }
} }
#[test] #[test]
fn analyze_matches_empty_calldata() { fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap(); let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap();
assert_eq!(m.to, ALLOWED); assert_eq!(m.to, ALLOWED);
assert_eq!(m.value, U256::from(1_000u64)); assert_eq!(m.value, U256::from(1_000u64));
} }
#[test] #[test]
fn analyze_rejects_nonempty_calldata() { fn analyze_rejects_nonempty_calldata() {
let context = EvalContext { let context = EvalContext {
calldata: Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]), calldata: Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]),
..ctx(ALLOWED, U256::from(1u64)) ..ctx(ALLOWED, U256::from(1u64))
}; };
assert!(EtherTransfer::analyze(&context).is_none()); assert!(EtherTransfer::analyze(&context).is_none());
} }
#[tokio::test] #[tokio::test]
async fn evaluate_passes_for_allowed_target() { async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, common_settings_id: 999,
settings: CombinedSettings { settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000), specific: make_settings(vec![ALLOWED], 1_000_000),
}, },
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!(v.is_empty()); assert!(v.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn evaluate_rejects_disallowed_target() { async fn evaluate_rejects_disallowed_target() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, common_settings_id: 999,
settings: CombinedSettings { settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000), specific: make_settings(vec![ALLOWED], 1_000_000),
}, },
}; };
let context = ctx(OTHER, U256::from(100u64)); let context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!( assert!(
v.iter() v.iter()
.any(|e| matches!(e, EvalViolation::InvalidTarget { .. })) .any(|e| matches!(e, EvalViolation::InvalidTarget { .. }))
); );
} }
#[tokio::test] #[tokio::test]
async fn evaluate_passes_when_volume_within_limit() { async fn evaluate_passes_when_volume_within_limit() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, false).await; let basic = insert_basic(&mut conn, false).await;
let settings = make_settings(vec![ALLOWED], 1_000); let settings = make_settings(vec![ALLOWED], 1_000);
let grant_id = EtherTransfer::create_grant(&basic, &settings, &mut *conn) let grant_id = EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
insert_into(evm_transaction_log::table) insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
.unwrap(); .unwrap();
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings { settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, specific: settings,
}, },
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!( assert!(
!v.iter() !v.iter()
.any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded)) .any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded))
); );
} }
#[tokio::test] #[tokio::test]
async fn evaluate_rejects_volume_over_limit() { async fn evaluate_rejects_volume_over_limit() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, false).await; let basic = insert_basic(&mut conn, false).await;
let settings = make_settings(vec![ALLOWED], 1_000); let settings = make_settings(vec![ALLOWED], 1_000);
let grant_id = EtherTransfer::create_grant(&basic, &settings, &mut *conn) let grant_id = EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
insert_into(evm_transaction_log::table) insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
.unwrap(); .unwrap();
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings { settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, specific: settings,
}, },
}; };
let context = ctx(ALLOWED, U256::from(1u64)); let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!( assert!(
v.iter() v.iter()
.any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded)) .any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded))
); );
} }
#[tokio::test] #[tokio::test]
async fn evaluate_passes_at_exactly_volume_limit() { async fn evaluate_passes_at_exactly_volume_limit() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, false).await; let basic = insert_basic(&mut conn, false).await;
let settings = make_settings(vec![ALLOWED], 1_000); let settings = make_settings(vec![ALLOWED], 1_000);
let grant_id = EtherTransfer::create_grant(&basic, &settings, &mut *conn) let grant_id = EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
// Exactly at the limit including current transfer — check is `>`, so this should not violate // Exactly at the limit including current transfer — check is `>`, so this should not violate
insert_into(evm_transaction_log::table) insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
.unwrap(); .unwrap();
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings { settings: CombinedSettings {
shared: shared(), shared: shared(),
specific: settings, specific: settings,
}, },
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!( assert!(
!v.iter() !v.iter()
.any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded)) .any(|e| matches!(e, EvalViolation::VolumetricLimitExceeded))
); );
} }
#[tokio::test] #[tokio::test]
async fn try_find_grant_roundtrip() { async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, false).await; let basic = insert_basic(&mut conn, false).await;
let settings = make_settings(vec![ALLOWED], 1_000_000); let settings = make_settings(vec![ALLOWED], 1_000_000);
EtherTransfer::create_grant(&basic, &settings, &mut *conn) EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
let found = EtherTransfer::try_find_grant(&ctx(ALLOWED, U256::from(1u64)), &mut *conn) let found = EtherTransfer::try_find_grant(&ctx(ALLOWED, U256::from(1u64)), &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.specific.target, vec![ALLOWED]); assert_eq!(g.settings.specific.target, vec![ALLOWED]);
assert_eq!( assert_eq!(
g.settings.specific.limit.max_volume, g.settings.specific.limit.max_volume,
U256::from(1_000_000u64) U256::from(1_000_000u64)
); );
} }
#[tokio::test] #[tokio::test]
async fn try_find_grant_revoked_returns_none() { async fn try_find_grant_revoked_returns_none() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, true).await; let basic = insert_basic(&mut conn, true).await;
let settings = make_settings(vec![ALLOWED], 1_000_000); let settings = make_settings(vec![ALLOWED], 1_000_000);
EtherTransfer::create_grant(&basic, &settings, &mut *conn) EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
let found = EtherTransfer::try_find_grant(&ctx(ALLOWED, U256::from(1u64)), &mut *conn) let found = EtherTransfer::try_find_grant(&ctx(ALLOWED, U256::from(1u64)), &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!(found.is_none()); assert!(found.is_none());
} }
#[tokio::test] #[tokio::test]
async fn try_find_grant_wrong_target_returns_none() { async fn try_find_grant_wrong_target_returns_none() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, false).await; let basic = insert_basic(&mut conn, false).await;
let settings = make_settings(vec![ALLOWED], 1_000_000); let settings = make_settings(vec![ALLOWED], 1_000_000);
EtherTransfer::create_grant(&basic, &settings, &mut *conn) EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
let found = EtherTransfer::try_find_grant(&ctx(OTHER, U256::from(1u64)), &mut *conn) let found = EtherTransfer::try_find_grant(&ctx(OTHER, U256::from(1u64)), &mut *conn)
.await .await
.unwrap(); .unwrap();
assert!(found.is_none()); assert!(found.is_none());
} }
proptest::proptest! { proptest::proptest! {
#[test] #[test]
fn target_order_does_not_affect_hash( fn target_order_does_not_affect_hash(
raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8), raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8),
seed in proptest::prelude::any::<u64>(), seed in proptest::prelude::any::<u64>(),
max_volume in proptest::prelude::any::<u64>(), max_volume in proptest::prelude::any::<u64>(),
window_secs in 1i64..=86400, window_secs in 1i64..=86400,
) { ) {
use rand::{SeedableRng, seq::SliceRandom}; use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest; use sha2::Digest;
use arbiter_crypto::hashing::Hashable; use arbiter_crypto::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect(); let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone(); let mut shuffled = addrs.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed)); shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let limit = VolumeRateLimit { let limit = VolumeRateLimit {
max_volume: U256::from(max_volume), max_volume: U256::from(max_volume),
window: Duration::seconds(window_secs), window: Duration::seconds(window_secs),
}; };
let mut h1 = sha2::Sha256::new(); let mut h1 = sha2::Sha256::new();
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1); Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
let mut h2 = sha2::Sha256::new(); let mut h2 = sha2::Sha256::new();
Settings { target: shuffled, limit }.hash(&mut h2); Settings { target: shuffled, limit }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize()); proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
} }
} }
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert!(all.is_empty()); assert!(all.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn find_all_grants_excludes_revoked() { async fn find_all_grants_excludes_revoked() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let settings = make_settings(vec![ALLOWED], 1_000_000); let settings = make_settings(vec![ALLOWED], 1_000_000);
let active = insert_basic(&mut conn, false).await; let active = insert_basic(&mut conn, false).await;
EtherTransfer::create_grant(&active, &settings, &mut *conn) EtherTransfer::create_grant(&active, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
let revoked = insert_basic(&mut conn, true).await; let revoked = insert_basic(&mut conn, true).await;
EtherTransfer::create_grant(&revoked, &settings, &mut *conn) EtherTransfer::create_grant(&revoked, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.target, vec![ALLOWED]); assert_eq!(all[0].settings.specific.target, vec![ALLOWED]);
} }
#[tokio::test] #[tokio::test]
async fn find_all_grants_multiple_targets() { async fn find_all_grants_multiple_targets() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic = insert_basic(&mut conn, false).await; let basic = insert_basic(&mut conn, false).await;
let settings = make_settings(vec![ALLOWED, OTHER], 1_000_000); let settings = make_settings(vec![ALLOWED, OTHER], 1_000_000);
EtherTransfer::create_grant(&basic, &settings, &mut *conn) EtherTransfer::create_grant(&basic, &settings, &mut *conn)
.await .await
.unwrap(); .unwrap();
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.target.len(), 2); assert_eq!(all[0].settings.specific.target.len(), 2);
assert_eq!( assert_eq!(
all[0].settings.specific.limit.max_volume, all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64) U256::from(1_000_000u64)
); );
} }
#[tokio::test] #[tokio::test]
async fn find_all_grants_multiple_grants() { async fn find_all_grants_multiple_grants() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let basic1 = insert_basic(&mut conn, false).await; let basic1 = insert_basic(&mut conn, false).await;
EtherTransfer::create_grant(&basic1, &make_settings(vec![ALLOWED], 500), &mut *conn) EtherTransfer::create_grant(&basic1, &make_settings(vec![ALLOWED], 500), &mut *conn)
.await .await
.unwrap(); .unwrap();
let basic2 = insert_basic(&mut conn, false).await; let basic2 = insert_basic(&mut conn, false).await;
EtherTransfer::create_grant(&basic2, &make_settings(vec![OTHER], 1_000), &mut *conn) EtherTransfer::create_grant(&basic2, &make_settings(vec![OTHER], 1_000), &mut *conn)
.await .await
.unwrap(); .unwrap();
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
} }

View File

@@ -1,408 +1,408 @@
use super::{DatabaseID, EvalContext, EvalViolation}; use super::{DatabaseID, EvalContext, EvalViolation};
use crate::{ use crate::{
crypto::integrity::Integrable, crypto::integrity::Integrable,
db::models::{ db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp, SqliteTimestamp,
}, },
db::schema::{ db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit, evm_token_transfer_volume_limit,
}, },
evm::policies::CombinedSettings, evm::policies::CombinedSettings,
evm::{ evm::{
abi::IERC20::transferCall, abi::IERC20::transferCall,
policies::{ policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
}, },
utils, utils,
}, },
}; };
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo}; use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use alloy::{ use alloy::{
primitives::{Address, U256}, primitives::{Address, U256},
sol_types::SolCall, sol_types::SolCall,
}; };
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use diesel::{ use diesel::{
dsl::{auto_type, insert_into}, dsl::{auto_type, insert_into},
prelude::*, prelude::*,
sqlite::Sqlite, sqlite::Sqlite,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use std::collections::HashMap; use std::collections::HashMap;
#[auto_type] #[auto_type]
fn grant_join() -> _ { fn grant_join() -> _ {
evm_token_transfer_grant::table.inner_join( evm_token_transfer_grant::table.inner_join(
evm_basic_grant::table.on(evm_token_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)), evm_basic_grant::table.on(evm_token_transfer_grant::basic_grant_id.eq(evm_basic_grant::id)),
) )
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
pub token: &'static TokenInfo, pub token: &'static TokenInfo,
pub to: Address, pub to: Address,
pub value: U256, pub value: U256,
} }
impl std::fmt::Display for Meaning { impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!( write!(
f, f,
"Transfer of {} {} to {}", "Transfer of {} {} to {}",
self.value, self.token.symbol, self.to self.value, self.token.symbol, self.to
) )
} }
} }
impl From<Meaning> for SpecificMeaning { impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> Self { fn from(val: Meaning) -> Self {
Self::TokenTransfer(val) Self::TokenTransfer(val)
} }
} }
// A grant for token transfers, which can be scoped to specific target addresses and volume limits // A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, arbiter_macros::Hashable)] #[derive(Debug, Clone, arbiter_macros::Hashable)]
pub struct Settings { pub struct Settings {
pub token_contract: Address, pub token_contract: Address,
pub target: Option<Address>, pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>, pub volume_limits: Vec<VolumeRateLimit>,
} }
impl Integrable for Settings { impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer"; const KIND: &'static str = "TokenTransfer";
} }
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> Self { fn from(val: Settings) -> Self {
Self::TokenTransfer(val) Self::TokenTransfer(val)
} }
} }
async fn query_relevant_past_transfers( async fn query_relevant_past_transfers(
grant_id: i32, grant_id: i32,
longest_window: Duration, longest_window: Duration,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> { ) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_logs: Vec<(Vec<u8>, SqliteTimestamp)> = evm_token_transfer_log::table let past_logs: Vec<(Vec<u8>, SqliteTimestamp)> = evm_token_transfer_log::table
.filter(evm_token_transfer_log::grant_id.eq(grant_id)) .filter(evm_token_transfer_log::grant_id.eq(grant_id))
.filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window))) .filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select(( .select((
evm_token_transfer_log::value, evm_token_transfer_log::value,
evm_token_transfer_log::created_at, evm_token_transfer_log::created_at,
)) ))
.load(db) .load(db)
.await?; .await?;
let past_transfers: Vec<(U256, DateTime<Utc>)> = past_logs let past_transfers: Vec<(U256, DateTime<Utc>)> = past_logs
.into_iter() .into_iter()
.filter_map(|(value_bytes, timestamp)| { .filter_map(|(value_bytes, timestamp)| {
let value = utils::bytes_to_u256(&value_bytes)?; let value = utils::bytes_to_u256(&value_bytes)?;
Some((value, timestamp.0)) Some((value, timestamp.0))
}) })
.collect(); .collect();
Ok(past_transfers) Ok(past_transfers)
} }
async fn check_volume_rate_limits( async fn check_volume_rate_limits(
grant: &Grant<Settings>, grant: &Grant<Settings>,
current_transfer_value: U256, current_transfer_value: U256,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let Some(longest_window) = grant let Some(longest_window) = grant
.settings .settings
.specific .specific
.volume_limits .volume_limits
.iter() .iter()
.map(|l| l.window) .map(|l| l.window)
.max() .max()
else { else {
return Ok(violations); return Ok(violations);
}; };
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.specific.volume_limits { for limit in &grant.settings.specific.volume_limits {
let window_start = Utc::now() - limit.window; let window_start = Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers let prospective_cumulative_volume: U256 = past_transfers
.iter() .iter()
.filter(|(_, timestamp)| timestamp >= &window_start) .filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value); .fold(current_transfer_value, |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > limit.max_volume { if prospective_cumulative_volume > limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded); violations.push(EvalViolation::VolumetricLimitExceeded);
break; break;
} }
} }
Ok(violations) Ok(violations)
} }
pub struct TokenTransfer; pub struct TokenTransfer;
impl Policy for TokenTransfer { impl Policy for TokenTransfer {
type Settings = Settings; type Settings = Settings;
type Meaning = Meaning; type Meaning = Meaning;
fn analyze(context: &EvalContext) -> Option<Self::Meaning> { fn analyze(context: &EvalContext) -> Option<Self::Meaning> {
let token = nonfungible::get_token(context.chain, context.to)?; let token = nonfungible::get_token(context.chain, context.to)?;
let decoded = transferCall::abi_decode_raw_validate(&context.calldata).ok()?; let decoded = transferCall::abi_decode_raw_validate(&context.calldata).ok()?;
Some(Meaning { Some(Meaning {
token, token,
to: decoded.to, to: decoded.to,
value: decoded.value, value: decoded.value,
}) })
} }
async fn evaluate( async fn evaluate(
context: &EvalContext, context: &EvalContext,
meaning: &Self::Meaning, meaning: &Self::Meaning,
grant: &Grant<Self::Settings>, grant: &Grant<Self::Settings>,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
// erc20 transfer shouldn't carry eth value // erc20 transfer shouldn't carry eth value
if !context.value.is_zero() { if !context.value.is_zero() {
violations.push(EvalViolation::InvalidTransactionType); violations.push(EvalViolation::InvalidTransactionType);
return Ok(violations); return Ok(violations);
} }
if let Some(allowed) = grant.settings.specific.target if let Some(allowed) = grant.settings.specific.target
&& allowed != meaning.to && allowed != meaning.to
{ {
violations.push(EvalViolation::InvalidTarget { target: meaning.to }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
} }
let rate_violations = check_volume_rate_limits(grant, meaning.value, db).await?; let rate_violations = check_volume_rate_limits(grant, meaning.value, db).await?;
violations.extend(rate_violations); violations.extend(rate_violations);
Ok(violations) Ok(violations)
} }
async fn create_grant( async fn create_grant(
basic: &EvmBasicGrant, basic: &EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<DatabaseID> { ) -> QueryResult<DatabaseID> {
// Store the specific receiver as bytes (None means any receiver is allowed) // Store the specific receiver as bytes (None means any receiver is allowed)
let receiver: Option<Vec<u8>> = grant.target.map(|addr| addr.to_vec()); let receiver: Option<Vec<u8>> = grant.target.map(|addr| addr.to_vec());
let grant_id: i32 = insert_into(evm_token_transfer_grant::table) let grant_id: i32 = insert_into(evm_token_transfer_grant::table)
.values(NewEvmTokenTransferGrant { .values(NewEvmTokenTransferGrant {
basic_grant_id: basic.id, basic_grant_id: basic.id,
token_contract: grant.token_contract.to_vec(), token_contract: grant.token_contract.to_vec(),
receiver, receiver,
}) })
.returning(evm_token_transfer_grant::id) .returning(evm_token_transfer_grant::id)
.get_result(conn) .get_result(conn)
.await?; .await?;
for limit in &grant.volume_limits { for limit in &grant.volume_limits {
#[expect( #[expect(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::as_conversions, clippy::as_conversions,
reason = "fixme! #86" reason = "fixme! #86"
)] )]
insert_into(evm_token_transfer_volume_limit::table) insert_into(evm_token_transfer_volume_limit::table)
.values(NewEvmTokenTransferVolumeLimit { .values(NewEvmTokenTransferVolumeLimit {
grant_id, grant_id,
window_secs: limit.window.num_seconds() as i32, window_secs: limit.window.num_seconds() as i32,
max_volume: utils::u256_to_bytes(limit.max_volume).to_vec(), max_volume: utils::u256_to_bytes(limit.max_volume).to_vec(),
}) })
.execute(conn) .execute(conn)
.await?; .await?;
} }
Ok(grant_id) Ok(grant_id)
} }
async fn try_find_grant( async fn try_find_grant(
context: &EvalContext, context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Option<Grant<Self::Settings>>> { ) -> QueryResult<Option<Grant<Self::Settings>>> {
let token_contract_bytes = context.to.to_vec(); let token_contract_bytes = context.to.to_vec();
let grant: Option<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() let grant: Option<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join()
.filter(evm_basic_grant::revoked_at.is_null()) .filter(evm_basic_grant::revoked_at.is_null())
.filter(evm_basic_grant::wallet_access_id.eq(context.target.id)) .filter(evm_basic_grant::wallet_access_id.eq(context.target.id))
.filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes)) .filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes))
.select(( .select((
EvmBasicGrant::as_select(), EvmBasicGrant::as_select(),
EvmTokenTransferGrant::as_select(), EvmTokenTransferGrant::as_select(),
)) ))
.first(conn) .first(conn)
.await .await
.optional()?; .optional()?;
let Some((basic_grant, token_grant)) = grant else { let Some((basic_grant, token_grant)) = grant else {
return Ok(None); return Ok(None);
}; };
let volume_limits_db: Vec<EvmTokenTransferVolumeLimit> = let volume_limits_db: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id)) .filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id))
.select(EvmTokenTransferVolumeLimit::as_select()) .select(EvmTokenTransferVolumeLimit::as_select())
.load(conn) .load(conn)
.await?; .await?;
let volume_limits: Vec<VolumeRateLimit> = volume_limits_db let volume_limits: Vec<VolumeRateLimit> = volume_limits_db
.into_iter() .into_iter()
.map(|row| { .map(|row| {
Ok(VolumeRateLimit { Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| { max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err)) diesel::result::Error::DeserializationError(Box::new(err))
})?, })?,
window: Duration::seconds(row.window_secs.into()), window: Duration::seconds(row.window_secs.into()),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
let token_contract: [u8; 20] = token_grant.token_contract.try_into().map_err(|_| { let token_contract: [u8; 20] = token_grant.token_contract.try_into().map_err(|_| {
diesel::result::Error::DeserializationError( diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(), "Invalid token contract address length".into(),
) )
})?; })?;
let target: Option<Address> = match token_grant.receiver { let target: Option<Address> = match token_grant.receiver {
None => None, None => None,
Some(bytes) => { Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| { let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError( diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(), "Invalid receiver address length".into(),
) )
})?; })?;
Some(Address::from(arr)) Some(Address::from(arr))
} }
}; };
let settings = Settings { let settings = Settings {
token_contract: Address::from(token_contract), token_contract: Address::from(token_contract),
target, target,
volume_limits, volume_limits,
}; };
Ok(Some(Grant { Ok(Some(Grant {
id: token_grant.id, id: token_grant.id,
common_settings_id: token_grant.basic_grant_id, common_settings_id: token_grant.basic_grant_id,
settings: CombinedSettings { settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?, shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings, specific: settings,
}, },
})) }))
} }
async fn record_transaction( async fn record_transaction(
context: &EvalContext, context: &EvalContext,
meaning: &Self::Meaning, meaning: &Self::Meaning,
log_id: i32, log_id: i32,
grant: &Grant<Self::Settings>, grant: &Grant<Self::Settings>,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<()> { ) -> QueryResult<()> {
insert_into(evm_token_transfer_log::table) insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog { .values(NewEvmTokenTransferLog {
grant_id: grant.id, grant_id: grant.id,
log_id, log_id,
chain_id: context.chain.into(), chain_id: context.chain.into(),
token_contract: context.to.to_vec(), token_contract: context.to.to_vec(),
recipient_address: meaning.to.to_vec(), recipient_address: meaning.to.to_vec(),
value: utils::u256_to_bytes(meaning.value).to_vec(), value: utils::u256_to_bytes(meaning.value).to_vec(),
}) })
.execute(conn) .execute(conn)
.await?; .await?;
Ok(()) Ok(())
} }
async fn find_all_grants( async fn find_all_grants(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<Grant<Self::Settings>>> { ) -> QueryResult<Vec<Grant<Self::Settings>>> {
let grants: Vec<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() let grants: Vec<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join()
.filter(evm_basic_grant::revoked_at.is_null()) .filter(evm_basic_grant::revoked_at.is_null())
.select(( .select((
EvmBasicGrant::as_select(), EvmBasicGrant::as_select(),
EvmTokenTransferGrant::as_select(), EvmTokenTransferGrant::as_select(),
)) ))
.load(conn) .load(conn)
.await?; .await?;
if grants.is_empty() { if grants.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let grant_ids: Vec<i32> = grants.iter().map(|(_, g)| g.id).collect(); let grant_ids: Vec<i32> = grants.iter().map(|(_, g)| g.id).collect();
let all_volume_limits: Vec<EvmTokenTransferVolumeLimit> = let all_volume_limits: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq_any(&grant_ids)) .filter(evm_token_transfer_volume_limit::grant_id.eq_any(&grant_ids))
.select(EvmTokenTransferVolumeLimit::as_select()) .select(EvmTokenTransferVolumeLimit::as_select())
.load(conn) .load(conn)
.await?; .await?;
let mut limits_by_grant: HashMap<i32, Vec<EvmTokenTransferVolumeLimit>> = HashMap::new(); let mut limits_by_grant: HashMap<i32, Vec<EvmTokenTransferVolumeLimit>> = HashMap::new();
for limit in all_volume_limits { for limit in all_volume_limits {
limits_by_grant limits_by_grant
.entry(limit.grant_id) .entry(limit.grant_id)
.or_default() .or_default()
.push(limit); .push(limit);
} }
grants grants
.into_iter() .into_iter()
.map(|(basic, specific)| { .map(|(basic, specific)| {
let volume_limits: Vec<VolumeRateLimit> = limits_by_grant let volume_limits: Vec<VolumeRateLimit> = limits_by_grant
.get(&specific.id) .get(&specific.id)
.map(Vec::as_slice) .map(Vec::as_slice)
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.map(|row| { .map(|row| {
Ok(VolumeRateLimit { Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| { max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| {
diesel::result::Error::DeserializationError(Box::new(e)) diesel::result::Error::DeserializationError(Box::new(e))
})?, })?,
window: Duration::seconds(row.window_secs.into()), window: Duration::seconds(row.window_secs.into()),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
let token_contract: [u8; 20] = let token_contract: [u8; 20] =
specific.token_contract.clone().try_into().map_err(|_| { specific.token_contract.clone().try_into().map_err(|_| {
diesel::result::Error::DeserializationError( diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(), "Invalid token contract address length".into(),
) )
})?; })?;
let target: Option<Address> = match &specific.receiver { let target: Option<Address> = match &specific.receiver {
None => None, None => None,
Some(bytes) => { Some(bytes) => {
let arr: [u8; 20] = bytes.clone().try_into().map_err(|_| { let arr: [u8; 20] = bytes.clone().try_into().map_err(|_| {
diesel::result::Error::DeserializationError( diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(), "Invalid receiver address length".into(),
) )
})?; })?;
Some(Address::from(arr)) Some(Address::from(arr))
} }
}; };
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
common_settings_id: specific.basic_grant_id, common_settings_id: specific.basic_grant_id,
settings: CombinedSettings { settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?, shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings { specific: Settings {
token_contract: Address::from(token_contract), token_contract: Address::from(token_contract),
target, target,
volume_limits, volume_limits,
}, },
}, },
}) })
}) })
.collect() .collect()
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

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