72 Commits

Author SHA1 Message Date
CleverWild
41b3fc5d39 fix(lints): remove unstable ones
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-10 01:00:21 +02:00
CleverWild
f6a0c32b9d feat: rustc and clippy linting
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-10 00:42:43 +02:00
62dff3f810 Merge pull request 'refactor(hashing): introduce Hashable derive macro and migrate server types' (#82) from hashing-proc-macro into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #82
Reviewed-by: Stas <business@jexter.tech>
2026-04-08 00:18:40 +00:00
CleverWild
6e22f368c9 refactor(hashing): introduce Hashable derive macro and migrate server types
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-08 01:32:59 +02:00
f3cf6a9438 Merge pull request 'Post-quantum crypto and better useragent security' (#80) from push-xrxykvkuxpsv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #80
2026-04-07 19:26:54 +00:00
hdbg
a9f9fc2a9d housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 16:28:47 +02:00
hdbg
d22ab49e3d refactor(server): moved shared module crypto into arbiter-crypto 2026-04-07 16:24:51 +02:00
hdbg
a845181ef6 docs: ml-dsa scheme everywhere
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 15:02:32 +02:00
hdbg
0d424f3afc refactor(server): migrated auth to ml-dsa 2026-04-07 14:55:31 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
hdbg
00745bb381 tests(server): fixed for new integrity checks
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 14:49:02 +02:00
hdbg
b122aa464c refactor(server): rework envelopes and integrity check
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-05 14:17:00 +02:00
hdbg
9fab945a00 fix(server): remove stale mentions of miette
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:45:24 +02:00
CleverWild
aeed664e9a chore: inline integrity proto types
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:44:21 +02:00
CleverWild
4057c1fc12 feat(server): integrity envelope engine for EVM grants with HMAC verification 2026-04-05 10:44:21 +02:00
hdbg
f5eb51978d docs: add recovery operators and multi-operator details 2026-04-05 08:27:24 +00:00
hdbg
d997e0f843 docs: add multi-operator governance section 2026-04-05 08:27:24 +00:00
hdbg
7aca281a81 merge: @main into client-integrity-verification
Some checks failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/useragent-analyze Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
2026-04-05 10:25:46 +02:00
0daad1dd37 Merge branch 'main' into push-zmyvyloztluy
Some checks failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-04-05 07:57:31 +00:00
9ea474e1b2 fix(server): use LOCALHOST const instead of hard-coded ip value
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 14:14:15 +00:00
CleverWild
c6f440fdad fix(client): evm-feature's code for new proto
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-04 14:10:44 +00:00
e17c25a604 ci(server-test): ensure that all features are compiling
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
2026-04-04 14:06:02 +00:00
hdbg
01b12515bd housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:33:48 +02:00
hdbg
4a50daa7ea refactor(user-agent): remove backfill pubkey integrity tags
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:32:00 +02:00
hdbg
352ee3ee63 fix(server): previously, user agent auth accepted invalid signatures
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:28:07 +02:00
hdbg
dd51d756da refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-04-04 14:21:52 +02:00
CleverWild
0bb6e596ac feat(auth): implement attestation status verification for public keys
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 12:10:45 +02:00
hdbg
083ff66af2 refactor(server): removed miette out of server
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 12:10:34 +02:00
CleverWild
881f16bb1a fix(keyholder): comment drift 2026-04-04 12:02:50 +02:00
CleverWild
78895bca5b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-04-04 12:00:39 +02:00
1495fbe754 Merge pull request 'refactor(protocol): split into domain-based nesting' (#45) from push-zwvktknttnmw into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #45
2026-04-04 08:24:16 +00:00
ab8cf877d7 Merge branch 'main' into push-zwvktknttnmw
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 20:34:37 +00:00
hdbg
146f7a419e housekeeping: updated docs to match current impl state 2026-04-03 22:26:25 +02:00
hdbg
0362044b83 housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 22:20:07 +02:00
72618c186f Merge pull request 'feat(evm): implement EVM sign transaction handling in client and user agent' (#38) from feat--self-signed-transactions into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #38
Reviewed-by: Stas <business@jexter.tech>
2026-04-03 22:20:07 +02:00
hdbg
e47ccc3108 fix(useragent): upgraded to new protocol changes 2026-04-03 22:20:07 +02:00
90d8ae3c6c Merge pull request 'fix-security' (#42) from fix-security into main
Reviewed-on: #42
Reviewed-by: Stas <business@jexter.tech>
2026-04-03 22:20:07 +02:00
4af172e49a Merge branch 'main' into feat--self-signed-transactions 2026-04-03 22:20:07 +02:00
hdbg
bc45b9b9ce merge: @main into refactor-proto 2026-04-03 22:20:07 +02:00
CleverWild
5bce9fd68e chore: bump mise deps 2026-04-03 22:20:07 +02:00
CleverWild
63a4875fdb fix(keyholder): remove dead overwritten select in try_unseal query 2026-04-03 22:20:07 +02:00
hdbg
d5ec303b9a merge: main 2026-04-03 22:20:07 +02:00
hdbg
82b5b85f52 refactor(proto): nest client protocol and extract shared schemas 2026-04-03 22:20:07 +02:00
hdbg
e2d8b7841b style(dashboard): format code and add title margin 2026-04-03 22:20:07 +02:00
CleverWild
8feda7990c fix(auth): reject invalid challenge signatures instead of transitioning to AuthOk 2026-04-03 22:20:07 +02:00
hdbg
16f0e67d02 refactor(proto): scope client and user-agent schemas and extract shared types 2026-04-03 22:20:07 +02:00
hdbg
b5507e7d0f feat(grants-create): add configurable grant authorization fields 2026-04-03 22:20:07 +02:00
CleverWild
0388fa2c8b fix(server): enforce volumetric cap using past + current transfer value 2026-04-03 22:20:07 +02:00
hdbg
cfe01ba1ad refactor(server, protocol): split big message files into smaller and domain-based 2026-04-03 22:20:07 +02:00
hdbg
59c7091cba refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-04-03 22:20:07 +02:00
hdbg
523bf783ac refactor(grpc): extract user agent request handlers into separate functions 2026-04-03 22:20:07 +02:00
hdbg
643f251419 fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-04-03 22:20:07 +02:00
hdbg
bce6ecd409 refactor(grants): wrap grant list in SingleChildScrollView 2026-04-03 22:20:07 +02:00
hdbg
f32728a277 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-04-03 22:20:07 +02:00
hdbg
32743741e1 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-04-03 22:20:07 +02:00
hdbg
54b2183be5 feat(evm): add EVM grants screen with create UI and list 2026-04-03 22:20:07 +02:00
hdbg
ca35b9fed7 refactor(proto): restructure wallet access messages for improved data organization 2026-04-03 22:20:07 +02:00
hdbg
27428f709a refactor(server::evm): removed repetetive errors and error variants 2026-04-03 22:20:07 +02:00
hdbg
78006e90f2 refactor(useragent::evm::table): broke down into more widgets 2026-04-03 22:20:07 +02:00
hdbg
29cc4d9e5b refactor(useragent::evm): moved out header into general widget 2026-04-03 22:20:07 +02:00
hdbg
7f8b9cc63e feat(useragent): vibe-coded access list 2026-04-03 22:20:07 +02:00
CleverWild
a02ef68a70 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-03-30 00:17:04 +02:00
CleverWild
6987e5f70f feat(evm): implement EVM sign transaction handling in client and user agent
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-03-26 19:57:48 +01:00
188 changed files with 13812 additions and 7636 deletions

View File

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

View File

@@ -100,6 +100,27 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
diesel migration run --migration-dir crates/arbiter-server/migrations diesel migration run --migration-dir crates/arbiter-server/migrations
``` ```
### Code Conventions
**`#[must_use]` Attribute:**
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
- Methods that return `bool` indicating success/failure or validation state
- Any function where ignoring the return value indicates a logic error
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
Example:
```rust
#[must_use]
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
// verification logic
}
```
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
## User Agent (Flutter + Rinf at `useragent/`) ## User Agent (Flutter + Rinf at `useragent/`)
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client. The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.

View File

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

@@ -100,6 +100,27 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
diesel migration run --migration-dir crates/arbiter-server/migrations diesel migration run --migration-dir crates/arbiter-server/migrations
``` ```
### Code Conventions
**`#[must_use]` Attribute:**
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
- Methods that return `bool` indicating success/failure or validation state
- Any function where ignoring the return value indicates a logic error
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
Example:
```rust
#[must_use]
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
// verification logic
}
```
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
## User Agent (Flutter + Rinf at `useragent/`) ## User Agent (Flutter + Rinf at `useragent/`)
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client. The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.

View File

@@ -67,7 +67,14 @@ The `program_client.nonce` column stores the **next usable nonce** — i.e. it i
## Cryptography ## Cryptography
### Authentication ### Authentication
- **Signature scheme:** ed25519 - **Client protocol:** ML-DSA
### User-Agent Authentication
User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
- **Supported schemes:** ML-DSA
- **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**
@@ -117,6 +124,52 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations. 4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume). 5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
The detailed branch structure is shown below:
```mermaid
flowchart TD
A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
B --> C{Wallet exists?}
C -- No --> Z1[Return wallet not found error]
C -- Yes --> D[Check SDK client wallet visibility]
D --> E{Wallet visible to SDK client?}
E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?}
G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility]
E -- Yes --> I[Classify transaction meaning]
H --> I
I --> J{Meaning supported?}
J -- No --> Z3[Return unsupported transaction error]
J -- Yes --> K[Find matching grant]
K --> L{Grant exists?}
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{User-agent decision}
O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]
P --> Q[Persist grant]
Q --> M
M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error]
R -- No --> S[Record transaction in logs]
S --> T[Produce signature]
T --> U[Return signature to SDK client]
note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
K -. uses .-> note2
M -. checks .-> note1
```
### Policy Trait ### Policy Trait
| Method | Purpose | | Method | Purpose |
@@ -148,7 +201,7 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
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`, etc.) 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.
@@ -171,7 +224,6 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
- **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.
- **Nonce management is not implemented.** The architecture lists nonce deduplication as a core responsibility, but no nonce tracking or enforcement exists yet.
--- ---
@@ -179,5 +231,5 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
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:** Using the `memsafe` crate as an interim solution - **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
- **Planned:** 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)

View File

@@ -8,10 +8,18 @@ backend = "aqua:ast-grep/ast-grep"
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-arm64-musl"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64"] [tools.ast-grep."platforms.linux-x64"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64-musl"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.macos-arm64"] [tools.ast-grep."platforms.macos-arm64"]
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620" checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
@@ -32,10 +40,6 @@ backend = "cargo:cargo-audit"
version = "0.13.9" version = "0.13.9"
backend = "cargo:cargo-edit" backend = "cargo:cargo-edit"
[[tools."cargo:cargo-features"]]
version = "1.0.0"
backend = "cargo:cargo-features"
[[tools."cargo:cargo-features-manager"]] [[tools."cargo:cargo-features-manager"]]
version = "0.11.1" version = "0.11.1"
backend = "cargo:cargo-features-manager" backend = "cargo:cargo-features-manager"
@@ -44,26 +48,22 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3" version = "1.46.3"
backend = "cargo:cargo-insta" backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]] [[tools."cargo:cargo-nextest"]]
version = "0.9.126" version = "0.9.126"
backend = "cargo:cargo-nextest" backend = "cargo:cargo-nextest"
[[tools."cargo:cargo-shear"]] [[tools."cargo:cargo-shear"]]
version = "1.9.1" version = "1.11.2"
backend = "cargo:cargo-shear" backend = "cargo:cargo-shear"
[[tools."cargo:cargo-vet"]] [[tools."cargo:cargo-vet"]]
version = "0.10.2" version = "0.10.2"
backend = "cargo:cargo-vet" backend = "cargo:cargo-vet"
[[tools."cargo:diesel-cli"]]
version = "2.3.6"
backend = "cargo:diesel-cli"
[tools."cargo:diesel-cli".options]
default-features = "false"
features = "sqlite,sqlite-bundled"
[[tools."cargo:diesel_cli"]] [[tools."cargo:diesel_cli"]]
version = "2.3.6" version = "2.3.6"
backend = "cargo:diesel_cli" backend = "cargo:diesel_cli"
@@ -72,10 +72,6 @@ backend = "cargo:diesel_cli"
default-features = "false" default-features = "false"
features = "sqlite,sqlite-bundled" features = "sqlite,sqlite-bundled"
[[tools."cargo:rinf_cli"]]
version = "8.9.1"
backend = "cargo:rinf_cli"
[[tools.flutter]] [[tools.flutter]]
version = "3.38.9-stable" version = "3.38.9-stable"
backend = "asdf:flutter" backend = "asdf:flutter"
@@ -88,10 +84,18 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
[tools.protoc."platforms.linux-arm64-musl"]
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
[tools.protoc."platforms.linux-x64"] [tools.protoc."platforms.linux-x64"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
[tools.protoc."platforms.linux-x64-musl"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
[tools.protoc."platforms.macos-arm64"] [tools.protoc."platforms.macos-arm64"]
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed" checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
@@ -109,24 +113,39 @@ version = "3.14.3"
backend = "core:python" backend = "core:python"
[tools.python."platforms.linux-arm64"] [tools.python."platforms.linux-arm64"]
checksum = "sha256:be0f4dc2932f762292b27d46ea7d3e8e66ddf3969a5eb0254a229015ed402625" checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"] [tools.python."platforms.linux-x64"]
checksum = "sha256:0a73413f89efd417871876c9accaab28a9d1e3cd6358fbfff171a38ec99302f0" checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"] [tools.python."platforms.macos-arm64"]
checksum = "sha256:4703cdf18b26798fde7b49b6b66149674c25f97127be6a10dbcf29309bdcdcdb" checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"] [tools.python."platforms.macos-x64"]
checksum = "sha256:76f1cc26e3d262eae8ca546a93e8bded10cf0323613f7e246fea2e10a8115eb7" checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"] [tools.python."platforms.windows-x64"]
checksum = "sha256:950c5f21a015c1bdd1337f233456df2470fab71e4d794407d27a84cb8b9909a0" checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]] [[tools.rust]]
version = "1.93.0" version = "1.93.0"

View File

@@ -12,11 +12,12 @@ protoc = "29.6"
python = "3.14.3" python = "3.14.3"
ast-grep = "0.42.0" ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9" "cargo:cargo-edit" = "0.13.9"
"cargo:cargo-mutants" = "27.0.0"
[tasks.codegen] [tasks.codegen]
sources = ['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/ protobufs/*.proto protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
''' '''

View File

@@ -2,63 +2,24 @@ syntax = "proto3";
package arbiter.client; package arbiter.client;
import "evm.proto"; import "client/auth.proto";
import "google/protobuf/empty.proto"; import "client/evm.proto";
import "client/vault.proto";
message ClientInfo {
string name = 1;
optional string description = 2;
optional string version = 3;
}
message AuthChallengeRequest {
bytes pubkey = 1;
ClientInfo client_info = 2;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message ClientRequest { message ClientRequest {
int32 request_id = 4; int32 request_id = 4;
oneof payload { oneof payload {
AuthChallengeRequest auth_challenge_request = 1; auth.Request auth = 1;
AuthChallengeSolution auth_challenge_solution = 2; vault.Request vault = 2;
google.protobuf.Empty query_vault_state = 3; evm.Request evm = 3;
} }
} }
message ClientResponse { message ClientResponse {
optional int32 request_id = 7; optional int32 request_id = 7;
oneof payload { oneof payload {
AuthChallenge auth_challenge = 1; auth.Response auth = 1;
AuthResult auth_result = 2; vault.Response vault = 2;
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 3; evm.Response evm = 3;
arbiter.evm.EvmAnalyzeTransactionResponse evm_analyze_transaction = 4;
VaultState vault_state = 6;
} }
} }

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ 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";
enum EvmError { enum EvmError {
EVM_ERROR_UNSPECIFIED = 0; EVM_ERROR_UNSPECIFIED = 0;
@@ -74,70 +75,6 @@ message SpecificGrant {
} }
} }
message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes
}
message TokenInfo {
string symbol = 1;
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3;
}
// Mirror of token_transfers::Meaning
message TokenTransferMeaning {
TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning {
oneof meaning {
EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2;
}
}
// --- Eval error types ---
message GasLimitExceededViolation {
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
}
message EvalViolation {
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
}
}
// Transaction was classified but no grant covers it
message NoMatchingGrantError {
SpecificMeaning meaning = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError {
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2;
}
// top-level error returned when transaction evaluation fails
message TransactionEvalError {
oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4;
}
}
// --- UserAgent grant management --- // --- UserAgent grant management ---
message EvmGrantCreateRequest { message EvmGrantCreateRequest {
SharedSettings shared = 1; SharedSettings shared = 1;
@@ -197,7 +134,7 @@ message EvmSignTransactionRequest {
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]
TransactionEvalError eval_error = 2; arbiter.shared.evm.TransactionEvalError eval_error = 2;
EvmError error = 3; EvmError error = 3;
} }
} }
@@ -209,8 +146,8 @@ message EvmAnalyzeTransactionRequest {
message EvmAnalyzeTransactionResponse { message EvmAnalyzeTransactionResponse {
oneof result { oneof result {
SpecificMeaning meaning = 1; arbiter.shared.evm.SpecificMeaning meaning = 1;
TransactionEvalError eval_error = 2; arbiter.shared.evm.TransactionEvalError eval_error = 2;
EvmError error = 3; EvmError error = 3;
} }
} }

View File

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

View File

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

View File

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

View File

@@ -2,198 +2,27 @@ syntax = "proto3";
package arbiter.user_agent; package arbiter.user_agent;
import "client.proto"; import "user_agent/auth.proto";
import "evm.proto"; import "user_agent/evm.proto";
import "google/protobuf/empty.proto"; import "user_agent/sdk_client.proto";
import "user_agent/vault/vault.proto";
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
// --- SDK client management ---
enum SdkClientError {
SDK_CLIENT_ERROR_UNSPECIFIED = 0;
SDK_CLIENT_ERROR_ALREADY_EXISTS = 1;
SDK_CLIENT_ERROR_NOT_FOUND = 2;
SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
SDK_CLIENT_ERROR_INTERNAL = 4;
}
message SdkClientRevokeRequest {
int32 client_id = 1;
}
message SdkClientEntry {
int32 id = 1;
bytes pubkey = 2;
arbiter.client.ClientInfo info = 3;
int32 created_at = 4;
}
message SdkClientList {
repeated SdkClientEntry clients = 1;
}
message SdkClientRevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
SdkClientError error = 2;
}
}
message SdkClientListResponse {
oneof result {
SdkClientList clients = 1;
SdkClientError error = 2;
}
}
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
KeyType key_type = 3;
}
message AuthChallenge {
int32 nonce = 2;
reserved 1;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message SdkClientConnectionRequest {
bytes pubkey = 1;
arbiter.client.ClientInfo info = 2;
}
message SdkClientConnectionResponse {
bool approved = 1;
bytes pubkey = 2;
}
message SdkClientConnectionCancel {
bytes pubkey = 1;
}
message WalletAccess {
int32 wallet_id = 1;
int32 sdk_client_id = 2;
}
message SdkClientWalletAccess {
int32 id = 1;
WalletAccess access = 2;
}
message SdkClientGrantWalletAccess {
repeated WalletAccess accesses = 1;
}
message SdkClientRevokeWalletAccess {
repeated int32 accesses = 1;
}
message ListWalletAccessResponse {
repeated SdkClientWalletAccess accesses = 1;
}
message UserAgentRequest { message UserAgentRequest {
int32 id = 16; int32 id = 16;
oneof payload { oneof payload {
AuthChallengeRequest auth_challenge_request = 1; auth.Request auth = 1;
AuthChallengeSolution auth_challenge_solution = 2; vault.Request vault = 2;
UnsealStart unseal_start = 3; evm.Request evm = 3;
UnsealEncryptedKey unseal_encrypted_key = 4; sdk_client.Request sdk_client = 4;
google.protobuf.Empty query_vault_state = 5;
google.protobuf.Empty evm_wallet_create = 6;
google.protobuf.Empty evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientRevokeRequest sdk_client_revoke = 12;
google.protobuf.Empty sdk_client_list = 13;
BootstrapEncryptedKey bootstrap_encrypted_key = 14;
SdkClientGrantWalletAccess grant_wallet_access = 15;
SdkClientRevokeWalletAccess revoke_wallet_access = 17;
google.protobuf.Empty list_wallet_access = 18;
} }
} }
message UserAgentResponse { message UserAgentResponse {
optional int32 id = 16; optional int32 id = 16;
oneof payload { oneof payload {
AuthChallenge auth_challenge = 1; auth.Response auth = 1;
AuthResult auth_result = 2; vault.Response vault = 2;
UnsealStartResponse unseal_start_response = 3; evm.Response evm = 3;
UnsealResult unseal_result = 4; sdk_client.Response sdk_client = 4;
VaultState vault_state = 5;
arbiter.evm.WalletCreateResponse evm_wallet_create = 6;
arbiter.evm.WalletListResponse evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
SdkClientConnectionRequest sdk_client_connection_request = 11;
SdkClientConnectionCancel sdk_client_connection_cancel = 12;
SdkClientRevokeResponse sdk_client_revoke_response = 13;
SdkClientListResponse sdk_client_list_response = 14;
BootstrapResult bootstrap_result = 15;
ListWalletAccessResponse list_wallet_access_response = 17;
} }
} }

View File

@@ -0,0 +1,48 @@
syntax = "proto3";
package arbiter.user_agent.auth;
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
KeyType key_type = 3;
}
message AuthChallenge {
int32 nonce = 1;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
server/.gitignore vendored Normal file
View File

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

949
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,42 +4,169 @@ members = [
] ]
resolver = "3" resolver = "3"
[workspace.lints.clippy]
disallowed-methods = "deny"
[workspace.dependencies] [workspace.dependencies]
tonic = { version = "0.14.5", features = [
"deflate",
"gzip",
"tls-connect-info",
"zstd",
] }
tracing = "0.1.44"
tokio = { version = "1.50.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
smlang = "0.8.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
thiserror = "2.0.18"
async-trait = "0.1.89"
futures = "0.3.32"
tokio-stream = { version = "0.1.18", features = ["full"] }
kameo = "0.19.2"
prost-types = { version = "0.14.3", features = ["chrono"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1"
rustls-pki-types = "1.14.0"
alloy = "1.7.3" alloy = "1.7.3"
rcgen = { version = "0.14.7", features = [ async-trait = "0.1.89"
"aws_lc_rs", base64 = "0.22.1"
"pem", chrono = { version = "0.4.44", features = ["serde"] }
"x509-parser", ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
"zeroize", futures = "0.3.32"
], default-features = false } hmac = "0.12.1"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = { version = "0.20.0", git = "https://github.com/tqwewe/kameo" } # hold this until new patch version is released
miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] }
mutants = "0.0.4"
prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] }
rand = "0.10.0"
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
rsa = { version = "0.9", features = ["sha2"] } rsa = { version = "0.9", features = ["sha2"] }
rstest = "0.26.1"
rustls = { version = "0.23.37", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls-pki-types = "1.14.0"
sha2 = "0.10" sha2 = "0.10"
smlang = "0.8.0"
spki = "0.7" spki = "0.7"
thiserror = "2.0.18"
tokio = { version = "1.50.0", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
tracing = "0.1.44"
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
[workspace.lints.rust]
missing_unsafe_on_extern = "deny"
unsafe_attr_outside_unsafe = "deny"
unsafe_op_in_unsafe_fn = "deny"
unstable_features = "deny"
deprecated_safe_2024 = "warn"
ffi_unwind_calls = "warn"
linker_messages = "warn"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
impl-trait-redundant-captures = "warn"
redundant_lifetimes = "warn"
single_use_lifetimes = "warn"
unused_lifetimes = "warn"
macro_use_extern_crate = "warn"
redundant_imports = "warn"
unused_import_braces = "warn"
unused_macro_rules = "warn"
unused_qualifications = "warn"
unit_bindings = "warn"
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!!
unnameable_types = "warn"
variant_size_differences = "warn"
[workspace.lints.clippy]
derive_partial_eq_without_eq = "allow"
future_not_send = "allow"
inconsistent_struct_constructor = "allow"
inline_always = "allow"
missing_errors_doc = "allow"
missing_fields_in_debug = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
# restriction lints
alloc_instead_of_core = "warn"
allow_attributes_without_reason = "warn"
as_conversions = "warn"
assertions_on_result_states = "warn"
cfg_not_test = "warn"
clone_on_ref_ptr = "warn"
cognitive_complexity = "warn"
create_dir = "warn"
dbg_macro = "warn"
decimal_literal_representation = "warn"
default_union_representation = "warn"
deref_by_slicing = "warn"
disallowed_script_idents = "warn"
doc_include_without_cfg = "warn"
empty_drop = "warn"
empty_enum_variants_with_brackets = "warn"
empty_structs_with_brackets = "warn"
error_impl_error = "warn"
exit = "warn"
filetype_is_file = "warn"
float_arithmetic = "warn"
float_cmp_const = "warn"
fn_to_numeric_cast_any = "warn"
get_unwrap = "warn"
if_then_some_else_none = "warn"
indexing_slicing = "warn"
infinite_loop = "warn"
inline_asm_x86_att_syntax = "warn"
inline_asm_x86_intel_syntax = "warn"
integer_division = "warn"
large_include_file = "warn"
lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn"
mem_forget = "warn"
missing_assert_message = "warn"
mixed_read_write_in_expression = "warn"
modulo_arithmetic = "warn"
multiple_unsafe_ops_per_block = "warn"
mutex_atomic = "warn"
mutex_integer = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
non_zero_suggestions = "warn"
pathbuf_init_then_push = "warn"
pointer_format = "warn"
precedence_bits = "warn"
pub_without_shorthand = "warn"
rc_buffer = "warn"
rc_mutex = "warn"
redundant_test_prefix = "warn"
redundant_type_annotations = "warn"
ref_patterns = "warn"
renamed_function_params = "warn"
rest_pat_in_fully_bound_structs = "warn"
return_and_then = "warn"
semicolon_inside_block = "warn"
str_to_string = "warn"
string_add = "warn"
string_lit_chars_any = "warn"
string_slice = "warn"
suspicious_xor_used_as_pow = "warn"
try_err = "warn"
undocumented_unsafe_blocks = "warn"
uninlined_format_args = "warn"
unnecessary_safety_comment = "warn"
unnecessary_safety_doc = "warn"
unnecessary_self_imports = "warn"
unneeded_field_pattern = "warn"
unused_result_ok = "warn"
verbose_file_reads = "warn"
# cargo lints
negative_feature_names = "warn"
redundant_feature_names = "warn"
wildcard_dependencies = "warn"
# ENABLE BY THE FIRST MAJOR VERSION!!
# todo = "warn"
# unimplemented = "warn"
# panic = "warn"
# panic_in_result_fn = "warn"
#
# cargo_common_metadata = "warn"
# multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
disallowed_methods = "deny"
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }

View File

@@ -7,3 +7,22 @@ disallowed-methods = [
{ 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-panic-in-tests = true
check-inconsistent-struct-field-initializers = true
suppress-restriction-lint-in-const = true
allow-renamed-params-for = [
"core::convert::From",
"core::convert::TryFrom",
"core::str::FromStr",
"kameo::actor::Actor",
]
module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"]
source-item-ordering = ["enum"]
trait-assoc-item-kinds-order = [
"const",
"type",
"fn",
] # community tested standard

View File

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

View File

@@ -1,12 +1,20 @@
use arbiter_crypto::authn::{CLIENT_CONTEXT, SigningKey, format_challenge};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, format_challenge, ClientMetadata,
proto::client::{ proto::{
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientInfo as ProtoClientInfo, client::{
ClientRequest, client_request::Payload as ClientRequestPayload, ClientRequest,
client_response::Payload as ClientResponsePayload, auth::{
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
AuthResult, request::Payload as AuthRequestPayload,
response::Payload as AuthResponsePayload,
},
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
shared::ClientInfo as ProtoClientInfo,
}, },
}; };
use ed25519_dalek::Signer as _;
use crate::{ use crate::{
storage::StorageError, storage::StorageError,
@@ -15,20 +23,20 @@ use crate::{
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum AuthError { pub enum AuthError {
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("Client approval denied by User Agent")] #[error("Client approval denied by User Agent")]
ApprovalDenied, ApprovalDenied,
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("No User Agents online to approve client")] #[error("No User Agents online to approve client")]
NoUserAgentsOnline, NoUserAgentsOnline,
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
#[error("Signing key storage error")] #[error("Signing key storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
} }
fn map_auth_result(code: i32) -> AuthError { fn map_auth_result(code: i32) -> AuthError {
@@ -46,21 +54,21 @@ fn map_auth_result(code: i32) -> AuthError {
async fn send_auth_challenge_request( async fn send_auth_challenge_request(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &ed25519_dalek::SigningKey, key: &SigningKey,
) -> std::result::Result<(), AuthError> { ) -> Result<(), AuthError> {
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id: next_request_id(), request_id: next_request_id(),
payload: Some(ClientRequestPayload::AuthChallengeRequest( payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
AuthChallengeRequest { payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
pubkey: key.verifying_key().to_bytes().to_vec(), 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)
@@ -68,7 +76,7 @@ async fn send_auth_challenge_request(
async fn receive_auth_challenge( async fn receive_auth_challenge(
transport: &mut ClientTransport, transport: &mut ClientTransport,
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, AuthError> { ) -> Result<AuthChallenge, AuthError> {
let response = transport let response = transport
.recv() .recv()
.await .await
@@ -76,58 +84,65 @@ async fn receive_auth_challenge(
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?; let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
match payload { match payload {
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), ClientResponsePayload::Auth(response) => match response.payload {
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
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: &ed25519_dalek::SigningKey, key: &SigningKey,
challenge: arbiter_proto::proto::client::AuthChallenge, challenge: AuthChallenge,
) -> std::result::Result<(), AuthError> { ) -> Result<(), AuthError> {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
let signature = key.sign(&challenge_payload).to_bytes().to_vec(); let signature = key
.sign_message(&challenge_payload, CLIENT_CONTEXT)
.map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes();
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id: next_request_id(), request_id: next_request_id(),
payload: Some(ClientRequestPayload::AuthChallengeSolution( payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
AuthChallengeSolution { signature }, payload: Some(AuthRequestPayload::ChallengeSolution(
)), AuthChallengeSolution { signature },
)),
})),
}) })
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse) .map_err(|_| AuthError::UnexpectedAuthResponse)
} }
async fn receive_auth_confirmation( async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
transport: &mut ClientTransport,
) -> std::result::Result<(), AuthError> {
let response = transport let response = transport
.recv() .recv()
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse)?; .map_err(|_| AuthError::UnexpectedAuthResponse)?;
let payload = response let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
.payload
.ok_or(AuthError::UnexpectedAuthResponse)?;
match payload { match payload {
ClientResponsePayload::AuthResult(result) ClientResponsePayload::Auth(response) => match response.payload {
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => Some(AuthResponsePayload::Result(result))
{ if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
Ok(()) {
} Ok(())
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), }
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
_ => Err(AuthError::UnexpectedAuthResponse),
},
_ => Err(AuthError::UnexpectedAuthResponse), _ => Err(AuthError::UnexpectedAuthResponse),
} }
} }
pub(crate) async fn authenticate( pub async fn authenticate(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &ed25519_dalek::SigningKey, key: &SigningKey,
) -> std::result::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?;

View File

@@ -1,9 +1,7 @@
use std::io::{self, Write}; use std::io::{self, Write};
use arbiter_client::ArbiterClient; use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl}; use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use tonic::ConnectError;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -23,8 +21,6 @@ async fn main() {
return; return;
} }
let url = match ArbiterUrl::try_from(input) { let url = match ArbiterUrl::try_from(input) {
Ok(url) => url, Ok(url) => url,
Err(err) => { Err(err) => {
@@ -33,16 +29,16 @@ async fn main() {
} }
}; };
println!("{:#?}", url); println!("{url:#?}");
let metadata = ClientMetadata { let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_string(), name: "arbiter-client test_connect".to_owned(),
description: Some("Manual connection smoke test".to_string()), description: Some("Manual connection smoke test".to_owned()),
version: Some(env!("CARGO_PKG_VERSION").to_string()), 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,45 +1,56 @@
use arbiter_proto::{ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl}; use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig; use tonic::transport::ClientTlsConfig;
use crate::{ use crate::{
StorageError, auth::{AuthError, authenticate}, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport} StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
}; };
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet; use crate::wallets::evm::ArbiterEvmWallet;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum ArbiterClientError {
#[error("gRPC error")] #[error("Authentication error")]
Grpc(#[from] tonic::Status), Authentication(#[from] AuthError),
#[error("Could not establish connection")] #[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error), Connection(#[from] tonic::transport::Error),
#[error("Invalid server URI")] #[error("gRPC error")]
InvalidUri(#[from] http::uri::InvalidUri), Grpc(#[from] tonic::Status),
#[error("Invalid CA certificate")] #[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error), InvalidCaCert(#[from] webpki::Error),
#[error("Authentication error")] #[error("Invalid server URI")]
Authentication(#[from] AuthError), InvalidUri(#[from] http::uri::InvalidUri),
#[error("Storage error")] #[error("Storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
} }
pub struct ArbiterClient { pub struct ArbiterClient {
#[allow(dead_code)] #[expect(
dead_code,
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(url: ArbiterUrl, metadata: ClientMetadata) -> Result<Self, Error> { pub async fn connect(
url: ArbiterUrl,
metadata: ClientMetadata,
) -> 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
} }
@@ -48,7 +59,7 @@ impl ArbiterClient {
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
storage: &S, storage: &S,
) -> Result<Self, Error> { ) -> 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
} }
@@ -56,15 +67,16 @@ impl ArbiterClient {
pub async fn connect_with_key( pub async fn connect_with_key(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
key: ed25519_dalek::SigningKey, key: SigningKey,
) -> Result<Self, Error> { ) -> 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 = tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))? let channel =
.tls_config(tls)? tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.connect() .tls_config(tls)?
.await?; .connect()
.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);
@@ -83,7 +95,8 @@ impl ArbiterClient {
} }
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, Error> { #[expect(clippy::unused_async, reason = "false positive")]
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

@@ -5,8 +5,8 @@ mod transport;
pub mod wallets; pub mod wallets;
pub use auth::AuthError; pub use auth::AuthError;
pub use client::{ArbiterClient, Error}; 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::ArbiterEvmWallet; pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};

View File

@@ -1,17 +1,18 @@
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("I/O error")]
Io(#[from] std::io::Error),
#[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")]
Io(#[from] std::io::Error),
} }
pub trait SigningKeyStorage { pub trait SigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>; fn load_or_create(&self) -> Result<SigningKey, StorageError>;
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -20,17 +21,17 @@ pub struct FileSigningKeyStorage {
} }
impl FileSigningKeyStorage { impl FileSigningKeyStorage {
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.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() -> std::result::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) -> std::result::Result<ed25519_dalek::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
@@ -39,12 +40,12 @@ impl FileSigningKeyStorage {
expected: 32, expected: 32,
actual: v.len(), actual: v.len(),
})?; })?;
Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) Ok(SigningKey::from_seed(raw))
} }
} }
impl SigningKeyStorage for FileSigningKeyStorage { impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::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)?;
} }
@@ -53,8 +54,8 @@ impl SigningKeyStorage for FileSigningKeyStorage {
return Self::read_key(&self.path); return Self::read_key(&self.path);
} }
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let key = SigningKey::generate();
let raw_key = key.to_bytes(); 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()
@@ -103,7 +104,7 @@ mod tests {
.load_or_create() .load_or_create()
.expect("second load_or_create should read same key"); .expect("second load_or_create should read same key");
assert_eq!(key_a.to_bytes(), key_b.to_bytes()); 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");
@@ -124,7 +125,7 @@ mod tests {
assert_eq!(expected, 32); assert_eq!(expected, 32);
assert_eq!(actual, 31); assert_eq!(actual, 31);
} }
other => 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,18 +1,16 @@
use arbiter_proto::proto::{ use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
client::{ClientRequest, ClientResponse},
};
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::atomic::{AtomicI32, Ordering};
use tokio::sync::mpsc; use tokio::sync::mpsc;
pub(crate) 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(crate) 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(crate) enum ClientSignError { pub enum ClientSignError {
#[error("Transport channel closed")] #[error("Transport channel closed")]
ChannelClosed, ChannelClosed,
@@ -20,7 +18,7 @@ pub(crate) enum ClientSignError {
ConnectionClosed, ConnectionClosed,
} }
pub(crate) 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>,
} }
@@ -29,20 +27,17 @@ impl ClientTransport {
pub(crate) async fn send( pub(crate) async fn send(
&mut self, &mut self,
request: ClientRequest, request: ClientRequest,
) -> std::result::Result<(), ClientSignError> { ) -> 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( pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> {
&mut self,
) -> std::result::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(ClientSignError::ConnectionClosed), Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed),
Err(_) => Err(ClientSignError::ConnectionClosed),
} }
} }
} }

View File

@@ -8,7 +8,49 @@ use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::transport::ClientTransport; use arbiter_proto::proto::{
client::{
ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
evm::{
self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload,
},
},
evm::{
EvmSignTransactionRequest,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::TransactionEvalError,
};
use crate::transport::{ClientTransport, next_request_id};
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
///
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
/// interpret the concrete policy evaluation failure instead of parsing strings.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ArbiterEvmSignTransactionError {
#[error("transaction rejected by policy: {0:?}")]
PolicyEval(TransactionEvalError),
}
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
type Error = ();
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
if let Error::Other(inner) = value
&& let Some(eval_error) = inner.downcast_ref()
{
Ok(eval_error)
} else {
Err(())
}
}
}
pub struct ArbiterEvmWallet { pub struct ArbiterEvmWallet {
transport: Arc<Mutex<ClientTransport>>, transport: Arc<Mutex<ClientTransport>>,
@@ -17,7 +59,11 @@ pub struct ArbiterEvmWallet {
} }
impl ArbiterEvmWallet { impl ArbiterEvmWallet {
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self { #[expect(
dead_code,
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 {
Self { Self {
transport, transport,
address, address,
@@ -25,11 +71,12 @@ impl ArbiterEvmWallet {
} }
} }
pub fn address(&self) -> Address { pub const fn address(&self) -> Address {
self.address self.address
} }
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self { #[must_use]
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
} }
@@ -79,11 +126,73 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
&self, &self,
tx: &mut dyn SignableTransaction<Signature>, tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> { ) -> Result<Signature> {
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)?; self.validate_chain_id(tx)?;
Err(Error::other( let mut transport = self.transport.lock().await;
"transaction signing is not supported by current arbiter.client protocol", let request_id = next_request_id();
)) let rlp_transaction = tx.encoded_for_signing();
transport
.send(ClientRequest {
request_id,
payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
payload: Some(EvmRequestPayload::SignTransaction(
EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
})),
})
.await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
let response = transport
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport);
if response.request_id != Some(request_id) {
return Err(Error::other(
"received mismatched response id for evm sign transaction",
));
}
let payload = response
.payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
}) = payload
else {
return Err(Error::other(
"unexpected response payload for evm sign transaction request",
));
};
let EvmResponsePayload::SignTransaction(response) = payload else {
return Err(Error::other(
"unexpected evm response payload for sign transaction request",
));
};
let result = response
.result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
match result {
EvmSignTransactionResult::Signature(signature) => {
Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server"))
}
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
)),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}"
))),
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,13 @@ tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3" hex = "0.4.3"
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost = "0.14.3" 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 = "0.22.1" base64.workspace = true
prost-types.workspace = true prost-types.workspace = true
tracing.workspace = true tracing.workspace = true
async-trait.workspace = true async-trait.workspace = true

View File

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

View File

@@ -7,7 +7,6 @@ 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,
@@ -106,7 +105,7 @@ mod tests {
#[rstest] #[rstest]
fn test_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>,

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use miette::Diagnostic;
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
@@ -12,8 +13,8 @@ const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> { pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng(); let rng: StdRng = make_rng();
let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold( let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
Default::default(), String::default(),
|mut accum, char| { |mut accum, char| {
accum += char.to_string().as_str(); accum += char.to_string().as_str();
accum accum
@@ -25,19 +26,16 @@ pub async fn generate_token() -> Result<String, std::io::Error> {
Ok(token) Ok(token)
} }
#[derive(Error, Debug, Diagnostic)] #[derive(Error, Debug)]
pub enum Error { pub enum BootstrappError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database))]
Database(#[from] db::PoolError), Database(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database_query))]
Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::io))]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
} }
#[derive(Actor)] #[derive(Actor)]
@@ -46,15 +44,15 @@ pub struct Bootstrapper {
} }
impl Bootstrapper { impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> { pub async fn new(db: &DatabasePool) -> Result<Self, BootstrappError> {
let mut conn = db.get().await?; let row_count: i64 = {
let mut conn = db.get().await?;
let row_count: i64 = schema::useragent_client::table schema::useragent_client::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?
};
drop(conn);
let token = if row_count == 0 { let token = if row_count == 0 {
let token = generate_token().await?; let token = generate_token().await?;
@@ -71,10 +69,13 @@ impl Bootstrapper {
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn is_correct_token(&self, token: String) -> bool { pub fn is_correct_token(&self, token: String) -> bool {
match &self.token { self.token.as_ref().is_some_and(|expected| {
Some(expected) => *expected == token, let expected_bytes = expected.as_bytes();
None => false, let token_bytes = token.as_bytes();
}
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
})
} }
#[message] #[message]

View File

@@ -1,5 +1,8 @@
use arbiter_crypto::authn::{self, CLIENT_CONTEXT};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, format_challenge, transport::{Bi, expect_message} ClientMetadata,
proto::client::auth::{AuthChallenge as ProtoAuthChallenge, AuthResult as ProtoAuthResult},
transport::{Bi, expect_message},
}; };
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{
@@ -7,15 +10,16 @@ use diesel::{
dsl::insert_into, update, dsl::insert_into, update,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey}; use kameo::{actor::ActorRef, error::SendError};
use kameo::error::SendError;
use tracing::error; use tracing::error;
use crate::{ use crate::{
actors::{ actors::{
client::{ClientConnection, ClientProfile}, client::{ClientConnection, ClientCredentials, ClientProfile},
flow_coordinator::{self, RequestClientApproval}, flow_coordinator::{self, RequestClientApproval},
keyholder::KeyHolder,
}, },
crypto::integrity::{self, AttestationStatus},
db::{ db::{
self, self,
models::{ProgramClientMetadata, SqliteTimestamp}, models::{ProgramClientMetadata, SqliteTimestamp},
@@ -24,25 +28,60 @@ use crate::{
}; };
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum ClientAuthError {
#[error("Database pool unavailable")]
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")] #[error("Client approval request failed")]
ApproveError(#[from] ApproveError), ApproveError(#[from] ApproveError),
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Database pool unavailable")]
DatabasePoolUnavailable,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Transport error")] #[error("Transport error")]
Transport, Transport,
} }
impl From<diesel::result::Error> for ClientAuthError {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::DatabaseOperationFailed
}
}
impl From<ClientAuthError> for arbiter_proto::proto::client::auth::AuthResult {
fn from(value: ClientAuthError) -> Self {
match value {
ClientAuthError::ApproveError(e) => match e {
ApproveError::Denied => Self::ApprovalDenied,
ApproveError::Internal => Self::Internal,
ApproveError::Upstream(flow_coordinator::ApprovalError::NoUserAgentsConnected) => {
Self::NoUserAgentsOnline
} // ApproveError::Upstream(_) => Self::Internal,
},
ClientAuthError::DatabaseOperationFailed
| ClientAuthError::DatabasePoolUnavailable
| ClientAuthError::IntegrityCheckFailed
| ClientAuthError::Transport => Self::Internal,
ClientAuthError::InvalidChallengeSolution => Self::InvalidSignature,
}
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError { pub enum ApproveError {
#[error("Internal error")]
Internal,
#[error("Client connection denied by user agents")] #[error("Client connection denied by user agents")]
Denied, Denied,
#[error("Internal error")]
Internal,
#[error("Upstream error: {0}")] #[error("Upstream error: {0}")]
Upstream(flow_coordinator::ApprovalError), Upstream(flow_coordinator::ApprovalError),
} }
@@ -50,74 +89,147 @@ pub enum ApproveError {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: VerifyingKey, pubkey: authn::PublicKey,
metadata: ClientMetadata, metadata: ClientMetadata,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: Signature, signature: authn::Signature,
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { pubkey: VerifyingKey, nonce: i32 }, AuthChallenge {
pubkey: authn::PublicKey,
nonce: i32,
},
AuthSuccess, AuthSuccess,
} }
pub struct ClientInfo { impl From<Outbound> for arbiter_proto::proto::client::auth::response::Payload {
pub id: i32, fn from(value: Outbound) -> Self {
pub current_nonce: i32, match value {
Outbound::AuthChallenge { pubkey, nonce } => Self::Challenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes(),
nonce,
}),
Outbound::AuthSuccess => Self::Result(ProtoAuthResult::Success.into()),
}
}
} }
/// Atomically reads and increments the nonce for a known client. /// Returns the current nonce and client ID for a registered client.
/// Returns `None` if the pubkey is not registered. /// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce( async fn get_current_nonce_and_id(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: &VerifyingKey, pubkey: &authn::PublicKey,
) -> Result<Option<ClientInfo>, Error> { ) -> Result<Option<(i32, i32)>, ClientAuthError> {
let pubkey_bytes = pubkey.as_bytes().to_vec(); let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
ClientAuthError::DatabasePoolUnavailable
})?;
program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
ClientAuthError::DatabaseOperationFailed
})
}
async fn verify_integrity(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &authn::PublicKey,
) -> Result<(), ClientAuthError> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
ClientAuthError::DatabasePoolUnavailable
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?.ok_or_else(|| {
error!("Client not found during integrity verification");
ClientAuthError::DatabaseOperationFailed
})?;
let attestation = integrity::verify_entity(
&mut db_conn,
keyholder,
&ClientCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
ClientAuthError::IntegrityCheckFailed
})?;
if attestation != AttestationStatus::Attested {
error!("Integrity attestation unavailable for client {id}");
return Err(ClientAuthError::IntegrityCheckFailed);
}
Ok(())
}
/// Atomically increments the nonce and re-signs the integrity envelope.
/// Returns the new nonce, which is used as the challenge nonce.
async fn create_nonce(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &authn::PublicKey,
) -> Result<i32, ClientAuthError> {
let pubkey_bytes = pubkey.to_bytes();
let pubkey = pubkey.clone();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable ClientAuthError::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(|conn| { conn.exclusive_transaction(|conn| {
let pubkey_bytes = pubkey_bytes.clone(); let keyholder = keyholder.clone();
let pubkey = pubkey.clone();
Box::pin(async move { Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table let (id, new_nonce): (i32, i32) = update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes)) .filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce)) .set(program_client::nonce.eq(program_client::nonce + 1))
.first::<(i32, i32)>(conn) .returning((program_client::id, program_client::nonce))
.await .get_result(conn)
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?; .await?;
Ok(Some(ClientInfo { integrity::sign_entity(
id: client_id, conn,
current_nonce, &keyholder,
})) &ClientCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity sign failed after nonce update");
ClientAuthError::DatabaseOperationFailed
})?;
Ok(new_nonce)
}) })
}) })
.await .await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
} }
async fn approve_new_client( async fn approve_new_client(
actors: &crate::actors::GlobalActors, actors: &crate::actors::GlobalActors,
profile: ClientProfile, profile: ClientProfile,
) -> Result<(), Error> { ) -> Result<(), ClientAuthError> {
let result = actors let result = actors
.flow_coordinator .flow_coordinator
.ask(RequestClientApproval { client: profile }) .ask(RequestClientApproval { client: profile })
@@ -125,73 +237,93 @@ async fn approve_new_client(
match result { match result {
Ok(true) => Ok(()), Ok(true) => Ok(()),
Ok(false) => Err(Error::ApproveError(ApproveError::Denied)), Ok(false) => Err(ClientAuthError::ApproveError(ApproveError::Denied)),
Err(SendError::HandlerError(e)) => { Err(SendError::HandlerError(e)) => {
error!(error = ?e, "Approval upstream error"); error!(error = ?e, "Approval upstream error");
Err(Error::ApproveError(ApproveError::Upstream(e))) Err(ClientAuthError::ApproveError(ApproveError::Upstream(e)))
} }
Err(e) => { Err(e) => {
error!(error = ?e, "Approval request to flow coordinator failed"); error!(error = ?e, "Approval request to flow coordinator failed");
Err(Error::ApproveError(ApproveError::Internal)) Err(ClientAuthError::ApproveError(ApproveError::Internal))
} }
} }
} }
async fn insert_client( async fn insert_client(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: &VerifyingKey, keyholder: &ActorRef<KeyHolder>,
pubkey: &authn::PublicKey,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<i32, Error> { ) -> Result<i32, ClientAuthError> {
use crate::db::schema::{client_metadata, program_client}; use crate::db::schema::client_metadata;
let pubkey = pubkey.clone();
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable ClientAuthError::DatabasePoolUnavailable
})?; })?;
let metadata_id = insert_into(client_metadata::table) conn.exclusive_transaction(|conn| {
.values(( let keyholder = keyholder.clone();
client_metadata::name.eq(&metadata.name), let pubkey = pubkey.clone();
client_metadata::description.eq(&metadata.description), Box::pin(async move {
client_metadata::version.eq(&metadata.version), const NONCE_START: i32 = 1;
))
.returning(client_metadata::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
let client_id = insert_into(program_client::table) let metadata_id = insert_into(client_metadata::table)
.values(( .values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()), client_metadata::name.eq(&metadata.name),
program_client::metadata_id.eq(metadata_id), client_metadata::description.eq(&metadata.description),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0 client_metadata::version.eq(&metadata.version),
)) ))
.on_conflict_do_nothing() .returning(client_metadata::id)
.returning(program_client::id) .get_result::<i32>(conn)
.get_result::<i32>(&mut conn) .await?;
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
Ok(client_id) let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.to_bytes()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(NONCE_START),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(conn)
.await?;
integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
ClientAuthError::DatabaseOperationFailed
})?;
Ok(client_id)
})
})
.await
} }
async fn sync_client_metadata( async fn sync_client_metadata(
db: &db::DatabasePool, db: &db::DatabasePool,
client_id: i32, client_id: i32,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<(), Error> { ) -> Result<(), ClientAuthError> {
use crate::db::schema::{client_metadata, client_metadata_history}; use crate::db::schema::{client_metadata, client_metadata_history};
let now = SqliteTimestamp(Utc::now()); let now = SqliteTimestamp(Utc::now());
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable ClientAuthError::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(|conn| { conn.exclusive_transaction(|conn| {
@@ -247,42 +379,43 @@ async fn sync_client_metadata(
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Database error"); error!(error = ?e, "Database error");
Error::DatabaseOperationFailed ClientAuthError::DatabaseOperationFailed
}) })
} }
async fn challenge_client<T>( async fn challenge_client<T>(
transport: &mut T, transport: &mut T,
pubkey: VerifyingKey, pubkey: authn::PublicKey,
nonce: i32, nonce: i32,
) -> Result<(), Error> ) -> Result<(), ClientAuthError>
where where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized, T: Bi<Inbound, Result<Outbound, ClientAuthError>> + ?Sized,
{ {
transport transport
.send(Ok(Outbound::AuthChallenge { pubkey, nonce })) .send(Ok(Outbound::AuthChallenge {
pubkey: pubkey.clone(),
nonce,
}))
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to send auth challenge"); error!(error = ?e, "Failed to send auth challenge");
Error::Transport ClientAuthError::Transport
})?; })?;
let signature = expect_message(transport, |req: Inbound| match req { let signature = expect_message(transport, |req: Inbound| match req {
Inbound::AuthChallengeSolution { signature } => Some(signature), Inbound::AuthChallengeSolution { signature } => Some(signature),
_ => None, Inbound::AuthChallengeRequest { .. } => None,
}) })
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to receive challenge solution"); error!(error = ?e, "Failed to receive challenge solution");
Error::Transport ClientAuthError::Transport
})?; })?;
let formatted = format_challenge(nonce, pubkey.as_bytes()); if !pubkey.verify(nonce, CLIENT_CONTEXT, &signature) {
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
error!("Challenge solution verification failed"); error!("Challenge solution verification failed");
Error::InvalidChallengeSolution return Err(ClientAuthError::InvalidChallengeSolution);
})?; }
Ok(()) Ok(())
} }
@@ -290,44 +423,40 @@ where
pub async fn authenticate<T>( pub async fn authenticate<T>(
props: &mut ClientConnection, props: &mut ClientConnection,
transport: &mut T, transport: &mut T,
) -> Result<VerifyingKey, Error> ) -> Result<i32, ClientAuthError>
where where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized, T: Bi<Inbound, Result<Outbound, ClientAuthError>> + Send + ?Sized,
{ {
let Some(Inbound::AuthChallengeRequest { pubkey, metadata }) = transport.recv().await else { let Some(Inbound::AuthChallengeRequest { pubkey, metadata }) = transport.recv().await else {
return Err(Error::Transport); return Err(ClientAuthError::Transport);
}; };
let info = match get_client_and_nonce(&props.db, &pubkey).await? { let client_id = if let Some((id, _)) = get_current_nonce_and_id(&props.db, &pubkey).await? {
Some(nonce) => nonce, verify_integrity(&props.db, &props.actors.key_holder, &pubkey).await?;
None => { id
approve_new_client( } else {
&props.actors, approve_new_client(
ClientProfile { &props.actors,
pubkey, ClientProfile {
metadata: metadata.clone(), pubkey: pubkey.clone(),
}, metadata: metadata.clone(),
) },
.await?; )
let client_id = insert_client(&props.db, &pubkey, &metadata).await?; .await?;
ClientInfo { insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
id: client_id,
current_nonce: 0,
}
}
}; };
sync_client_metadata(&props.db, info.id, &metadata).await?; sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
transport transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to send auth success"); error!(error = ?e, "Failed to send auth success");
Error::Transport ClientAuthError::Transport
})?; })?;
Ok(pubkey) Ok(client_id)
} }

View File

@@ -1,25 +1,37 @@
use arbiter_crypto::authn;
use arbiter_proto::{ClientMetadata, transport::Bi}; use arbiter_proto::{ClientMetadata, transport::Bi};
use kameo::actor::Spawn; use kameo::actor::Spawn;
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
actors::{GlobalActors, client::{ session::ClientSession}}, actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::Integrable,
db, db,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClientProfile { pub struct ClientProfile {
pub pubkey: ed25519_dalek::VerifyingKey, pub pubkey: authn::PublicKey,
pub metadata: ClientMetadata, pub metadata: ClientMetadata,
} }
#[derive(arbiter_macros::Hashable)]
pub struct ClientCredentials {
pub pubkey: authn::PublicKey,
pub nonce: i32,
}
impl Integrable for ClientCredentials {
const KIND: &'static str = "client_credentials";
}
pub struct ClientConnection { pub struct ClientConnection {
pub(crate) db: db::DatabasePool, pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors, pub(crate) actors: GlobalActors,
} }
impl ClientConnection { impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self { pub const fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors } Self { db, actors }
} }
} }
@@ -29,11 +41,13 @@ pub mod session;
pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T) pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
where where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized, T: Bi<auth::Inbound, Result<auth::Outbound, auth::ClientAuthError>> + Send + ?Sized,
{ {
match auth::authenticate(&mut props, transport).await { let fut = auth::authenticate(&mut props, transport);
Ok(_pubkey) => { println!("authenticate future size: {}", size_of_val(&fut));
ClientSession::spawn(ClientSession::new(props)); match fut.await {
Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id));
info!("Client authenticated, session started"); info!("Client authenticated, session started");
} }
Err(err) => { Err(err) => {

View File

@@ -1,46 +1,83 @@
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use tracing::error; use tracing::error;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{ use crate::{
actors::{ actors::{
GlobalActors, client::ClientConnection, flow_coordinator::RegisterClient, GlobalActors,
client::ClientConnection,
evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient,
keyholder::KeyHolderState, keyholder::KeyHolderState,
}, },
db, db,
evm::VetError,
}; };
pub struct ClientSession { pub struct ClientSession {
props: ClientConnection, props: ClientConnection,
client_id: i32,
} }
impl ClientSession { impl ClientSession {
pub(crate) fn new(props: ClientConnection) -> Self { pub(crate) const fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props } Self { props, client_id }
} }
} }
#[messages] #[messages]
impl ClientSession { impl ClientSession {
#[message] #[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> { pub(crate) async fn handle_query_vault_state(
&mut self,
) -> Result<KeyHolderState, ClientSessionError> {
use crate::actors::keyholder::GetState; use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await { let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state, Ok(state) => state,
Err(err) => { Err(err) => {
error!(?err, actor = "client", "keyholder.query.failed"); error!(?err, actor = "client", "keyholder.query.failed");
return Err(Error::Internal); return Err(ClientSessionError::Internal);
} }
}; };
Ok(vault_state) Ok(vault_state)
} }
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionRpcError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id: self.client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(kameo::error::SendError::HandlerError(SignTransactionError::Vet(vet_error))) => {
Err(SignTransactionRpcError::Vet(vet_error))
}
Err(err) => {
error!(?err, "Failed to sign EVM transaction in client session");
Err(SignTransactionRpcError::Internal)
}
}
}
} }
impl Actor for ClientSession { impl Actor for ClientSession {
type Args = Self; type Args = Self;
type Error = Error; type Error = ClientSessionError;
async fn on_start( async fn on_start(
args: Self::Args, args: Self::Args,
@@ -51,22 +88,34 @@ impl Actor for ClientSession {
.flow_coordinator .flow_coordinator
.ask(RegisterClient { actor: this }) .ask(RegisterClient { actor: this })
.await .await
.map_err(|_| Error::ConnectionRegistrationFailed)?; .map_err(|_| ClientSessionError::ConnectionRegistrationFailed)?;
Ok(args) Ok(args)
} }
} }
impl ClientSession { impl ClientSession {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self { pub const fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors); let props = ClientConnection::new(db, actors);
Self { props } Self {
props,
client_id: 0,
}
} }
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum ClientSessionError {
#[error("Connection registration failed")] #[error("Connection registration failed")]
ConnectionRegistrationFailed, ConnectionRegistrationFailed,
#[error("Internal error")] #[error("Internal error")]
Internal, Internal,
} }
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionRpcError {
#[error("Internal error")]
Internal,
#[error("Policy evaluation failed")]
Vet(#[from] VetError),
}

View File

@@ -1,4 +1,6 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; use alloy::{
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,
}; };
@@ -8,63 +10,58 @@ use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{ use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder}, actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity,
db::{ db::{
self, DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp}, models::{self},
schema, schema,
}, },
evm::{ evm::{
self, RunKind, self, ListError, RunKind,
policies::{ policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, },
}, },
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
pub use crate::evm::safe_signer; pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error)]
pub enum SignTransactionError { pub enum SignTransactionError {
#[error("Wallet not found")] #[error("Wallet not found")]
#[diagnostic(code(arbiter::evm::sign::wallet_not_found))]
WalletNotFound, WalletNotFound,
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Keyholder error: {0}")] #[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::sign::keyholder))] Keyholder(#[from] crate::actors::keyholder::KeyHolderError),
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")] #[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::sign::keyholder_send))]
KeyholderSend, KeyholderSend,
#[error("Signing error: {0}")] #[error("Signing error: {0}")]
#[diagnostic(code(arbiter::evm::sign::signing))]
Signing(#[from] alloy::signers::Error), Signing(#[from] alloy::signers::Error),
#[error("Policy error: {0}")] #[error("Policy error: {0}")]
#[diagnostic(code(arbiter::evm::sign::vet))]
Vet(#[from] evm::VetError), Vet(#[from] evm::VetError),
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum EvmActorError {
#[error("Keyholder error: {0}")] #[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::keyholder))] Keyholder(#[from] crate::actors::keyholder::KeyHolderError),
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")] #[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend, KeyholderSend,
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::IntegrityError),
} }
#[derive(Actor)] #[derive(Actor)]
@@ -80,7 +77,7 @@ impl EvmActor {
// is it safe to seed rng from system once? // is it safe to seed rng from system once?
// todo: audit // todo: audit
let rng = StdRng::from_rng(&mut rng()); let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone()); let engine = evm::Engine::new(db.clone(), keyholder.clone());
Self { Self {
keyholder, keyholder,
db, db,
@@ -93,7 +90,7 @@ impl EvmActor {
#[messages] #[messages]
impl EvmActor { impl EvmActor {
#[message] #[message]
pub async fn generate(&mut self) -> Result<(i32, Address), Error> { pub async fn generate(&mut self) -> Result<(i32, Address), EvmActorError> {
let (mut key_cell, address) = safe_signer::generate(&mut self.rng); let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
@@ -102,7 +99,7 @@ impl EvmActor {
.keyholder .keyholder
.ask(CreateNew { plaintext }) .ask(CreateNew { plaintext })
.await .await
.map_err(|_| Error::KeyholderSend)?; .map_err(|_| EvmActorError::KeyholderSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table) let wallet_id = insert_into(schema::evm_wallet::table)
@@ -119,7 +116,7 @@ impl EvmActor {
} }
#[message] #[message]
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> { pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, EvmActorError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select()) .select(models::EvmWallet::as_select())
@@ -141,46 +138,64 @@ impl EvmActor {
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
) -> Result<i32, DatabaseError> { ) -> Result<i32, EvmActorError> {
match grant { match grant {
SpecificGrant::EtherTransfer(settings) => { SpecificGrant::EtherTransfer(settings) => self
self.engine .engine
.create_grant::<EtherTransfer>(FullGrant { .create_grant::<EtherTransfer>(CombinedSettings {
basic, shared: basic,
specific: settings, specific: settings,
}) })
.await .await
} .map_err(EvmActorError::from),
SpecificGrant::TokenTransfer(settings) => { SpecificGrant::TokenTransfer(settings) => self
self.engine .engine
.create_grant::<TokenTransfer>(FullGrant { .create_grant::<TokenTransfer>(CombinedSettings {
basic, shared: basic,
specific: settings, specific: settings,
}) })
.await .await
} .map_err(EvmActorError::from),
} }
} }
#[message] #[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { #[expect(clippy::unused_async, reason = "reserved for impl")]
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), EvmActorError> {
diesel::update(schema::evm_basic_grant::table) // let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
.filter(schema::evm_basic_grant::id.eq(grant_id)) // let keyholder = self.keyholder.clone();
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(&mut conn) // diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
.await // Box::pin(async move {
.map_err(DatabaseError::from)?; // diesel::update(schema::evm_basic_grant::table)
Ok(()) // .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
} }
#[message] #[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(
Ok(self &mut self,
.engine ) -> Result<Vec<Grant<SpecificGrant>>, EvmActorError> {
.list_all_grants() match self.engine.list_all_grants().await {
.await Ok(grants) => Ok(grants),
.map_err(DatabaseError::from)?) Err(ListError::Database(db_err)) => Err(EvmActorError::Database(db_err)),
Err(ListError::Integrity(integrity_err)) => {
Err(EvmActorError::Integrity(integrity_err))
}
}
} }
#[message] #[message]
@@ -259,7 +274,6 @@ impl EvmActor {
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution) .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?; .await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?) Ok(signer.sign_transaction_sync(&mut transaction)?)
} }
} }

View File

@@ -15,7 +15,7 @@ use crate::actors::{
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
pub user_agents: Vec<ActorRef<UserAgentSession>>, pub user_agents: Vec<ActorRef<UserAgentSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>> pub reply: ReplySender<Result<bool, ApprovalError>>,
} }
pub struct ClientApprovalController { pub struct ClientApprovalController {
@@ -39,7 +39,11 @@ impl Actor for ClientApprovalController {
type Error = (); type Error = ();
async fn on_start( async fn on_start(
Args { client, mut user_agents, reply }: Self::Args, Args {
client,
user_agents,
reply,
}: Self::Args,
actor_ref: ActorRef<Self>, actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let this = Self { let this = Self {
@@ -48,8 +52,9 @@ impl Actor for ClientApprovalController {
reply: Some(reply), reply: Some(reply),
}; };
for user_agent in user_agents.drain(..) { for user_agent in user_agents {
actor_ref.link(&user_agent).await; actor_ref.link(&user_agent).await;
let _ = user_agent let _ = user_agent
.tell(BeginNewClientApproval { .tell(BeginNewClientApproval {
client: client.clone(), client: client.clone(),
@@ -81,7 +86,7 @@ impl Actor for ClientApprovalController {
#[messages] #[messages]
impl ClientApprovalController { impl ClientApprovalController {
#[message(ctx)] #[message(ctx)]
pub async 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));

View File

@@ -92,7 +92,7 @@ impl FlowCoordinator {
} }
#[message(ctx)] #[message(ctx)]
pub async fn request_client_approval( pub fn request_client_approval(
&mut self, &mut self,
client: ClientProfile, client: ClientProfile,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>, ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,

View File

@@ -1,243 +0,0 @@
use std::ops::Deref as _;
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt};
use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce([u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
fn from(value: SafeCell<Key>) -> Self {
Self(value)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
let value = value.read();
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
}
}
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(key_buffer);
});
key.into()
}
pub fn encrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
}
pub fn decrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut();
cipher.decrypt_in_place(nonce, associated_data, buffer)
}
pub fn encrypt(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let mut cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
nonce,
Payload {
msg: plaintext.as_ref(),
aad: associated_data,
},
)?;
Ok(ciphertext)
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
#[allow(
clippy::unwrap_used,
reason = "Better fail completely than return a weak key"
)]
hasher
.hash_password_into(password_source.deref(), salt, key_buffer)
.unwrap();
});
key.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safe_cell::SafeCell;
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_seal_key(password, &salt);
let mut key2 = derive_seal_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test]
pub fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();
key.encrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
assert_ne!(buffer, b"secret data");
let mut buffer = SafeCell::new(buffer);
key.decrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
let buffer = buffer.read();
assert_eq!(*buffer, b"secret data");
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -4,22 +4,22 @@ use diesel::{
dsl::{insert_into, update}, dsl::{insert_into, update},
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use kameo::{Actor, Reply, messages}; use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
use crate::safe_cell::SafeCell; use crate::crypto::{
use crate::{ KeyCell, derive_key,
db::{ encryption::v1::{self, Nonce},
self, integrity::v1::HmacSha256,
models::{self, RootKeyHistory},
schema::{self},
},
safe_cell::SafeCellHandle as _,
}; };
use encryption::v1::{self, KeyCell, Nonce}; use crate::db::{
self,
pub mod encryption; models::{self, RootKeyHistory},
schema::{self},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[derive(Default, EnumDiscriminants)] #[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))] #[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -35,40 +35,35 @@ enum State {
}, },
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum KeyHolderError {
#[error("Keyholder is already bootstrapped")] #[error("Keyholder is already bootstrapped")]
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))]
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))]
NotBootstrapped,
#[error("Invalid key provided")]
#[diagnostic(code(arbiter::keyholder::invalid_key))]
InvalidKey,
#[error("Requested aead entry not found")] #[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))] BrokenDatabase,
NotFound,
#[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))]
DatabaseConnection(#[from] db::PoolError), DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")] #[error("Database transaction error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))]
DatabaseTransaction(#[from] diesel::result::Error), DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")] #[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::broken_database))] Encryption(#[from] chacha20poly1305::aead::Error),
BrokenDatabase,
#[error("Invalid key provided")]
InvalidKey,
#[error("Keyholder is not bootstrapped")]
NotBootstrapped,
#[error("Requested aead entry not found")]
NotFound,
} }
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor. /// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)] #[derive(Actor)]
@@ -79,7 +74,7 @@ pub struct KeyHolder {
#[messages] #[messages]
impl KeyHolder { impl KeyHolder {
pub async fn new(db: db::DatabasePool) -> Result<Self, Error> { pub async fn new(db: db::DatabasePool) -> Result<Self, KeyHolderError> {
let state = { let state = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
@@ -102,7 +97,10 @@ impl KeyHolder {
// Exclusive transaction to avoid race condtions if multiple keyholders write // Exclusive transaction to avoid race condtions if multiple keyholders write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> { async fn get_new_nonce(
pool: &db::DatabasePool,
root_key_id: i32,
) -> Result<Nonce, KeyHolderError> {
let mut conn = pool.get().await?; let mut conn = pool.get().await?;
let nonce = conn let nonce = conn
@@ -114,14 +112,13 @@ impl KeyHolder {
.first(conn) .first(conn)
.await?; .await?;
let mut nonce = let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| { error!(
error!( "Broken database: invalid nonce for root key history id={}",
"Broken database: invalid nonce for root key history id={}", root_key_id
root_key_id );
); KeyHolderError::BrokenDatabase
Error::BrokenDatabase })?;
})?;
nonce.increment(); nonce.increment();
update(schema::root_key_history::table) update(schema::root_key_history::table)
@@ -130,7 +127,7 @@ impl KeyHolder {
.execute(conn) .execute(conn)
.await?; .await?;
Result::<_, Error>::Ok(nonce) Result::<_, KeyHolderError>::Ok(nonce)
}) })
}) })
.await?; .await?;
@@ -139,17 +136,20 @@ impl KeyHolder {
} }
#[message] #[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn bootstrap(
&mut self,
seal_key_raw: SafeCell<Vec<u8>>,
) -> Result<(), KeyHolderError> {
if !matches!(self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); return Err(KeyHolderError::AlreadyBootstrapped);
} }
let salt = v1::generate_salt(); let salt = v1::generate_salt();
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt); let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = v1::Nonce::default(); let root_key_nonce = Nonce::default();
let data_encryption_nonce = v1::Nonce::default(); let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice(); let root_key_reader = reader.as_slice();
@@ -157,7 +157,7 @@ impl KeyHolder {
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader) .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.map_err(|err| { .map_err(|err| {
error!(?err, "Fatal bootstrap error"); error!(?err, "Fatal bootstrap error");
Error::Encryption(err) KeyHolderError::Encryption(err)
}) })
})?; })?;
@@ -201,12 +201,15 @@ impl KeyHolder {
} }
#[message] #[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn try_unseal(
&mut self,
seal_key_raw: SafeCell<Vec<u8>>,
) -> Result<(), KeyHolderError> {
let State::Sealed { let State::Sealed {
root_key_history_id, root_key_history_id,
} = &self.state } = &self.state
else { else {
return Err(Error::NotBootstrapped); return Err(KeyHolderError::NotBootstrapped);
}; };
// We don't want to hold connection while doing expensive KDF work // We don't want to hold connection while doing expensive KDF work
@@ -214,7 +217,6 @@ impl KeyHolder {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
schema::root_key_history::table schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id)) .filter(schema::root_key_history::id.eq(*root_key_history_id))
.select(schema::root_key_history::data_encryption_nonce)
.select(RootKeyHistory::as_select()) .select(RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await? .await?
@@ -223,16 +225,16 @@ impl KeyHolder {
let salt = &current_key.salt; let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| { let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
error!("Broken database: invalid salt for root key"); error!("Broken database: invalid salt for root key");
Error::BrokenDatabase KeyHolderError::BrokenDatabase
})?; })?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt); let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err( let nonce = Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
|_| { |()| {
error!("Broken database: invalid nonce for root key"); error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase KeyHolderError::BrokenDatabase
}, },
)?; )?;
@@ -240,14 +242,14 @@ impl KeyHolder {
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.map_err(|err| { .map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key"); error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey KeyHolderError::InvalidKey
})?; })?;
self.state = State::Unsealed { self.state = State::Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: v1::KeyCell::try_from(root_key).map_err(|err| { root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size"); error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase KeyHolderError::BrokenDatabase
})?, })?,
}; };
@@ -256,11 +258,10 @@ impl KeyHolder {
Ok(()) Ok(())
} }
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, KeyHolderError> {
let State::Unsealed { root_key, .. } = &mut self.state else { let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped); return Err(KeyHolderError::NotBootstrapped);
}; };
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
@@ -271,15 +272,15 @@ impl KeyHolder {
.first(&mut conn) .first(&mut conn)
.await .await
.optional()? .optional()?
.ok_or(Error::NotFound)? .ok_or(KeyHolderError::NotFound)?
}; };
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| { let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
error!( error!(
"Broken database: invalid nonce for aead_encrypted id={}", "Broken database: invalid nonce for aead_encrypted id={}",
aead_id aead_id
); );
Error::BrokenDatabase KeyHolderError::BrokenDatabase
})?; })?;
let mut output = SafeCell::new(row.ciphertext); let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?; root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
@@ -288,13 +289,17 @@ impl KeyHolder {
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { pub async fn create_new(
&mut self,
mut plaintext: SafeCell<Vec<u8>>,
) -> Result<i32, KeyHolderError> {
let State::Unsealed { let State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
..
} = &mut self.state } = &mut self.state
else { else {
return Err(Error::NotBootstrapped); return Err(KeyHolderError::NotBootstrapped);
}; };
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire // Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
@@ -330,13 +335,63 @@ impl KeyHolder {
} }
#[message] #[message]
pub fn seal(&mut self) -> Result<(), Error> { pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), KeyHolderError> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(KeyHolderError::NotBootstrapped);
};
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
Ok((*root_key_history_id, mac))
}
#[message]
pub fn verify_integrity(
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
) -> Result<bool, KeyHolderError> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(KeyHolderError::NotBootstrapped);
};
if *root_key_history_id != key_version {
return Ok(false);
}
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message]
pub fn seal(&mut self) -> Result<(), KeyHolderError> {
let State::Unsealed { let State::Unsealed {
root_key_history_id, root_key_history_id,
.. ..
} = &self.state } = &self.state
else { else {
return Err(Error::NotBootstrapped); return Err(KeyHolderError::NotBootstrapped);
}; };
self.state = State::Sealed { self.state = State::Sealed {
root_key_history_id: *root_key_history_id, root_key_history_id: *root_key_history_id,
@@ -347,14 +402,7 @@ impl KeyHolder {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use diesel::SelectableHelper; use arbiter_crypto::safecell::SafeCellHandle as _;
use diesel_async::RunQueryDsl;
use crate::{
db::{self},
safe_cell::SafeCell,
};
use super::*; use super::*;
@@ -370,12 +418,12 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state { let State::Unsealed {
State::Unsealed { root_key_history_id,
root_key_history_id, ..
.. } = actor.state
} => root_key_history_id, else {
_ => panic!("expected unsealed state"), panic!("expected unsealed state");
}; };
let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id) let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id)
@@ -387,8 +435,8 @@ mod tests {
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let root_row: models::RootKeyHistory = schema::root_key_history::table let root_row = schema::root_key_history::table
.select(models::RootKeyHistory::as_select()) .select(RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await .await
.unwrap(); .unwrap();

View File

@@ -1,5 +1,4 @@
use kameo::actor::{ActorRef, Spawn}; use kameo::actor::{ActorRef, Spawn};
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
@@ -12,20 +11,18 @@ use crate::{
pub mod bootstrap; pub mod bootstrap;
pub mod client; pub mod client;
mod evm; pub mod evm;
pub mod flow_coordinator; pub mod flow_coordinator;
pub mod keyholder; pub mod keyholder;
pub mod user_agent; pub mod user_agent;
#[derive(Error, Debug, Diagnostic)] #[derive(Error, Debug)]
pub enum SpawnError { pub enum GlobalActorsSpawnError {
#[error("Failed to spawn Bootstrapper actor")] #[error("Failed to spawn Bootstrapper actor")]
#[diagnostic(code(SpawnError::Bootstrapper))] Bootstrapper(#[from] bootstrap::BootstrappError),
Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn KeyHolder actor")] #[error("Failed to spawn KeyHolder actor")]
#[diagnostic(code(SpawnError::KeyHolder))] KeyHolder(#[from] keyholder::KeyHolderError),
KeyHolder(#[from] keyholder::Error),
} }
/// Long-lived actors that are shared across all connections and handle global state and operations /// Long-lived actors that are shared across all connections and handle global state and operations
@@ -38,7 +35,7 @@ pub struct GlobalActors {
} }
impl GlobalActors { impl GlobalActors {
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, GlobalActorsSpawnError> {
let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?); let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?);
Ok(Self { Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),

View File

@@ -1,18 +1,20 @@
use arbiter_crypto::authn;
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use tracing::error; use tracing::error;
use crate::actors::user_agent::{ use crate::actors::user_agent::{
AuthPublicKey, UserAgentConnection, UserAgentConnection,
auth::state::{AuthContext, AuthStateMachine}, auth::state::{AuthContext, AuthStateMachine},
}; };
mod state; mod state;
use state::*; use state::{
AuthError, AuthEvents, AuthStates, BootstrapAuthRequest, ChallengeRequest, ChallengeSolution,
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: AuthPublicKey, pubkey: authn::PublicKey,
bootstrap_token: Option<String>, bootstrap_token: Option<String>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
@@ -37,6 +39,13 @@ impl Error {
} }
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::internal("Database error")
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { nonce: i32 }, AuthChallenge { nonce: i32 },
@@ -64,7 +73,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
pub async fn authenticate<T>( pub async fn authenticate<T>(
props: &mut UserAgentConnection, props: &mut UserAgentConnection,
transport: T, transport: T,
) -> Result<AuthPublicKey, Error> ) -> Result<authn::PublicKey, Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + Send, T: Bi<Inbound, Result<Outbound, Error>> + Send,
{ {

View File

@@ -1,29 +1,33 @@
use arbiter_crypto::authn::{self, USERAGENT_CONTEXT};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use tracing::error; use tracing::error;
use super::Error; use super::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::ConsumeToken, bootstrap::ConsumeToken,
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, keyholder::KeyHolder,
user_agent::{UserAgentConnection, UserAgentCredentials, auth::Outbound},
}, },
db::schema, crypto::integrity,
db::{DatabasePool, schema::useragent_client},
}; };
pub struct ChallengeRequest { pub struct ChallengeRequest {
pub pubkey: AuthPublicKey, pub pubkey: authn::PublicKey,
} }
pub struct BootstrapAuthRequest { pub struct BootstrapAuthRequest {
pub pubkey: AuthPublicKey, pub pubkey: authn::PublicKey,
pub token: String, pub token: String,
} }
pub struct ChallengeContext { pub struct ChallengeContext {
pub challenge_nonce: i32, pub challenge_nonce: i32,
pub key: AuthPublicKey, pub key: authn::PublicKey,
} }
pub struct ChallengeSolution { pub struct ChallengeSolution {
@@ -35,12 +39,16 @@ smlang::statemachine!(
custom_error: true, custom_error: true,
transitions: { transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext), *Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey), Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(authn::PublicKey),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey), SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(authn::PublicKey),
} }
); );
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> { /// Returns the current nonce, ready to use for the challenge nonce.
async fn get_current_nonce_and_id(
db: &DatabasePool,
key: &authn::PublicKey,
) -> Result<(i32, i32), Error> {
let mut db_conn = db.get().await.map_err(|e| { let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
@@ -48,19 +56,11 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
db_conn db_conn
.exclusive_transaction(|conn| { .exclusive_transaction(|conn| {
Box::pin(async move { Box::pin(async move {
let current_nonce = schema::useragent_client::table useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) .filter(useragent_client::public_key.eq(key.to_bytes()))
.select(schema::useragent_client::nonce) .select((useragent_client::id, useragent_client::nonce))
.first::<i32>(conn) .first::<(i32, i32)>(conn)
.await?; .await
update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
}) })
}) })
.await .await
@@ -70,31 +70,130 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
Error::internal("Database operation failed") Error::internal("Database operation failed")
})? })?
.ok_or_else(|| { .ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database"); error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey Error::UnregisteredPublicKey
}) })
} }
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> { async fn verify_integrity(
let pubkey_bytes = pubkey.to_stored_bytes(); db: &DatabasePool,
let key_type = pubkey.key_type(); keyholder: &ActorRef<KeyHolder>,
pubkey: &authn::PublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let _result = integrity::verify_entity(
&mut db_conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::internal("Integrity verification failed")
})?;
Ok(())
}
async fn create_nonce(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &authn::PublicKey,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let new_nonce = db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
.returning((useragent_client::id, useragent_client::nonce))
.get_result(conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity signature update failed");
Error::internal("Database error")
})?;
Result::<_, Error>::Ok(new_nonce)
})
})
.await?;
Ok(new_nonce)
}
async fn register_key(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &authn::PublicKey,
) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
diesel::insert_into(schema::useragent_client::table) conn.transaction(|conn| {
.values(( Box::pin(async move {
schema::useragent_client::public_key.eq(pubkey_bytes), const NONCE_START: i32 = 1;
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type), let id: i32 = diesel::insert_into(useragent_client::table)
)) .values((
.execute(&mut conn) useragent_client::public_key.eq(pubkey_bytes),
.await useragent_client::nonce.eq(NONCE_START),
.map_err(|e| { ))
error!(error = ?e, "Database error"); .returning(useragent_client::id)
Error::internal("Database operation failed") .get_result(conn)
})?; .await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
let entity = UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
};
integrity::sign_entity(conn, keyholder, &entity, id)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new user-agent key");
Error::internal("Failed to register public key")
})?;
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(()) Ok(())
} }
@@ -105,14 +204,14 @@ pub struct AuthContext<'a, T> {
} }
impl<'a, T> AuthContext<'a, T> { impl<'a, T> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self { pub const fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self {
Self { conn, transport } Self { conn, transport }
} }
} }
impl<T> AuthStateMachineContext for AuthContext<'_, T> impl<T> AuthStateMachineContext for AuthContext<'_, T>
where where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send, T: Bi<super::Inbound, Result<Outbound, Error>> + Send,
{ {
type Error = Error; type Error = Error;
@@ -120,8 +219,9 @@ where
&mut self, &mut self,
ChallengeRequest { pubkey }: ChallengeRequest, ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> { ) -> Result<ChallengeContext, Self::Error> {
let stored_bytes = pubkey.to_stored_bytes(); verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport self.transport
.send(Ok(Outbound::AuthChallenge { nonce })) .send(Ok(Outbound::AuthChallenge { nonce }))
@@ -137,12 +237,10 @@ where
}) })
} }
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token( async fn verify_bootstrap_token(
&mut self, &mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest, BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthPublicKey, Self::Error> { ) -> Result<authn::PublicKey, Self::Error> {
let token_ok: bool = self let token_ok: bool = self
.conn .conn
.actors .actors
@@ -161,18 +259,23 @@ where
return Err(Error::InvalidBootstrapToken); return Err(Error::InvalidBootstrapToken);
} }
register_key(&self.conn.db, &pubkey).await?; if token_ok {
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport self.transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
.await .await
.map_err(|_| Error::Transport)?; .map_err(|_| Error::Transport)?;
Ok(pubkey)
Ok(pubkey) } else {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
} }
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution( async fn verify_solution(
&mut self, &mut self,
ChallengeContext { ChallengeContext {
@@ -180,43 +283,26 @@ where
key, key,
}: &ChallengeContext, }: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution, ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthPublicKey, Self::Error> { ) -> Result<authn::PublicKey, Self::Error> {
let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes()); let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
})?;
let valid = match key { let valid = key.verify(*challenge_nonce, USERAGENT_CONTEXT, &signature);
AuthPublicKey::Ed25519(vk) => {
let sig = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid Ed25519 signature length");
Error::InvalidChallengeSolution
})?;
vk.verify_strict(&formatted, &sig).is_ok()
}
AuthPublicKey::EcdsaSecp256k1(vk) => {
use k256::ecdsa::signature::Verifier as _;
let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid ECDSA signature bytes");
Error::InvalidChallengeSolution
})?;
vk.verify(&formatted, &sig).is_ok()
}
AuthPublicKey::Rsa(pk) => {
use rsa::signature::Verifier as _;
let verifying_key = rsa::pss::VerifyingKey::<sha2::Sha256>::new(pk.clone());
let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid RSA signature bytes");
Error::InvalidChallengeSolution
})?;
verifying_key.verify(&formatted, &sig).is_ok()
}
};
if valid { if valid {
self.transport self.transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
.await .await
.map_err(|_| Error::Transport)?; .map_err(|_| Error::Transport)?;
Ok(key.clone())
} else {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
} }
Ok(key.clone())
} }
} }

View File

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

View File

@@ -1,8 +1,9 @@
use arbiter_crypto::authn;
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use arbiter_proto::transport::Sender; use arbiter_proto::transport::Sender;
use async_trait::async_trait; use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
@@ -12,33 +13,32 @@ use crate::actors::{
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController}, flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController},
user_agent::{OutOfBand, UserAgentConnection}, user_agent::{OutOfBand, UserAgentConnection},
}; };
mod state; mod state;
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine}; use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum UserAgentSessionError {
#[error("State transition failed")]
State,
#[error("Internal error: {message}")] #[error("Internal error: {message}")]
Internal { message: Cow<'static, str> }, Internal { message: Cow<'static, str> },
#[error("State transition failed")]
State,
} }
impl From<crate::db::PoolError> for Error { impl From<crate::db::PoolError> for UserAgentSessionError {
fn from(err: crate::db::PoolError) -> Self { fn from(err: crate::db::PoolError) -> Self {
error!(?err, "Database pool error"); error!(?err, "Database pool error");
Self::internal("Database pool error") Self::internal("Database pool error")
} }
} }
impl From<diesel::result::Error> for Error { impl From<diesel::result::Error> for UserAgentSessionError {
fn from(err: diesel::result::Error) -> Self { fn from(err: diesel::result::Error) -> Self {
error!(?err, "Database error"); error!(?err, "Database error");
Self::internal("Database error") Self::internal("Database error")
} }
} }
impl Error { impl UserAgentSessionError {
pub fn internal(message: impl Into<Cow<'static, str>>) -> Self { pub fn internal(message: impl Into<Cow<'static, str>>) -> Self {
Self::Internal { Self::Internal {
message: message.into(), message: message.into(),
@@ -47,6 +47,7 @@ impl Error {
} }
pub struct PendingClientApproval { pub struct PendingClientApproval {
pubkey: authn::PublicKey,
controller: ActorRef<ClientApprovalController>, controller: ActorRef<ClientApprovalController>,
} }
@@ -55,7 +56,7 @@ pub struct UserAgentSession {
state: UserAgentStateMachine<DummyContext>, state: UserAgentStateMachine<DummyContext>,
sender: Box<dyn Sender<OutOfBand>>, sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<VerifyingKey, PendingClientApproval>, pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
} }
pub mod connection; pub mod connection;
@@ -66,7 +67,7 @@ impl UserAgentSession {
props, props,
state: UserAgentStateMachine::new(DummyContext), state: UserAgentStateMachine::new(DummyContext),
sender, sender,
pending_client_approvals: Default::default(), pending_client_approvals: HashMap::default(),
} }
} }
@@ -86,10 +87,10 @@ impl UserAgentSession {
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender)) Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
} }
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> { fn transition(&mut self, event: UserAgentEvents) -> Result<(), UserAgentSessionError> {
self.state.process_event(event).map_err(|e| { self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed"); error!(?e, "State transition failed");
Error::State UserAgentSessionError::State
})?; })?;
Ok(()) Ok(())
} }
@@ -118,19 +119,24 @@ impl UserAgentSession {
return; return;
} }
self.pending_client_approvals self.pending_client_approvals.insert(
.insert(client.pubkey, PendingClientApproval { controller }); client.pubkey.to_bytes(),
PendingClientApproval {
pubkey: client.pubkey,
controller,
},
);
} }
} }
impl Actor for UserAgentSession { impl Actor for UserAgentSession {
type Args = Self; type Args = Self;
type Error = Error; type Error = UserAgentSessionError;
async fn on_start( async fn on_start(
args: Self::Args, args: Self::Args,
this: kameo::prelude::ActorRef<Self>, this: ActorRef<Self>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
@@ -144,7 +150,9 @@ impl Actor for UserAgentSession {
?err, ?err,
"Failed to register user agent connection with flow coordinator" "Failed to register user agent connection with flow coordinator"
); );
Error::internal("Failed to register user agent connection with flow coordinator") UserAgentSessionError::internal(
"Failed to register user agent connection with flow coordinator",
)
})?; })?;
Ok(args) Ok(args)
} }
@@ -158,14 +166,18 @@ impl Actor for UserAgentSession {
let cancelled_pubkey = self let cancelled_pubkey = self
.pending_client_approvals .pending_client_approvals
.iter() .iter()
.find_map(|(k, v)| (v.controller.id() == id).then_some(*k)); .find_map(|(k, v)| (v.controller.id() == id).then_some(k.clone()));
if let Some(pubkey) = cancelled_pubkey { if let Some(pubkey_bytes) = cancelled_pubkey {
self.pending_client_approvals.remove(&pubkey); let Some(approval) = self.pending_client_approvals.remove(&pubkey_bytes) else {
return Ok(std::ops::ControlFlow::Continue(()));
};
if let Err(e) = self if let Err(e) = self
.sender .sender
.send(OutOfBand::ClientConnectionCancel { pubkey }) .send(OutOfBand::ClientConnectionCancel {
pubkey: approval.pubkey,
})
.await .await
{ {
error!( error!(

View File

@@ -1,44 +1,46 @@
use std::sync::Mutex; use std::sync::Mutex;
use alloy::primitives::Address; use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::sql_types::ops::Add; use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError; use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context; use kameo::prelude::Context;
use kameo::{message, messages}; use thiserror::Error;
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::{actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer, evm::policies::SharedGrantSettings};
use crate::actors::keyholder::KeyHolderState; use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error; use crate::actors::user_agent::session::UserAgentSessionError;
use crate::db::models::{ use crate::actors::{
CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata, evm::{
}; ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
use crate::db::schema::evm_wallet_access; UseragentCreateGrant, UseragentListGrants,
use crate::evm::policies::{Grant, SpecificGrant}; },
use crate::safe_cell::SafeCell; keyholder::{self, Bootstrap, TryUnseal},
use crate::{ user_agent::session::{
actors::{ UserAgentSession,
evm::{ state::{UnsealContext, UserAgentEvents, UserAgentStates},
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
}, },
safe_cell::SafeCellHandle as _,
}; };
use crate::db::models::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant};
impl UserAgentSession { impl UserAgentSession {
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> { fn take_unseal_secret(&self) -> Result<(EphemeralSecret, PublicKey), UserAgentSessionError> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else { let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state"); error!("Received encrypted key in invalid state");
return Err(Error::internal("Invalid state for unseal encrypted key")); return Err(UserAgentSessionError::internal(
"Invalid state for unseal encrypted key",
));
}; };
let ephemeral_secret = { let ephemeral_secret = {
@@ -48,13 +50,14 @@ impl UserAgentSession {
)] )]
let mut secret_lock = unseal_context.secret.lock().unwrap(); let mut secret_lock = unseal_context.secret.lock().unwrap();
let secret = secret_lock.take(); let secret = secret_lock.take();
match secret { if let Some(secret) = secret {
Some(secret) => secret, secret
None => { } else {
drop(secret_lock); drop(secret_lock);
error!("Ephemeral secret already taken"); error!("Ephemeral secret already taken");
return Err(Error::internal("Ephemeral secret already taken")); return Err(UserAgentSessionError::internal(
} "Ephemeral secret already taken",
));
} }
}; };
@@ -80,7 +83,7 @@ impl UserAgentSession {
}); });
match decryption_result { match decryption_result {
Ok(_) => Ok(key_buffer), Ok(()) => Ok(key_buffer),
Err(err) => { Err(err) => {
error!(?err, "Failed to decrypt encrypted key material"); error!(?err, "Failed to decrypt encrypted key material");
Err(()) Err(())
@@ -98,7 +101,7 @@ pub enum UnsealError {
#[error("Invalid key provided for unsealing")] #[error("Invalid key provided for unsealing")]
InvalidKey, InvalidKey,
#[error("Internal error during unsealing process")] #[error("Internal error during unsealing process")]
General(#[from] super::Error), General(#[from] UserAgentSessionError),
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -109,22 +112,40 @@ pub enum BootstrapError {
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Internal error during bootstrapping process")] #[error("Internal error during bootstrapping process")]
General(#[from] super::Error), General(#[from] UserAgentSessionError),
}
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
} }
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
pub async fn handle_unseal_request( pub fn handle_unseal_request(
&mut self, &mut self,
client_pubkey: x25519_dalek::PublicKey, client_pubkey: PublicKey,
) -> Result<UnsealStartResponse, Error> { ) -> Result<UnsealStartResponse, UserAgentSessionError> {
let secret = EphemeralSecret::random(); let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret); let public_key = PublicKey::from(&secret);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext { self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key: client_pubkey, client_public_key: client_pubkey,
secret: Mutex::new(Some(secret)),
}))?; }))?;
Ok(UnsealStartResponse { Ok(UnsealStartResponse {
@@ -141,27 +162,24 @@ impl UserAgentSession {
) -> Result<(), UnsealError> { ) -> Result<(), UnsealError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() { let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values, Ok(values) => values,
Err(Error::State) => { Err(UserAgentSessionError::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?; self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey); return Err(UnsealError::InvalidKey);
} }
Err(_err) => { Err(_err) => {
return Err(Error::internal("Failed to take unseal secret").into()); return Err(UserAgentSessionError::internal("Failed to take unseal secret").into());
} }
}; };
let seal_key_buffer = match Self::decrypt_client_key_material( let Ok(seal_key_buffer) = Self::decrypt_client_key_material(
ephemeral_secret, ephemeral_secret,
client_public_key, client_public_key,
&nonce, &nonce,
&ciphertext, &ciphertext,
&associated_data, &associated_data,
) { ) else {
Ok(buffer) => buffer, self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(()) => { return Err(UnsealError::InvalidKey);
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
}; };
match self match self
@@ -173,12 +191,12 @@ impl UserAgentSession {
}) })
.await .await
{ {
Ok(_) => { Ok(()) => {
info!("Successfully unsealed key with client-provided key"); info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?; self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(()) Ok(())
} }
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => { Err(SendError::HandlerError(keyholder::KeyHolderError::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?; self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey) Err(UnsealError::InvalidKey)
} }
@@ -190,7 +208,7 @@ impl UserAgentSession {
Err(err) => { Err(err) => {
error!(?err, "Failed to send unseal request to keyholder"); error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?; self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(Error::internal("Vault actor error").into()) Err(UserAgentSessionError::internal("Vault actor error").into())
} }
} }
} }
@@ -204,25 +222,22 @@ impl UserAgentSession {
) -> Result<(), BootstrapError> { ) -> Result<(), BootstrapError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() { let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values, Ok(values) => values,
Err(Error::State) => { Err(UserAgentSessionError::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?; self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey); return Err(BootstrapError::InvalidKey);
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
let seal_key_buffer = match Self::decrypt_client_key_material( let Ok(seal_key_buffer) = Self::decrypt_client_key_material(
ephemeral_secret, ephemeral_secret,
client_public_key, client_public_key,
&nonce, &nonce,
&ciphertext, &ciphertext,
&associated_data, &associated_data,
) { ) else {
Ok(buffer) => buffer, self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(()) => { return Err(BootstrapError::InvalidKey);
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
}; };
match self match self
@@ -234,12 +249,12 @@ impl UserAgentSession {
}) })
.await .await
{ {
Ok(_) => { Ok(()) => {
info!("Successfully bootstrapped vault with client-provided key"); info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?; self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(()) Ok(())
} }
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => { Err(SendError::HandlerError(keyholder::KeyHolderError::AlreadyBootstrapped)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?; self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::AlreadyBootstrapped) Err(BootstrapError::AlreadyBootstrapped)
} }
@@ -251,7 +266,7 @@ impl UserAgentSession {
Err(err) => { Err(err) => {
error!(?err, "Failed to send bootstrap request to keyholder"); error!(?err, "Failed to send bootstrap request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?; self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::General(Error::internal( Err(BootstrapError::General(UserAgentSessionError::internal(
"Vault actor error", "Vault actor error",
))) )))
} }
@@ -262,14 +277,16 @@ impl UserAgentSession {
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> { pub(crate) async fn handle_query_vault_state(
&mut self,
) -> Result<KeyHolderState, UserAgentSessionError> {
use crate::actors::keyholder::GetState; use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await { let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state, Ok(state) => state,
Err(err) => { Err(err) => {
error!(?err, actor = "useragent", "keyholder.query.failed"); error!(?err, actor = "useragent", "keyholder.query.failed");
return Err(Error::internal("Vault is in broken state")); return Err(UserAgentSessionError::internal("Vault is in broken state"));
} }
}; };
@@ -280,26 +297,32 @@ impl UserAgentSession {
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> { pub(crate) async fn handle_evm_wallet_create(
&mut self,
) -> Result<(i32, Address), UserAgentSessionError> {
match self.props.actors.evm.ask(Generate {}).await { match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address), Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!( Err(SendError::HandlerError(err)) => Err(UserAgentSessionError::internal(format!(
"EVM wallet generation failed: {err}" "EVM wallet generation failed: {err}"
))), ))),
Err(err) => { Err(err) => {
error!(?err, "EVM actor unreachable during wallet create"); error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable")) Err(UserAgentSessionError::internal("EVM actor unreachable"))
} }
} }
} }
#[message] #[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> { pub(crate) async fn handle_evm_wallet_list(
&mut self,
) -> Result<Vec<(i32, Address)>, UserAgentSessionError> {
match self.props.actors.evm.ask(ListWallets {}).await { match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets), Ok(wallets) => Ok(wallets),
Err(err) => { Err(err) => {
error!(?err, "EVM wallet list failed"); error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets")) Err(UserAgentSessionError::internal(
"Failed to list EVM wallets",
))
} }
} }
} }
@@ -308,12 +331,14 @@ impl UserAgentSession {
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub(crate) async fn handle_grant_list(
&mut self,
) -> Result<Vec<Grant<SpecificGrant>>, UserAgentSessionError> {
match self.props.actors.evm.ask(UseragentListGrants {}).await { match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants), Ok(grants) => Ok(grants),
Err(err) => { Err(err) => {
error!(?err, "EVM grant list failed"); error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants")) Err(UserAgentSessionError::internal("Failed to list EVM grants"))
} }
} }
} }
@@ -321,9 +346,9 @@ impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_grant_create( pub(crate) async fn handle_grant_create(
&mut self, &mut self,
basic: crate::evm::policies::SharedGrantSettings, basic: SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant, grant: SpecificGrant,
) -> Result<i32, Error> { ) -> Result<i32, GrantMutationError> {
match self match self
.props .props
.actors .actors
@@ -334,24 +359,59 @@ impl UserAgentSession {
Ok(grant_id) => Ok(grant_id), Ok(grant_id) => Ok(grant_id),
Err(err) => { Err(err) => {
error!(?err, "EVM grant create failed"); error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant")) Err(GrantMutationError::Internal)
} }
} }
} }
#[message] #[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> { #[expect(clippy::unused_async, reason = "false positive")]
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(UseragentDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self match self
.props .props
.actors .actors
.evm .evm
.ask(UseragentDeleteGrant { grant_id }) .ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await .await
{ {
Ok(()) => Ok(()), Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => { Err(err) => {
error!(?err, "EVM grant delete failed"); error!(?err, "EVM sign transaction failed in user-agent session");
Err(Error::internal("Failed to delete EVM grant")) Err(SignTransactionError::Internal)
} }
} }
} }
@@ -360,7 +420,7 @@ impl UserAgentSession {
pub(crate) async fn handle_grant_evm_wallet_access( pub(crate) async fn handle_grant_evm_wallet_access(
&mut self, &mut self,
entries: Vec<NewEvmWalletAccess>, entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> { ) -> Result<(), UserAgentSessionError> {
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
@@ -374,7 +434,7 @@ impl UserAgentSession {
.await?; .await?;
} }
Result::<_, Error>::Ok(()) Result::<_, UserAgentSessionError>::Ok(())
}) })
}) })
.await?; .await?;
@@ -385,7 +445,7 @@ impl UserAgentSession {
pub(crate) async fn handle_revoke_evm_wallet_access( pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self, &mut self,
entries: Vec<i32>, entries: Vec<i32>,
) -> Result<(), Error> { ) -> Result<(), UserAgentSessionError> {
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
@@ -397,7 +457,7 @@ impl UserAgentSession {
.await?; .await?;
} }
Result::<_, Error>::Ok(()) Result::<_, UserAgentSessionError>::Ok(())
}) })
}) })
.await?; .await?;
@@ -407,10 +467,9 @@ impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_list_wallet_access( pub(crate) async fn handle_list_wallet_access(
&mut self, &mut self,
) -> Result<Vec<EvmWalletAccess>, Error> { ) -> Result<Vec<EvmWalletAccess>, UserAgentSessionError> {
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access; let access_entries = crate::db::schema::evm_wallet_access::table
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select()) .select(EvmWalletAccess::as_select())
.load::<_>(&mut conn) .load::<_>(&mut conn)
.await?; .await?;
@@ -424,15 +483,15 @@ impl UserAgentSession {
pub(crate) async fn handle_new_client_approve( pub(crate) async fn handle_new_client_approve(
&mut self, &mut self,
approved: bool, approved: bool,
pubkey: ed25519_dalek::VerifyingKey, pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>, ctx: &Context<Self, Result<(), UserAgentSessionError>>,
) -> Result<(), Error> { ) -> Result<(), UserAgentSessionError> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey) { let Some(pending_approval) = self.pending_client_approvals.remove(&pubkey.to_bytes())
Some(approval) => approval, else {
None => { error!("Received client connection response for unknown client");
error!("Received client connection response for unknown client"); return Err(UserAgentSessionError::internal(
return Err(Error::internal("Unknown client in connection response")); "Unknown client in connection response",
} ));
}; };
pending_approval pending_approval
@@ -444,7 +503,9 @@ impl UserAgentSession {
?err, ?err,
"Failed to send client approval response to controller" "Failed to send client approval response to controller"
); );
Error::internal("Failed to send client approval response to controller") UserAgentSessionError::internal(
"Failed to send client approval response to controller",
)
})?; })?;
ctx.actor_ref().unlink(&pending_approval.controller).await; ctx.actor_ref().unlink(&pending_approval.controller).await;
@@ -455,7 +516,7 @@ impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_sdk_client_list( pub(crate) async fn handle_sdk_client_list(
&mut self, &mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> { ) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, UserAgentSessionError> {
use crate::db::schema::{client_metadata, program_client}; use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;

View File

@@ -19,8 +19,6 @@ smlang::statemachine!(
pub struct DummyContext; pub struct DummyContext;
impl UserAgentStateMachineContext for DummyContext { impl UserAgentStateMachineContext for DummyContext {
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> { fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> {
Ok(event_data) Ok(event_data)
} }

View File

@@ -1,6 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
@@ -11,43 +10,37 @@ use crate::{
pub mod tls; pub mod tls;
#[derive(Error, Debug, Diagnostic)] #[derive(Error, Debug)]
pub enum InitError { pub enum InitError {
#[error("Database setup failed: {0}")] #[error("Database setup failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_setup))]
DatabaseSetup(#[from] db::DatabaseSetupError), DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Connection acquire failed: {0}")] #[error("Connection acquire failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_pool))]
DatabasePool(#[from] db::PoolError), DatabasePool(#[from] db::PoolError),
#[error("Database query error: {0}")] #[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::init::database_query))]
DatabaseQuery(#[from] diesel::result::Error), DatabaseQuery(#[from] diesel::result::Error),
#[error("TLS initialization failed: {0}")] #[error("TLS initialization failed: {0}")]
#[diagnostic(code(arbiter_server::init::tls_init))]
Tls(#[from] tls::InitError), Tls(#[from] tls::InitError),
#[error("Actor spawn failed: {0}")] #[error("Actor spawn failed: {0}")]
#[diagnostic(code(arbiter_server::init::actor_spawn))] ActorSpawn(#[from] crate::actors::GlobalActorsSpawnError),
ActorSpawn(#[from] crate::actors::SpawnError),
#[error("I/O Error: {0}")] #[error("I/O Error: {0}")]
#[diagnostic(code(arbiter_server::init::io))]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
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
@@ -56,7 +49,7 @@ impl std::ops::Deref for ServerContext {
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,8 +1,8 @@
use std::{net::IpAddr, string::FromUtf8Error}; use std::{net::Ipv4Addr, string::FromUtf8Error};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use pem::Pem; use pem::Pem;
use rcgen::{ use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
@@ -22,47 +22,45 @@ use crate::db::{
}; };
const ENCODE_CONFIG: pem::EncodeConfig = { const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = match cfg!(target_family = "windows") { let line_ending = if cfg!(target_family = "windows") {
true => pem::LineEnding::CRLF, pem::LineEnding::CRLF
false => pem::LineEnding::LF, } else {
pem::LineEnding::LF
}; };
pem::EncodeConfig::new().set_line_ending(line_ending) pem::EncodeConfig::new().set_line_ending(line_ending)
}; };
#[derive(Error, Debug, Diagnostic)] #[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}")]
#[diagnostic(code(arbiter_server::tls_init::key_generation))]
KeyGeneration(#[from] rcgen::Error), KeyGeneration(#[from] rcgen::Error),
#[error("Key invalid format: {0}")] #[error("Key invalid format: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_invalid_format))]
KeyInvalidFormat(#[from] FromUtf8Error), KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key deserialization error: {0}")] #[error("Key deserialization error: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_deserialization))]
KeyDeserializationError(rcgen::Error), KeyDeserializationError(rcgen::Error),
#[error("Database error during TLS initialization: {0}")] #[error("Database error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_error))]
DatabaseError(#[from] diesel::result::Error), DatabaseError(#[from] diesel::result::Error),
#[error("Pem deserialization error during TLS initialization: {0}")] #[error("Pem deserialization error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::pem_deserialization))]
PemDeserializationError(#[from] rustls::pki_types::pem::Error), PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[error("Database pool acquire error during TLS initialization: {0}")] #[error("Database pool acquire error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_pool_acquire))]
DatabasePoolAcquire(#[from] db::PoolError), DatabasePoolAcquire(#[from] db::PoolError),
} }
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)
} }
#[allow(unused)] #[expect(
unused,
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,
@@ -91,7 +89,7 @@ impl TlsCa {
let cert_key_pem = certified_issuer.key().serialize_pem(); let cert_key_pem = certified_issuer.key().serialize_pem();
#[allow( #[expect(
clippy::unwrap_used, clippy::unwrap_used,
reason = "Broken cert couldn't bootstrap server anyway" reason = "Broken cert couldn't bootstrap server anyway"
)] )]
@@ -116,9 +114,7 @@ impl TlsCa {
]; ];
params params
.subject_alt_names .subject_alt_names
.push(SanType::IpAddress(IpAddr::from([ .push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
127, 0, 0, 1,
])));
let mut dn = DistinguishedName::new(); let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf"); dn.push(DnType::CommonName, "Arbiter Instance Leaf");
@@ -132,7 +128,11 @@ impl TlsCa {
}) })
} }
#[allow(unused)] #[expect(
unused,
clippy::unnecessary_wraps,
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 {
@@ -141,7 +141,10 @@ impl TlsCa {
}) })
} }
#[allow(unused)] #[expect(
unused,
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)?;
@@ -242,10 +245,10 @@ impl TlsManager {
} }
} }
pub fn cert(&self) -> &CertificateDer<'static> { pub const fn cert(&self) -> &CertificateDer<'static> {
&self.cert &self.cert
} }
pub fn ca_cert(&self) -> &CertificateDer<'static> { pub const fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert &self.ca_cert
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,326 @@
use crate::actors::keyholder;
use arbiter_crypto::hashing::Hashable;
use hmac::Hmac;
use sha2::Sha256;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _;
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
#[derive(Debug, thiserror::Error)]
pub enum IntegrityError {
#[error("Database error: {0}")]
Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")]
Keyholder(#[from] keyholder::KeyHolderError),
#[error("KeyHolder mailbox error")]
KeyholderSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
found: i32,
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
Unavailable,
}
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>;
pub trait Integrable: Hashable {
const KIND: &'static str;
const VERSION: i32 = 1;
}
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #85"
)]
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
pub trait IntoId {
fn into_id(self) -> Vec<u8>;
}
impl IntoId for i32 {
fn into_id(self) -> Vec<u8> {
self.to_be_bytes().to_vec()
}
}
impl IntoId for &'_ [u8] {
fn into_id(self) -> Vec<u8> {
self.to_vec()
}
}
pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<(), IntegrityError> {
let payload_hash = payload_hash(&entity);
let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) = keyholder
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
SendError::HandlerError(inner) => IntegrityError::Keyholder(inner),
_ => IntegrityError::KeyholderSend,
})?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id,
payload_version: E::VERSION,
key_version,
mac: mac.clone(),
})
.on_conflict((
integrity_envelope::entity_id,
integrity_envelope::entity_kind,
))
.do_update()
.set((
integrity_envelope::payload_version.eq(E::VERSION),
integrity_envelope::key_version.eq(key_version),
integrity_envelope::mac.eq(mac),
))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(())
}
pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<AttestationStatus, IntegrityError> {
let entity_id = entity_id.into_id();
let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.first(conn)
.await
.map_err(|err| match err {
diesel::result::Error::NotFound => IntegrityError::MissingEnvelope {
entity_kind: E::KIND,
},
other => IntegrityError::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != E::VERSION {
return Err(IntegrityError::PayloadVersionMismatch {
entity_kind: E::KIND,
expected: E::VERSION,
found: envelope.payload_version,
});
}
let payload_hash = payload_hash(&entity);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let result = keyholder
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await;
match result {
Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(IntegrityError::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(keyholder::KeyHolderError::NotBootstrapped)) => {
Ok(AttestationStatus::Unavailable)
}
Err(_) => Err(IntegrityError::KeyholderSend),
}
}
#[cfg(test)]
mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use crate::{
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::{Integrable, IntegrityError, sign_entity, verify_entity};
#[derive(Clone, arbiter_macros::Hashable)]
struct DummyEntity {
payload_version: i32,
payload: Vec<u8>,
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
const ENTITY_ID: &[u8] = b"entity-id-7";
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&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, &keyholder, &entity, ENTITY_ID)
.await
.unwrap();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap();
}
#[tokio::test]
async fn tampered_mac_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-11";
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&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, &keyholder, &entity, ENTITY_ID)
.await
.unwrap();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, IntegrityError::MacMismatch { .. }));
}
#[tokio::test]
async fn changed_payload_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-21";
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&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, &keyholder, &entity, ENTITY_ID)
.await
.unwrap();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, IntegrityError::MacMismatch { .. }));
}
}

View File

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

View File

@@ -5,7 +5,7 @@ use diesel_async::{
sync_connection_wrapper::SyncConnectionWrapper, sync_connection_wrapper::SyncConnectionWrapper,
}; };
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::info;
@@ -21,35 +21,31 @@ static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Diagnostic, Debug)] #[derive(Error, Debug)]
pub enum DatabaseSetupError { pub enum DatabaseSetupError {
#[error("Failed to determine home directory")]
#[diagnostic(code(arbiter::db::home_dir))]
HomeDir(std::io::Error),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::connection))]
Connection(diesel::ConnectionError),
#[error(transparent)]
#[diagnostic(code(arbiter::db::concurrency))]
ConcurrencySetup(diesel::result::Error), ConcurrencySetup(diesel::result::Error),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::migration))] Connection(diesel::ConnectionError),
#[error("Failed to determine home directory")]
HomeDir(std::io::Error),
#[error(transparent)]
Migration(Box<dyn std::error::Error + Send + Sync>), Migration(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::pool))]
Pool(#[from] PoolInitError), Pool(#[from] PoolInitError),
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DatabaseError { pub enum DatabaseError {
#[error("Database connection error")]
Pool(#[from] PoolError),
#[error("Database query error")] #[error("Database query error")]
Connection(#[from] diesel::result::Error), Connection(#[from] diesel::result::Error),
#[error("Database connection error")]
Pool(#[from] PoolError),
} }
#[tracing::instrument(level = "info")] #[tracing::instrument(level = "info")]
@@ -98,13 +94,16 @@ fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
} }
#[tracing::instrument(level = "info")] #[tracing::instrument(level = "info")]
/// Creates a connection pool for the `SQLite` database.
///
/// # Panics
/// 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(
#[allow(clippy::expect_used)]
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_string(), .to_owned(),
); );
initialize_database(&database_url)?; initialize_database(&database_url)?;
@@ -138,19 +137,20 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool) Ok(pool)
} }
#[mutants::skip]
#[expect(clippy::missing_panics_doc, reason = "Tests oriented function")]
/// 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);
#[allow(clippy::expect_used)]
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_string(); .to_owned();
#[allow(clippy::expect_used)]
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,13 +1,14 @@
#![allow(unused)] #![allow(
#![allow(clippy::all)] clippy::duplicated_attributes,
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,
root_key_history, tls_history, integrity_envelope, root_key_history, tls_history,
}; };
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models; use restructed::Models;
@@ -27,16 +28,16 @@ pub mod types {
pub struct SqliteTimestamp(pub DateTime<Utc>); pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp { impl SqliteTimestamp {
pub fn now() -> Self { pub fn now() -> Self {
SqliteTimestamp(Utc::now()) Self(Utc::now())
} }
} }
impl From<chrono::DateTime<Utc>> for SqliteTimestamp { impl From<DateTime<Utc>> for SqliteTimestamp {
fn from(dt: chrono::DateTime<Utc>) -> Self { fn from(dt: DateTime<Utc>) -> Self {
SqliteTimestamp(dt) Self(dt)
} }
} }
impl From<SqliteTimestamp> for chrono::DateTime<Utc> { impl From<SqliteTimestamp> for DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self { fn from(ts: SqliteTimestamp) -> Self {
ts.0 ts.0
} }
@@ -47,6 +48,11 @@ pub mod types {
&'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(
clippy::cast_possible_truncation,
clippy::as_conversions,
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)
@@ -69,41 +75,47 @@ pub mod types {
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(SqliteTimestamp(datetime)) Ok(Self(datetime))
} }
} }
/// Key algorithm stored in the `useragent_client.key_type` column. #[derive(Debug, FromSqlRow, AsExpression, Clone)]
/// Values must stay stable — they are persisted in the database.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, strum::FromRepr)]
#[diesel(sql_type = Integer)] #[diesel(sql_type = Integer)]
#[repr(i32)] #[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub enum KeyType { pub struct ChainId(pub i32);
Ed25519 = 1,
EcdsaSecp256k1 = 2,
Rsa = 3,
}
impl ToSql<Integer, Sqlite> for KeyType { #[expect(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
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"
)]
const _: () = {
impl From<ChainId> for alloy::primitives::ChainId {
fn from(chain_id: ChainId) -> Self {
chain_id.0 as Self
}
}
impl From<alloy::primitives::ChainId> for ChainId {
fn from(chain_id: alloy::primitives::ChainId) -> Self {
Self(chain_id as _)
}
}
};
impl FromSql<Integer, Sqlite> for ChainId {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
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 {
out.set_value(*self as i32); ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
Ok(IsNull::No)
}
}
impl FromSql<Integer, Sqlite> for KeyType {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err("Expected Integer for KeyType".into());
};
let discriminant = bytes.read_long();
KeyType::from_repr(discriminant as i32)
.ok_or_else(|| format!("Unknown KeyType discriminant: {discriminant}").into())
} }
} }
} }
@@ -244,7 +256,6 @@ pub struct UseragentClient {
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
@@ -272,7 +283,7 @@ pub struct EvmEtherTransferLimit {
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: i32, 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>>,
@@ -295,7 +306,7 @@ 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: i32, pub chain_id: ChainId,
pub eth_value: Vec<u8>, pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
} }
@@ -370,9 +381,28 @@ 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: i32, 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)]
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
#[view(
NewIntegrityEnvelope,
derive(Insertable),
omit(id, signed_at, created_at),
attributes_with = "deriveless"
)]
pub struct IntegrityEnvelope {
pub id: i32,
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}

View File

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

View File

@@ -45,7 +45,7 @@ sol! {
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 {

View File

@@ -8,9 +8,11 @@ use alloy::{
use chrono::Utc; use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use tracing_subscriber::registry::Data; use kameo::actor::ActorRef;
use crate::{ use crate::{
actors::keyholder::KeyHolder,
crypto::integrity,
db::{ db::{
self, DatabaseError, self, DatabaseError,
models::{ models::{
@@ -19,8 +21,8 @@ use crate::{
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings, CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
}; };
@@ -29,42 +31,47 @@ pub mod policies;
mod utils; mod utils;
/// Errors that can only occur once the transaction meaning is known (during policy evaluation) /// Errors that can only occur once the transaction meaning is known (during policy evaluation)
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error)]
pub enum PolicyError { pub enum PolicyError {
#[error("Database error")] #[error("Database error")]
Error(#[from] crate::db::DatabaseError), Database(#[from] DatabaseError),
#[error("Transaction violates policy: {0:?}")] #[error("Transaction violates policy: {0:?}")]
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
Violations(Vec<EvalViolation>), Violations(Vec<EvalViolation>),
#[error("No matching grant found")] #[error("No matching grant found")]
#[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))]
NoMatchingGrant, NoMatchingGrant,
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::IntegrityError),
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error)]
pub enum VetError { pub enum VetError {
#[error("Contract creation transactions are not supported")] #[error("Contract creation transactions are not supported")]
#[diagnostic(code(arbiter_server::evm::vet_error::contract_creation_unsupported))]
ContractCreationNotSupported, ContractCreationNotSupported,
#[error("Engine can't classify this transaction")] #[error("Engine can't classify this transaction")]
#[diagnostic(code(arbiter_server::evm::vet_error::unsupported))]
UnsupportedTransactionType, UnsupportedTransactionType,
#[error("Policy evaluation failed: {1}")] #[error("Policy evaluation failed: {1}")]
#[diagnostic(code(arbiter_server::evm::vet_error::evaluated))]
Evaluated(SpecificMeaning, #[source] PolicyError), Evaluated(SpecificMeaning, #[source] PolicyError),
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error)]
pub enum AnalyzeError { pub enum AnalyzeError {
#[error("Engine doesn't support granting permissions for contract creation")] #[error("Engine doesn't support granting permissions for contract creation")]
#[diagnostic(code(arbiter_server::evm::analyze_error::contract_creation_not_supported))]
ContractCreationNotSupported, ContractCreationNotSupported,
#[error("Unsupported transaction type")] #[error("Unsupported transaction type")]
#[diagnostic(code(arbiter_server::evm::analyze_error::unsupported_transaction_type))]
UnsupportedTransactionType, UnsupportedTransactionType,
} }
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::IntegrityError),
}
/// Controls whether a transaction should be executed or only validated /// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind { pub enum RunKind {
@@ -83,6 +90,14 @@ async fn check_shared_constraints(
let mut violations = Vec::new(); let mut violations = Vec::new();
let now = Utc::now(); let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window // Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) { if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
violations.push(EvalViolation::InvalidTime); violations.push(EvalViolation::InvalidTime);
@@ -112,7 +127,7 @@ async fn check_shared_constraints(
.get_result(conn) .get_result(conn)
.await?; .await?;
if count >= rate_limit.count as i64 { if count >= rate_limit.count.into() {
violations.push(EvalViolation::RateLimitExceeded); violations.push(EvalViolation::RateLimitExceeded);
} }
} }
@@ -123,6 +138,7 @@ async fn check_shared_constraints(
// Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine { pub struct Engine {
db: db::DatabasePool, db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>,
} }
impl Engine { impl Engine {
@@ -131,7 +147,10 @@ impl Engine {
context: EvalContext, context: EvalContext,
meaning: &P::Meaning, meaning: &P::Meaning,
run_kind: RunKind, run_kind: RunKind,
) -> Result<(), PolicyError> { ) -> Result<(), PolicyError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn) let grant = P::try_find_grant(&context, &mut conn)
@@ -139,10 +158,16 @@ impl Engine {
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?; .ok_or(PolicyError::NoMatchingGrant)?;
let mut violations = integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?;
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await let mut violations = check_shared_constraints(
.map_err(DatabaseError::from)?; &context,
&grant.settings.shared,
grant.common_settings_id,
&mut conn,
)
.await
.map_err(DatabaseError::from)?;
violations.extend( violations.extend(
P::evaluate(&context, meaning, &grant, &mut conn) P::evaluate(&context, meaning, &grant, &mut conn)
.await .await
@@ -151,14 +176,16 @@ impl Engine {
if !violations.is_empty() { if !violations.is_empty() {
return Err(PolicyError::Violations(violations)); return Err(PolicyError::Violations(violations));
} else if run_kind == RunKind::Execution { }
if run_kind == RunKind::Execution {
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table) let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog { .values(&NewEvmTransactionLog {
grant_id: grant.shared_grant_id, grant_id: grant.common_settings_id,
wallet_access_id: context.target.id, wallet_access_id: context.target.id,
chain_id: context.chain as i32, chain_id: context.chain.into(),
eth_value: utils::u256_to_bytes(context.value).to_vec(), eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(), signed_at: Utc::now().into(),
}) })
@@ -180,42 +207,52 @@ impl Engine {
} }
impl Engine { impl Engine {
pub fn new(db: db::DatabasePool) -> Self { pub const fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db } Self { db, keyholder }
} }
pub async fn create_grant<P: Policy>( pub async fn create_grant<P: Policy>(
&self, &self,
full_grant: FullGrant<P::Settings>, full_grant: CombinedSettings<P::Settings>,
) -> Result<i32, DatabaseError> { ) -> Result<i32, DatabaseError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn let id = conn
.transaction(|conn| { .transaction(|conn| {
Box::pin(async move { Box::pin(async move {
use schema::evm_basic_grant; use schema::evm_basic_grant;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant { .values(&NewEvmBasicGrant {
chain_id: full_grant.basic.chain as i32, chain_id: full_grant.shared.chain.into(),
wallet_access_id: full_grant.basic.wallet_access_id, wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp), valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp), valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant max_gas_fee_per_gas: full_grant
.basic .shared
.max_gas_fee_per_gas .max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()), .map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant max_priority_fee_per_gas: full_grant
.basic .shared
.max_priority_fee_per_gas .max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()), .map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant rate_limit_count: full_grant
.basic .shared
.rate_limit .rate_limit
.as_ref() .as_ref()
.map(|rl| rl.count as i32), .map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant rate_limit_window_secs: full_grant
.basic .shared
.rate_limit .rate_limit
.as_ref() .as_ref()
.map(|rl| rl.window.num_seconds() as i32), .map(|rl| rl.window.num_seconds() as i32),
@@ -225,7 +262,13 @@ impl Engine {
.get_result(conn) .get_result(conn)
.await?; .await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
}) })
}) })
.await?; .await?;
@@ -233,33 +276,36 @@ impl Engine {
Ok(id) Ok(id)
} }
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> { async fn list_one_kind<Kind: Policy, Y>(
let mut conn = self.db.get().await?; &self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<impl Iterator<Item = Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
let all_grants = Kind::find_all_grants(conn)
.await
.map_err(DatabaseError::from)?;
// Verify integrity of all grants before returning any results
for grant in &all_grants {
integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?;
}
Ok(all_grants.into_iter().map(|g| Grant {
id: g.id,
common_settings_id: g.common_settings_id,
settings: g.settings.generalize(),
}))
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new(); let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
grants.extend( grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?);
EtherTransfer::find_all_grants(&mut conn) grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?);
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::EtherTransfer(g.settings),
}),
);
grants.extend(
TokenTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::TokenTransfer(g.settings),
}),
);
Ok(grants) Ok(grants)
} }
@@ -273,7 +319,7 @@ impl Engine {
let TxKind::Call(to) = transaction.to else { let TxKind::Call(to) = transaction.to else {
return Err(VetError::ContractCreationNotSupported); return Err(VetError::ContractCreationNotSupported);
}; };
let context = policies::EvalContext { let context = EvalContext {
target, target,
chain: transaction.chain_id, chain: transaction.chain_id,
to, to,
@@ -305,3 +351,261 @@ impl Engine {
Err(VetError::UnsupportedTransactionType) Err(VetError::UnsupportedTransactionType)
} }
} }
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
fn context() -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ZERO,
calldata: Bytes::new(),
max_fee_per_gas: 100,
max_priority_fee_per_gas: 10,
}
}
fn shared_settings() -> SharedGrantSettings {
SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
}
}
async fn insert_basic_grant(
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain.into(),
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
rate_limit_window_secs: shared
.rate_limit
.as_ref()
.map(|limit| limit.window.num_seconds() as i32),
revoked_at: None,
})
.returning(EvmBasicGrant::as_select())
.get_result(conn)
.await
.unwrap()
}
#[rstest]
#[case::matching_chain(CHAIN_ID, false)]
#[case::mismatching_chain(CHAIN_ID + 1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_chain_id(
#[case] context_chain: u64,
#[case] expect_mismatch: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
chain: context_chain,
..context()
};
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
expect_mismatch
);
if expect_mismatch {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
#[tokio::test]
async fn check_shared_constraints_enforces_validity_window(
#[case] valid_from: Option<chrono::DateTime<Utc>>,
#[case] valid_until: Option<chrono::DateTime<Utc>>,
#[case] expect_invalid_time: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
valid_from,
valid_until,
..shared_settings()
};
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
expect_invalid_time
);
if expect_invalid_time {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_gas_fee_caps(
#[case] max_gas_fee_per_gas: Option<U256>,
#[case] max_priority_fee_per_gas: Option<U256>,
#[case] actual_max_fee_per_gas: u128,
#[case] actual_max_priority_fee_per_gas: u128,
#[case] expect_gas_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
max_fee_per_gas: actual_max_fee_per_gas,
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
..context()
};
let shared = SharedGrantSettings {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
..shared_settings()
};
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
expect_gas_limit_violation
);
if expect_gas_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::under_rate_limit(2, false)]
#[case::at_rate_limit(1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_rate_limit(
#[case] rate_limit_count: u32,
#[case] expect_rate_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
rate_limit: Some(TransactionRateLimit {
count: rate_limit_count,
window: Duration::hours(1),
}),
..shared_settings()
};
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
.await
.unwrap();
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
expect_rate_limit_violation
);
if expect_rate_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
}

View File

@@ -6,11 +6,12 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite, ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
db::models::{self, EvmBasicGrant, EvmWalletAccess}, crypto::integrity::v1::Integrable,
db::models::{EvmBasicGrant, EvmWalletAccess},
evm::utils, evm::utils,
}; };
@@ -33,34 +34,31 @@ pub struct EvalContext {
pub max_priority_fee_per_gas: u128, pub max_priority_fee_per_gas: u128,
} }
#[derive(Debug, Error, Diagnostic)] #[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}")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_target))]
InvalidTarget { target: Address }, InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")] #[error("Gas limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::gas_limit_exceeded))]
GasLimitExceeded { GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>, max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>, max_priority_fee_per_gas: Option<U256>,
}, },
#[error("Rate limit exceeded for this grant")] #[error("Rate limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::rate_limit_exceeded))]
RateLimitExceeded, RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")] #[error("Transaction exceeds volumetric limits of the grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::volumetric_limit_exceeded))]
VolumetricLimitExceeded, VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")] #[error("Transaction is outside of the grant's validity period")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_time))]
InvalidTime, InvalidTime,
#[error("Transaction type is not allowed by this grant")] #[error("Transaction type is not allowed by this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_transaction_type))]
InvalidTransactionType, InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
} }
pub type DatabaseID = i32; pub type DatabaseID = i32;
@@ -68,13 +66,12 @@ pub type DatabaseID = i32;
#[derive(Debug)] #[derive(Debug)]
pub struct Grant<PolicySettings> { pub struct Grant<PolicySettings> {
pub id: DatabaseID, pub id: DatabaseID,
pub shared_grant_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 shared: SharedGrantSettings, pub settings: CombinedSettings<PolicySettings>,
pub settings: PolicySettings,
} }
pub trait Policy: Sized { pub trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant>; 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>;
@@ -90,10 +87,10 @@ pub trait Policy: Sized {
// 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: &models::EvmBasicGrant, basic: &EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl std::future::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
@@ -130,19 +127,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning), TokenTransfer(token_transfers::Meaning),
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[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)] #[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)] #[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,
@@ -157,10 +154,10 @@ pub struct SharedGrantSettings {
} }
impl SharedGrantSettings { impl SharedGrantSettings {
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 as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants chain: model.chain_id.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),
max_gas_fee_per_gas: model max_gas_fee_per_gas: model
@@ -171,10 +168,11 @@ impl SharedGrantSettings {
.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")]
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 as i64), window: Duration::seconds(window_secs.into()),
}), }),
_ => None, _ => None,
}, },
@@ -184,7 +182,7 @@ impl SharedGrantSettings {
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,
) -> diesel::result::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
@@ -203,7 +201,22 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings), TokenTransfer(token_transfers::Settings),
} }
pub struct FullGrant<PolicyGrant> { #[derive(Debug, arbiter_macros::Hashable)]
pub basic: SharedGrantSettings, pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant, pub specific: PolicyGrant,
} }
impl<P> CombinedSettings<P> {
pub fn generalize<Y: From<P>>(self) -> CombinedSettings<Y> {
CombinedSettings {
shared: self.shared,
specific: self.specific.into(),
}
}
}
impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION;
}

View File

@@ -4,21 +4,22 @@ use std::fmt::Display;
use alloy::primitives::{Address, U256}; use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into}; use diesel::dsl::{auto_type, insert_into};
use diesel::prelude::*;
use diesel::sqlite::Sqlite; use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{ use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit, EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp, NewEvmEtherTransferLimit, SqliteTimestamp,
}; };
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log}; use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{ use crate::evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
}; };
use crate::{ use crate::{
db::{ db::{
models::{self, 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::Policy, utils}, evm::{policies::Policy, utils},
@@ -36,8 +37,8 @@ use super::{DatabaseID, EvalContext, EvalViolation};
// Plain ether transfer // Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
to: Address, pub(crate) to: Address,
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 {
@@ -45,21 +46,24 @@ impl Display for Meaning {
} }
} }
impl From<Meaning> for SpecificMeaning { impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning { fn from(val: Meaning) -> Self {
SpecificMeaning::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)] #[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 {
const KIND: &'static str = "EtherTransfer";
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> Self {
SpecificGrant::EtherTransfer(val) Self::EtherTransfer(val)
} }
} }
@@ -70,9 +74,7 @@ async fn query_relevant_past_transaction(
) -> 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( .filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
evm_transaction_log::signed_at.ge(SqliteTimestamp(chrono::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,
@@ -91,20 +93,21 @@ async fn query_relevant_past_transaction(
async fn check_rate_limits( async fn check_rate_limits(
grant: &Grant<Settings>, grant: &Grant<Settings>,
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.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 = chrono::Utc::now() - grant.settings.limit.window; let window_start = Utc::now() - grant.settings.specific.limit.window;
let 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(U256::default(), |acc, (value, _)| acc + *value); .fold(current_transfer_value, |acc, (value, _)| acc + *value);
if cumulative_volume > grant.settings.limit.max_volume { if prospective_cumulative_volume > grant.settings.specific.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded); violations.push(EvalViolation::VolumetricLimitExceeded);
} }
@@ -137,21 +140,26 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new(); let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets // Check if the target address is within the grant's allowed targets
if !grant.settings.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, 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: &models::EvmBasicGrant, basic: &EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<DatabaseID> { ) -> QueryResult<DatabaseID> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
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,
@@ -186,7 +194,7 @@ impl Policy for EtherTransfer {
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>,
) -> diesel::result::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:
@@ -240,15 +248,17 @@ impl Policy for EtherTransfer {
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: chrono::Duration::seconds(limit.window_secs as i64), window: Duration::seconds(limit.window_secs.into()),
}, },
}; };
Ok(Some(Grant { Ok(Some(Grant {
id: grant.id, id: grant.id,
shared_grant_id: grant.basic_grant_id, common_settings_id: grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?, settings: CombinedSettings {
settings, shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings,
},
})) }))
} }
@@ -258,7 +268,7 @@ impl Policy for EtherTransfer {
_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>,
) -> diesel::result::QueryResult<()> { ) -> QueryResult<()> {
// Basic log is sufficient // Basic log is sufficient
Ok(()) Ok(())
@@ -311,7 +321,7 @@ impl Policy for EtherTransfer {
.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(|v| v.as_slice()) .map(Vec::as_slice)
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.filter_map(|t| { .filter_map(|t| {
@@ -326,15 +336,17 @@ impl Policy for EtherTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
shared_grant_id: specific.basic_grant_id, common_settings_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?, settings: CombinedSettings {
settings: Settings { shared: SharedGrantSettings::try_from_model(basic)?,
target: targets, specific: Settings {
limit: VolumeRateLimit { target: targets,
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( limit: VolumeRateLimit {
|e| diesel::result::Error::DeserializationError(Box::new(e)), max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
)?, |e| diesel::result::Error::DeserializationError(Box::new(e)),
window: Duration::seconds(limit.window_secs as i64), )?,
window: Duration::seconds(limit.window_secs.into()),
},
}, },
}, },
}) })

View File

@@ -11,14 +11,17 @@ use crate::db::{
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}; };
use crate::evm::{ use crate::evm::{
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils, utils,
}; };
use super::{EtherTransfer, Settings}; use super::{EtherTransfer, Settings};
const WALLET_ACCESS_ID: i32 = 1; const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 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");
@@ -44,7 +47,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
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 as i32, 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,
@@ -81,8 +84,6 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_matches_empty_calldata() { fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap(); let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap();
@@ -99,8 +100,6 @@ fn analyze_rejects_nonempty_calldata() {
assert!(EtherTransfer::analyze(&context).is_none()); assert!(EtherTransfer::analyze(&context).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_passes_for_allowed_target() { async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -108,9 +107,11 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(vec![ALLOWED], 1_000_000), shared: shared(),
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();
@@ -127,9 +128,11 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(vec![ALLOWED], 1_000_000), shared: shared(),
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();
@@ -157,7 +160,7 @@ async fn evaluate_passes_when_volume_within_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32, 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()),
}) })
@@ -167,9 +170,11 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
shared: shared(), settings: CombinedSettings {
settings, shared: shared(),
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();
@@ -197,8 +202,8 @@ async fn evaluate_rejects_volume_over_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32, chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(1_001u64)).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)
@@ -207,11 +212,13 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
shared: shared(), settings: CombinedSettings {
settings, shared: shared(),
specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); 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
@@ -233,13 +240,13 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.await .await
.unwrap(); .unwrap();
// Exactly at the limit — the 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 as i32, 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(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
.execute(&mut *conn) .execute(&mut *conn)
@@ -248,9 +255,11 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
shared: shared(), settings: CombinedSettings {
settings, shared: shared(),
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();
@@ -263,8 +272,6 @@ async fn evaluate_passes_at_exactly_volume_limit() {
); );
} }
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn try_find_grant_roundtrip() { async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -282,8 +289,11 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.target, vec![ALLOWED]); assert_eq!(g.settings.specific.target, vec![ALLOWED]);
assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64)); assert_eq!(
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -320,7 +330,36 @@ async fn try_find_grant_wrong_target_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
// ── find_all_grants ────────────────────────────────────────────────────── proptest::proptest! {
#[test]
fn target_order_does_not_affect_hash(
raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8),
seed in proptest::prelude::any::<u64>(),
max_volume in proptest::prelude::any::<u64>(),
window_secs in 1i64..=86400,
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use arbiter_crypto::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let limit = VolumeRateLimit {
max_volume: U256::from(max_volume),
window: Duration::seconds(window_secs),
};
let mut h1 = sha2::Sha256::new();
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { target: shuffled, limit }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {
@@ -347,7 +386,7 @@ async fn find_all_grants_excludes_revoked() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target, vec![ALLOWED]); assert_eq!(all[0].settings.specific.target, vec![ALLOWED]);
} }
#[tokio::test] #[tokio::test]
@@ -363,8 +402,11 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target.len(), 2); assert_eq!(all[0].settings.specific.target.len(), 2);
assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64)); assert_eq!(
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -1,20 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant,
NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp,
};
use crate::db::schema::{ use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit, evm_token_transfer_volume_limit,
@@ -26,6 +11,25 @@ use crate::evm::{
}, },
utils, utils,
}; };
use crate::{
crypto::integrity::Integrable,
db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
evm::policies::CombinedSettings,
};
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::prelude::*;
use diesel::sqlite::Sqlite;
use diesel_async::{AsyncConnection, RunQueryDsl};
use super::{DatabaseID, EvalContext, EvalViolation}; use super::{DatabaseID, EvalContext, EvalViolation};
@@ -38,9 +42,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
token: &'static TokenInfo, pub token: &'static TokenInfo,
to: Address, pub to: Address,
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 {
@@ -52,21 +56,25 @@ impl std::fmt::Display for Meaning {
} }
} }
impl From<Meaning> for SpecificMeaning { impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning { fn from(val: Meaning) -> Self {
SpecificMeaning::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)] #[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 {
const KIND: &'static str = "TokenTransfer";
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> Self {
SpecificGrant::TokenTransfer(val) Self::TokenTransfer(val)
} }
} }
@@ -77,10 +85,7 @@ async fn query_relevant_past_transfers(
) -> 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( .filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
evm_token_transfer_log::created_at
.ge(SqliteTimestamp(chrono::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,
@@ -101,24 +106,32 @@ async fn query_relevant_past_transfers(
async fn check_volume_rate_limits( async fn check_volume_rate_limits(
grant: &Grant<Settings>, grant: &Grant<Settings>,
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.settings.volume_limits.iter().map(|l| l.window).max() else { let Some(longest_window) = grant
.settings
.specific
.volume_limits
.iter()
.map(|l| l.window)
.max()
else {
return Ok(violations); return Ok(violations);
}; };
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.volume_limits { for limit in &grant.settings.specific.volume_limits {
let window_start = chrono::Utc::now() - limit.window; let window_start = Utc::now() - limit.window;
let 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(U256::default(), |acc, (value, _)| acc + *value); .fold(current_transfer_value, |acc, (value, _)| acc + *value);
if cumulative_volume > limit.max_volume { if prospective_cumulative_volume > limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded); violations.push(EvalViolation::VolumetricLimitExceeded);
break; break;
} }
@@ -157,13 +170,13 @@ impl Policy for TokenTransfer {
return Ok(violations); return Ok(violations);
} }
if let Some(allowed) = grant.settings.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, 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)
@@ -188,6 +201,11 @@ impl Policy for TokenTransfer {
.await?; .await?;
for limit in &grant.volume_limits { for limit in &grant.volume_limits {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
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,
@@ -237,7 +255,7 @@ impl Policy for TokenTransfer {
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 as i64), window: Duration::seconds(row.window_secs.into()),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
@@ -268,9 +286,11 @@ impl Policy for TokenTransfer {
Ok(Some(Grant { Ok(Some(Grant {
id: token_grant.id, id: token_grant.id,
shared_grant_id: token_grant.basic_grant_id, common_settings_id: token_grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?, settings: CombinedSettings {
settings, shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: settings,
},
})) }))
} }
@@ -285,7 +305,7 @@ impl Policy for TokenTransfer {
.values(NewEvmTokenTransferLog { .values(NewEvmTokenTransferLog {
grant_id: grant.id, grant_id: grant.id,
log_id, log_id,
chain_id: context.chain as i32, 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(),
@@ -334,7 +354,7 @@ impl Policy for TokenTransfer {
.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(|v| v.as_slice()) .map(Vec::as_slice)
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.map(|row| { .map(|row| {
@@ -342,7 +362,7 @@ impl Policy for TokenTransfer {
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 as i64), window: Duration::seconds(row.window_secs.into()),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
@@ -368,12 +388,14 @@ impl Policy for TokenTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
shared_grant_id: specific.basic_grant_id, common_settings_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?, settings: CombinedSettings {
settings: Settings { shared: SharedGrantSettings::try_from_model(basic)?,
token_contract: Address::from(token_contract), specific: Settings {
target, token_contract: Address::from(token_contract),
volume_limits, target,
volume_limits,
},
}, },
}) })
}) })

View File

@@ -11,7 +11,10 @@ use crate::db::{
}; };
use crate::evm::{ use crate::evm::{
abi::IERC20::transferCall, abi::IERC20::transferCall,
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils, utils,
}; };
@@ -56,7 +59,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
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 as i32, 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,
@@ -98,8 +101,6 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_known_token_valid_calldata() { fn analyze_known_token_valid_calldata() {
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
@@ -125,8 +126,6 @@ fn analyze_empty_calldata_returns_none() {
assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none()); assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() { async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -134,9 +133,11 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(None, None), shared: shared(),
specific: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata); let mut context = ctx(DAI, calldata);
@@ -163,9 +164,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(None, None), shared: shared(),
specific: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -183,9 +186,11 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(Some(RECIPIENT), None), shared: shared(),
specific: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -203,9 +208,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(Some(RECIPIENT), None), shared: shared(),
specific: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(OTHER, U256::from(100u64)); let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -220,7 +227,7 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
} }
#[tokio::test] #[tokio::test]
async fn evaluate_passes_volume_within_limit() { async fn evaluate_passes_volume_at_exact_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();
@@ -230,16 +237,15 @@ async fn evaluate_passes_volume_within_limit() {
.await .await
.unwrap(); .unwrap();
// Record a past transfer of 500 (within 1000 limit) // Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log}; insert_into(db::schema::evm_token_transfer_log::table)
insert_into(evm_token_transfer_log::table) .values(db::models::NewEvmTokenTransferLog {
.values(NewEvmTokenTransferLog {
grant_id, grant_id,
log_id: 0, log_id: 0,
chain_id: CHAIN_ID as i32, chain_id: CHAIN_ID.into(),
token_contract: DAI.to_vec(), token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(), recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
@@ -247,9 +253,11 @@ async fn evaluate_passes_volume_within_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
shared: shared(), settings: CombinedSettings {
settings, shared: shared(),
specific: settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -274,15 +282,14 @@ async fn evaluate_rejects_volume_over_limit() {
.await .await
.unwrap(); .unwrap();
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log}; insert_into(db::schema::evm_token_transfer_log::table)
insert_into(evm_token_transfer_log::table) .values(db::models::NewEvmTokenTransferLog {
.values(NewEvmTokenTransferLog {
grant_id, grant_id,
log_id: 0, log_id: 0,
chain_id: CHAIN_ID as i32, chain_id: CHAIN_ID.into(),
token_contract: DAI.to_vec(), token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(), recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(), value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
@@ -290,11 +297,13 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
shared: shared(), settings: CombinedSettings {
settings, shared: shared(),
specific: settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
let m = TokenTransfer::analyze(&context).unwrap(); let m = TokenTransfer::analyze(&context).unwrap();
let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn)
@@ -313,9 +322,11 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
shared: shared(), settings: CombinedSettings {
settings: make_settings(None, None), // no volume limits shared: shared(),
specific: make_settings(None, None), // no volume limits
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX)); let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -349,10 +360,13 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.token_contract, DAI); assert_eq!(g.settings.specific.token_contract, DAI);
assert_eq!(g.settings.target, Some(RECIPIENT)); assert_eq!(g.settings.specific.target, Some(RECIPIENT));
assert_eq!(g.settings.volume_limits.len(), 1); assert_eq!(g.settings.specific.volume_limits.len(), 1);
assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64)); assert_eq!(
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -392,7 +406,39 @@ async fn try_find_grant_unknown_token_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
// ── find_all_grants ────────────────────────────────────────────────────── proptest::proptest! {
#[test]
fn volume_limits_order_does_not_affect_hash(
raw_limits in proptest::collection::vec(
(proptest::prelude::any::<u64>(), 1i64..=86400),
0..8,
),
seed in proptest::prelude::any::<u64>(),
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use arbiter_crypto::hashing::Hashable;
let limits: Vec<VolumeRateLimit> = raw_limits
.iter()
.map(|(max_vol, window_secs)| VolumeRateLimit {
max_volume: U256::from(*max_vol),
window: Duration::seconds(*window_secs),
})
.collect();
let mut shuffled = limits.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let mut h1 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {
@@ -434,9 +480,9 @@ async fn find_all_grants_loads_volume_limits() {
let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.volume_limits.len(), 1); assert_eq!(all[0].settings.specific.volume_limits.len(), 1);
assert_eq!( assert_eq!(
all[0].settings.volume_limits[0].max_volume, all[0].settings.specific.volume_limits[0].max_volume,
U256::from(9_999u64) U256::from(9_999u64)
); );
} }

View File

@@ -1,12 +1,12 @@
use std::sync::Mutex; use std::sync::Mutex;
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
use alloy::{ use alloy::{
consensus::SignableTransaction, consensus::SignableTransaction,
network::{TxSigner, TxSignerSync}, network::{TxSigner, TxSignerSync},
primitives::{Address, B256, ChainId, Signature}, primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address}, signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use async_trait::async_trait; use async_trait::async_trait;
use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner}; use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner};
@@ -82,8 +82,8 @@ impl SafeSigner {
}) })
} }
#[expect(clippy::significant_drop_tightening, reason = "false positive")]
fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> { fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> {
#[allow(clippy::expect_used)]
let mut cell = self.key.lock().expect("SafeSigner mutex poisoned"); let mut cell = self.key.lock().expect("SafeSigner mutex poisoned");
let reader = cell.read(); let reader = cell.read();
let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?; let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?;
@@ -96,7 +96,6 @@ impl SafeSigner {
{ {
return Err(Error::TransactionChainIdMismatch { return Err(Error::TransactionChainIdMismatch {
signer: chain_id, signer: chain_id,
#[allow(clippy::expect_used)]
tx: tx.chain_id().expect("Chain ID is guaranteed to be set"), tx: tx.chain_id().expect("Chain ID is guaranteed to be set"),
}); });
} }

View File

@@ -7,7 +7,7 @@ pub struct LengthError {
pub actual: usize, pub actual: usize,
} }
pub fn u256_to_bytes(value: U256) -> [u8; 32] { pub const fn u256_to_bytes(value: U256) -> [u8; 32] {
value.to_le_bytes() value.to_le_bytes()
} }
pub fn bytes_to_u256(bytes: &[u8]) -> Option<U256> { pub fn bytes_to_u256(bytes: &[u8]) -> Option<U256> {

View File

@@ -1,32 +1,24 @@
use arbiter_proto::{ use arbiter_proto::{
proto::client::{ proto::client::{
ClientRequest, ClientResponse, VaultState as ProtoVaultState, ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload, client_response::Payload as ClientResponsePayload,
}, },
transport::{Receiver, Sender, grpc::GrpcBi}, transport::{Receiver, Sender, grpc::GrpcBi},
}; };
use kameo::{ use kameo::actor::{ActorRef, Spawn as _};
actor::{ActorRef, Spawn as _},
error::SendError,
};
use tonic::Status; use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
actors::{ actors::client::{ClientConnection, session::ClientSession},
client::{
self, ClientConnection,
session::{ClientSession, Error, HandleQueryVaultState},
},
keyholder::KeyHolderState,
},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
}; };
mod auth; mod auth;
mod evm;
mod inbound; mod inbound;
mod outbound; mod outbound;
mod vault;
async fn dispatch_loop( async fn dispatch_loop(
mut bi: GrpcBi<ClientRequest, ClientResponse>, mut bi: GrpcBi<ClientRequest, ClientResponse>,
@@ -34,7 +26,9 @@ async fn dispatch_loop(
mut request_tracker: RequestTracker, mut request_tracker: RequestTracker,
) { ) {
loop { loop {
let Some(message) = bi.recv().await else { return }; let Some(message) = bi.recv().await else {
return;
};
let conn = match message { let conn = match message {
Ok(conn) => conn, Ok(conn) => conn,
@@ -53,16 +47,24 @@ async fn dispatch_loop(
}; };
let Some(payload) = conn.payload else { let Some(payload) = conn.payload else {
let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await; let _ = bi
.send(Err(Status::invalid_argument(
"Missing client request payload",
)))
.await;
return; return;
}; };
match dispatch_inner(&actor, payload).await { match dispatch_inner(&actor, payload).await {
Ok(response) => { Ok(response) => {
if bi.send(Ok(ClientResponse { if bi
request_id: Some(request_id), .send(Ok(ClientResponse {
payload: Some(response), request_id: Some(request_id),
})).await.is_err() { payload: Some(response),
}))
.await
.is_err()
{
return; return;
} }
} }
@@ -79,21 +81,10 @@ async fn dispatch_inner(
payload: ClientRequestPayload, payload: ClientRequestPayload,
) -> Result<ClientResponsePayload, Status> { ) -> Result<ClientResponsePayload, Status> {
match payload { match payload {
ClientRequestPayload::QueryVaultState(_) => { ClientRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
let state = match actor.ask(HandleQueryVaultState {}).await { ClientRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, ClientRequestPayload::Auth(..) => {
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, warn!("Unsupported post-auth client auth request");
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
};
Ok(ClientResponsePayload::VaultState(state.into()))
}
payload => {
warn!(?payload, "Unsupported post-auth client request");
Err(Status::invalid_argument("Unsupported client request")) Err(Status::invalid_argument("Unsupported client request"))
} }
} }
@@ -102,14 +93,20 @@ async fn dispatch_inner(
pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) { pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
let mut request_tracker = RequestTracker::default(); let mut request_tracker = RequestTracker::default();
if let Err(e) = auth::start(&mut conn, &mut bi, &mut request_tracker).await { let client_id = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); Ok(id) => id,
let _ = transport.send(Err(e.clone())).await; Err(err) => {
warn!(error = ?e, "Client authentication failed"); let _ = bi
return; .send(Err(Status::unauthenticated(format!(
"Authentication failed: {err}",
))))
.await;
warn!(error = ?err, "Client authentication failed");
return;
}
}; };
let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn)); let actor = ClientSession::spawn(ClientSession::new(conn, client_id));
let actor_for_cleanup = actor.clone(); let actor_for_cleanup = actor.clone();
info!("Client authenticated successfully"); info!("Client authenticated successfully");

View File

@@ -1,18 +1,27 @@
use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, proto::client::{ ClientMetadata,
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest, proto::{
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, client::{
ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse, ClientRequest, ClientResponse,
client_request::Payload as ClientRequestPayload, auth::{
client_response::Payload as ClientResponsePayload, self as proto_auth, AuthChallengeRequest as ProtoAuthChallengeRequest,
}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi} AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
},
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
shared::ClientInfo as ProtoClientInfo,
},
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::client::{self, ClientConnection, auth}, actors::client::{ClientConnection, auth},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
}; };
@@ -22,7 +31,7 @@ pub struct AuthTransportAdapter<'a> {
} }
impl<'a> AuthTransportAdapter<'a> { impl<'a> AuthTransportAdapter<'a> {
pub fn new( pub const fn new(
bi: &'a mut GrpcBi<ClientRequest, ClientResponse>, bi: &'a mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &'a mut RequestTracker, request_tracker: &'a mut RequestTracker,
) -> Self { ) -> Self {
@@ -32,66 +41,35 @@ impl<'a> AuthTransportAdapter<'a> {
} }
} }
fn response_to_proto(response: auth::Outbound) -> ClientResponsePayload {
match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => {
ClientResponsePayload::AuthChallenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes().to_vec(),
nonce,
})
}
auth::Outbound::AuthSuccess => {
ClientResponsePayload::AuthResult(ProtoAuthResult::Success.into())
}
}
}
fn error_to_proto(error: auth::Error) -> ClientResponsePayload {
ClientResponsePayload::AuthResult(
match error {
auth::Error::InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
auth::Error::ApproveError(auth::ApproveError::Denied) => {
ProtoAuthResult::ApprovalDenied
}
auth::Error::ApproveError(auth::ApproveError::Upstream(
crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected,
)) => ProtoAuthResult::NoUserAgentsOnline,
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),
)
}
async fn send_client_response( async fn send_client_response(
&mut self, &mut self,
payload: ClientResponsePayload, payload: AuthResponsePayload,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
self.bi self.bi
.send(Ok(ClientResponse { .send(Ok(ClientResponse {
request_id: Some(self.request_tracker.current_request_id()), request_id: Some(self.request_tracker.current_request_id()),
payload: Some(payload), payload: Some(ClientResponsePayload::Auth(proto_auth::Response {
payload: Some(payload),
})),
})) }))
.await .await
} }
async fn send_auth_result(&mut self, result: ProtoAuthResult) -> Result<(), TransportError> { async fn send_auth_result(&mut self, result: ProtoAuthResult) -> Result<(), TransportError> {
self.send_client_response(ClientResponsePayload::AuthResult(result.into())) self.send_client_response(AuthResponsePayload::Result(result.into()))
.await .await
} }
} }
#[async_trait] #[async_trait]
impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> { impl Sender<Result<auth::Outbound, auth::ClientAuthError>> for AuthTransportAdapter<'_> {
async fn send( async fn send(
&mut self, &mut self,
item: Result<auth::Outbound, auth::Error>, item: Result<auth::Outbound, auth::ClientAuthError>,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
let payload = match item { let payload = match item {
Ok(message) => AuthTransportAdapter::response_to_proto(message), Ok(message) => message.into(),
Err(err) => AuthTransportAdapter::error_to_proto(err), Err(err) => AuthResponsePayload::Result(ProtoAuthResult::from(err).into()),
}; };
self.send_client_response(payload).await self.send_client_response(payload).await
@@ -117,9 +95,27 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
} }
}; };
let payload = request.payload?; let payload = request.payload?;
let ClientRequestPayload::Auth(auth_request) = payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported client auth request",
)))
.await;
return None;
};
let Some(payload) = auth_request.payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Missing client auth request payload",
)))
.await;
return None;
};
match payload { match payload {
ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
pubkey, pubkey,
client_info, client_info,
}) => { }) => {
@@ -130,11 +126,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
.await; .await;
return None; return None;
}; };
let Ok(pubkey) = <[u8; 32]>::try_from(pubkey) else { let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
};
let Ok(pubkey) = ed25519_dalek::VerifyingKey::from_bytes(&pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await; let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None; return None;
}; };
@@ -143,10 +135,8 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
metadata: client_metadata_from_proto(client_info), metadata: client_metadata_from_proto(client_info),
}) })
} }
ClientRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
signature, let Ok(signature) = authn::Signature::try_from(signature.as_slice()) else {
}) => {
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else {
let _ = self let _ = self
.send_auth_result(ProtoAuthResult::InvalidSignature) .send_auth_result(ProtoAuthResult::InvalidSignature)
.await; .await;
@@ -154,20 +144,11 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
}; };
Some(auth::Inbound::AuthChallengeSolution { signature }) Some(auth::Inbound::AuthChallengeSolution { signature })
} }
_ => {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported client auth request",
)))
.await;
None
}
} }
} }
} }
impl Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {} impl Bi<auth::Inbound, Result<auth::Outbound, auth::ClientAuthError>> for AuthTransportAdapter<'_> {}
fn client_metadata_from_proto(metadata: ProtoClientInfo) -> ClientMetadata { fn client_metadata_from_proto(metadata: ProtoClientInfo) -> ClientMetadata {
ClientMetadata { ClientMetadata {
@@ -181,8 +162,7 @@ pub async fn start(
conn: &mut ClientConnection, conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>, bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker, request_tracker: &mut RequestTracker,
) -> Result<(), auth::Error> { ) -> Result<i32, auth::ClientAuthError> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker); let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await?; auth::authenticate(conn, &mut transport).await
Ok(())
} }

View File

@@ -0,0 +1,87 @@
use arbiter_proto::proto::{
client::{
client_response::Payload as ClientResponsePayload,
evm::{
self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload,
},
},
evm::{
EvmError as ProtoEvmError, EvmSignTransactionResponse,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
use crate::{
actors::client::session::{ClientSession, HandleSignTransaction, SignTransactionRpcError},
grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
},
};
const fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload {
ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<ClientSession>,
req: proto_evm::Request,
) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing client EVM request payload",
));
};
match payload {
EvmRequestPayload::SignTransaction(request) => {
let address = RawEvmAddress(request.wallet_address).try_convert()?;
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
let response = match actor
.ask(HandleSignTransaction {
wallet_address: address,
transaction,
})
.await
{
Ok(signature) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Signature(
signature.as_bytes().to_vec(),
)),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Vet(
vet_error,
))) => EvmSignTransactionResponse {
result: Some(vet_error.convert()),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
};
Ok(wrap_response(EvmResponsePayload::SignTransaction(response)))
}
EvmRequestPayload::AnalyzeTransaction(_) => Err(Status::unimplemented(
"EVM transaction analysis is not yet implemented",
)),
}
}

View File

@@ -0,0 +1,49 @@
use arbiter_proto::proto::{
client::{
client_response::Payload as ClientResponsePayload,
vault::{
self as proto_vault, request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
},
},
shared::VaultState as ProtoVaultState,
};
use kameo::{actor::ActorRef, error::SendError};
use tonic::Status;
use tracing::warn;
use crate::actors::{
client::session::{ClientSession, ClientSessionError, HandleQueryVaultState},
keyholder::KeyHolderState,
};
pub(super) async fn dispatch(
actor: &ActorRef<ClientSession>,
req: proto_vault::Request,
) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing client vault request payload",
));
};
match payload {
VaultRequestPayload::QueryState(()) => {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(ClientSessionError::Internal)) => {
ProtoVaultState::Error
}
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
};
Ok(ClientResponsePayload::Vault(proto_vault::Response {
payload: Some(VaultResponsePayload::State(state.into())),
}))
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod inbound;
pub mod outbound;

View File

@@ -0,0 +1,35 @@
use alloy::{consensus::TxEip1559, primitives::Address, rlp::Decodable as _};
use crate::grpc::TryConvert;
pub struct RawEvmAddress(pub Vec<u8>);
impl TryConvert for RawEvmAddress {
type Output = Address;
type Error = tonic::Status;
fn try_convert(self) -> Result<Self::Output, Self::Error> {
let wallet_address = match <[u8; 20]>::try_from(self.0.as_slice()) {
Ok(address) => Address::from(address),
Err(_) => {
return Err(tonic::Status::invalid_argument(
"Invalid EVM wallet address",
));
}
};
Ok(wallet_address)
}
}
pub struct RawEvmTransaction(pub Vec<u8>);
impl TryConvert for RawEvmTransaction {
type Output = TxEip1559;
type Error = tonic::Status;
fn try_convert(self) -> Result<Self::Output, Self::Error> {
let tx = TxEip1559::decode(&mut self.0.as_slice())
.map_err(|_| tonic::Status::invalid_argument("Invalid EVM transaction format"))?;
Ok(tx)
}
}

View File

@@ -0,0 +1,121 @@
use alloy::primitives::U256;
use arbiter_proto::proto::{
evm::{
EvmError as ProtoEvmError,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
eval_violation as proto_eval_violation, eval_violation::Kind as ProtoEvalViolationKind,
specific_meaning::Meaning as ProtoSpecificMeaningKind,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
},
};
use crate::{
evm::{
PolicyError, VetError,
policies::{EvalViolation, SpecificMeaning},
},
grpc::Convert,
};
fn u256_to_proto_bytes(value: U256) -> Vec<u8> {
value.to_be_bytes::<32>().to_vec()
}
impl Convert for SpecificMeaning {
type Output = ProtoSpecificMeaning;
fn convert(self) -> Self::Output {
let kind = match self {
Self::EtherTransfer(meaning) => ProtoSpecificMeaningKind::EtherTransfer(
arbiter_proto::proto::shared::evm::EtherTransferMeaning {
to: meaning.to.to_vec(),
value: u256_to_proto_bytes(meaning.value),
},
),
Self::TokenTransfer(meaning) => ProtoSpecificMeaningKind::TokenTransfer(
arbiter_proto::proto::shared::evm::TokenTransferMeaning {
token: Some(ProtoTokenInfo {
symbol: meaning.token.symbol.to_owned(),
address: meaning.token.contract.to_vec(),
chain_id: meaning.token.chain,
}),
to: meaning.to.to_vec(),
value: u256_to_proto_bytes(meaning.value),
},
),
};
ProtoSpecificMeaning {
meaning: Some(kind),
}
}
}
impl Convert for EvalViolation {
type Output = ProtoEvalViolation;
fn convert(self) -> Self::Output {
let kind = match self {
Self::InvalidTarget { target } => {
ProtoEvalViolationKind::InvalidTarget(target.to_vec())
}
Self::GasLimitExceeded {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
} => ProtoEvalViolationKind::GasLimitExceeded(GasLimitExceededViolation {
max_gas_fee_per_gas: max_gas_fee_per_gas.map(u256_to_proto_bytes),
max_priority_fee_per_gas: max_priority_fee_per_gas.map(u256_to_proto_bytes),
}),
Self::RateLimitExceeded => ProtoEvalViolationKind::RateLimitExceeded(()),
Self::VolumetricLimitExceeded => ProtoEvalViolationKind::VolumetricLimitExceeded(()),
Self::InvalidTime => ProtoEvalViolationKind::InvalidTime(()),
Self::InvalidTransactionType => ProtoEvalViolationKind::InvalidTransactionType(()),
Self::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
})
}
};
ProtoEvalViolation { kind: Some(kind) }
}
}
impl Convert for VetError {
type Output = EvmSignTransactionResult;
fn convert(self) -> Self::Output {
let kind = match self {
Self::ContractCreationNotSupported => {
ProtoTransactionEvalErrorKind::ContractCreationNotSupported(())
}
Self::UnsupportedTransactionType => {
ProtoTransactionEvalErrorKind::UnsupportedTransactionType(())
}
Self::Evaluated(meaning, policy_error) => match policy_error {
PolicyError::NoMatchingGrant => {
ProtoTransactionEvalErrorKind::NoMatchingGrant(NoMatchingGrantError {
meaning: Some(meaning.convert()),
})
}
PolicyError::Violations(violations) => {
ProtoTransactionEvalErrorKind::PolicyViolations(PolicyViolationsError {
meaning: Some(meaning.convert()),
violations: violations.into_iter().map(Convert::convert).collect(),
})
}
PolicyError::Database(_) | PolicyError::Integrity(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
}
},
};
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) })
}
}

View File

@@ -14,10 +14,13 @@ use crate::{
grpc::user_agent::start, grpc::user_agent::start,
}; };
pub mod client;
mod request_tracker; mod request_tracker;
pub mod client;
pub mod user_agent; pub mod user_agent;
mod common;
pub trait Convert { pub trait Convert {
type Output; type Output;

View File

@@ -20,7 +20,7 @@ impl RequestTracker {
// This is used to set the response id for auth responses, which need to match the request id of the auth challenge request. // This is used to set the response id for auth responses, which need to match the request id of the auth challenge request.
// -1 offset is needed because request() increments the next_request_id after returning the current request id. // -1 offset is needed because request() increments the next_request_id after returning the current request id.
pub fn current_request_id(&self) -> i32 { pub const fn current_request_id(&self) -> i32 {
self.next_request_id - 1 self.next_request_id - 1
} }
} }

View File

@@ -1,64 +1,29 @@
use tokio::sync::mpsc; use tokio::sync::mpsc;
use arbiter_proto::{ use arbiter_proto::{
proto::{ proto::user_agent::{
client::ClientInfo as ProtoClientMetadata, UserAgentRequest, UserAgentResponse,
evm::{ user_agent_request::Payload as UserAgentRequestPayload,
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse, user_agent_response::Payload as UserAgentResponsePayload,
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
GrantEntry, WalletCreateResponse, WalletEntry, WalletList, WalletListResponse,
evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_delete_response::Result as EvmGrantDeleteResult,
evm_grant_list_response::Result as EvmGrantListResult,
wallet_create_response::Result as WalletCreateResult,
wallet_list_response::Result as WalletListResult,
},
user_agent::{
BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
BootstrapResult as ProtoBootstrapResult, ListWalletAccessResponse,
SdkClientConnectionCancel as ProtoSdkClientConnectionCancel,
SdkClientConnectionRequest as ProtoSdkClientConnectionRequest,
SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError,
SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList,
SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess,
SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse,
VaultState as ProtoVaultState,
sdk_client_list_response::Result as ProtoSdkClientListResult,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
}, },
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use kameo::{ use kameo::actor::{ActorRef, Spawn as _};
actor::{ActorRef, Spawn as _},
error::SendError,
};
use tonic::Status; use tonic::Status;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
actors::{ actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
keyholder::KeyHolderState, grpc::request_tracker::RequestTracker,
user_agent::{
OutOfBand, UserAgentConnection, UserAgentSession,
session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate,
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess,
HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess,
HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
},
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
}; };
mod auth; mod auth;
mod evm;
mod inbound; mod inbound;
mod outbound; mod outbound;
mod sdk_client;
mod vault;
pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>); pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>);
@@ -86,23 +51,7 @@ async fn dispatch_loop(
return; return;
}; };
let payload = match oob { let payload = sdk_client::out_of_band_payload(oob);
OutOfBand::ClientConnectionRequest { profile } => {
UserAgentResponsePayload::SdkClientConnectionRequest(ProtoSdkClientConnectionRequest {
pubkey: profile.pubkey.to_bytes().to_vec(),
info: Some(ProtoClientMetadata {
name: profile.metadata.name,
description: profile.metadata.description,
version: profile.metadata.version,
}),
})
}
OutOfBand::ClientConnectionCancel { pubkey } => {
UserAgentResponsePayload::SdkClientConnectionCancel(ProtoSdkClientConnectionCancel {
pubkey: pubkey.to_bytes().to_vec(),
})
}
};
if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() { if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() {
return; return;
@@ -144,7 +93,7 @@ async fn dispatch_loop(
} }
Ok(None) => {} Ok(None) => {}
Err(status) => { Err(status) => {
error!(?status, "Failed to process user agent request"); error!(?status, "Failed to process user agent request");
let _ = bi.send(Err(status)).await; let _ = bi.send(Err(status)).await;
return; return;
} }
@@ -158,285 +107,15 @@ async fn dispatch_inner(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
payload: UserAgentRequestPayload, payload: UserAgentRequestPayload,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let response = match payload { match payload {
UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey }) => { UserAgentRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
let client_pubkey = <[u8; 32]>::try_from(client_pubkey) UserAgentRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
.map(x25519_dalek::PublicKey::from) UserAgentRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?; UserAgentRequestPayload::Auth(..) => {
warn!("Unsupported post-auth user agent auth request");
let response = actor Err(Status::invalid_argument("Unsupported user-agent request"))
.ask(HandleUnsealRequest { client_pubkey })
.await
.map_err(|err| {
warn!(error = ?err, "Failed to handle unseal start request");
Status::internal("Failed to start unseal flow")
})?;
UserAgentResponsePayload::UnsealStartResponse(
arbiter_proto::proto::user_agent::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
)
} }
}
UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey {
nonce,
ciphertext,
associated_data,
}) => {
let result = match actor
.ask(HandleUnsealEncryptedKey {
nonce,
ciphertext,
associated_data,
})
.await
{
Ok(()) => ProtoUnsealResult::Success,
Err(SendError::HandlerError(UnsealError::InvalidKey)) => {
ProtoUnsealResult::InvalidKey
}
Err(err) => {
warn!(error = ?err, "Failed to handle unseal request");
return Err(Status::internal("Failed to unseal vault"));
}
};
UserAgentResponsePayload::UnsealResult(result.into())
}
UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
nonce,
ciphertext,
associated_data,
}) => {
let result = match actor
.ask(HandleBootstrapEncryptedKey {
nonce,
ciphertext,
associated_data,
})
.await
{
Ok(()) => ProtoBootstrapResult::Success,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
ProtoBootstrapResult::InvalidKey
}
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(error = ?err, "Failed to handle bootstrap request");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
UserAgentResponsePayload::BootstrapResult(result.into())
}
UserAgentRequestPayload::QueryVaultState(_) => {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
};
UserAgentResponsePayload::VaultState(state.into())
}
UserAgentRequestPayload::EvmWalletCreate(_) => {
let result = match actor.ask(HandleEvmWalletCreate {}).await {
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
id: wallet_id,
address: address.to_vec(),
}),
Err(err) => {
warn!(error = ?err, "Failed to create EVM wallet");
WalletCreateResult::Error(ProtoEvmError::Internal.into())
}
};
UserAgentResponsePayload::EvmWalletCreate(WalletCreateResponse {
result: Some(result),
})
}
UserAgentRequestPayload::EvmWalletList(_) => {
let result = match actor.ask(HandleEvmWalletList {}).await {
Ok(wallets) => WalletListResult::Wallets(WalletList {
wallets: wallets
.into_iter()
.map(|(id, address)| WalletEntry {
address: address.to_vec(),
id,
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list EVM wallets");
WalletListResult::Error(ProtoEvmError::Internal.into())
}
};
UserAgentResponsePayload::EvmWalletList(WalletListResponse {
result: Some(result),
})
}
UserAgentRequestPayload::EvmGrantList(_) => {
let result = match actor.ask(HandleGrantList {}).await {
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
grants: grants
.into_iter()
.map(|grant| GrantEntry {
id: grant.id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list EVM grants");
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
}
};
UserAgentResponsePayload::EvmGrantList(EvmGrantListResponse {
result: Some(result),
})
}
UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => {
let basic = shared
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
.try_convert()?;
let grant = specific
.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?
.try_convert()?;
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(err) => {
warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
}
};
UserAgentResponsePayload::EvmGrantCreate(EvmGrantCreateResponse {
result: Some(result),
})
}
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
let result = match actor.ask(HandleGrantDelete { grant_id }).await {
Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
}
};
UserAgentResponsePayload::EvmGrantDelete(EvmGrantDeleteResponse {
result: Some(result),
})
}
UserAgentRequestPayload::SdkClientConnectionResponse(resp) => {
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?;
actor
.ask(HandleNewClientApprove {
approved: resp.approved,
pubkey,
})
.await
.map_err(|err| {
warn!(?err, "Failed to process client connection response");
Status::internal("Failed to process response")
})?;
return Ok(None);
}
UserAgentRequestPayload::SdkClientRevoke(_) => todo!(),
UserAgentRequestPayload::SdkClientList(_) => {
let result = match actor.ask(HandleSdkClientList {}).await {
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
clients: clients
.into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id,
pubkey: client.public_key,
info: Some(ProtoClientMetadata {
name: metadata.name,
description: metadata.description,
version: metadata.version,
}),
created_at: client.created_at.0.timestamp() as i32,
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list SDK clients");
ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into())
}
};
UserAgentResponsePayload::SdkClientListResponse(ProtoSdkClientListResponse {
result: Some(result),
})
}
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
let entries: Vec<NewEvmWalletAccess> =
accesses.into_iter().map(|a| a.convert()).collect();
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
Ok(()) => {
info!("Successfully granted wallet access");
return Ok(None);
}
Err(err) => {
warn!(error = ?err, "Failed to grant wallet access");
return Err(Status::internal("Failed to grant wallet access"));
}
}
}
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
match actor.ask(HandleRevokeEvmWalletAccess { entries: accesses }).await {
Ok(()) => {
info!("Successfully revoked wallet access");
return Ok(None);
}
Err(err) => {
warn!(error = ?err, "Failed to revoke wallet access");
return Err(Status::internal("Failed to revoke wallet access"));
}
}
}
UserAgentRequestPayload::ListWalletAccess(_) => {
let result = match actor.ask(HandleListWalletAccess {}).await {
Ok(accesses) => ListWalletAccessResponse {
accesses: accesses.into_iter().map(|a| a.convert()).collect(),
},
Err(err) => {
warn!(error = ?err, "Failed to list wallet access");
return Err(Status::internal("Failed to list wallet access"));
}
};
UserAgentResponsePayload::ListWalletAccessResponse(result)
}
UserAgentRequestPayload::AuthChallengeRequest(..)
| UserAgentRequestPayload::AuthChallengeSolution(..) => {
warn!(?payload, "Unsupported post-auth user agent request");
return Err(Status::invalid_argument("Unsupported user-agent request"));
}
};
Ok(Some(response))
} }
pub async fn start( pub async fn start(

View File

@@ -1,8 +1,13 @@
use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
proto::user_agent::{ proto::user_agent::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest, UserAgentRequest, UserAgentResponse,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, auth::{
KeyType as ProtoKeyType, UserAgentRequest, UserAgentResponse, self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
},
user_agent_request::Payload as UserAgentRequestPayload, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}, },
@@ -13,8 +18,7 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::user_agent::{AuthPublicKey, UserAgentConnection, auth}, actors::user_agent::{UserAgentConnection, auth},
db::models::KeyType,
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
}; };
@@ -24,7 +28,7 @@ pub struct AuthTransportAdapter<'a> {
} }
impl<'a> AuthTransportAdapter<'a> { impl<'a> AuthTransportAdapter<'a> {
pub fn new( pub const fn new(
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>, bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &'a mut RequestTracker, request_tracker: &'a mut RequestTracker,
) -> Self { ) -> Self {
@@ -36,12 +40,14 @@ impl<'a> AuthTransportAdapter<'a> {
async fn send_user_agent_response( async fn send_user_agent_response(
&mut self, &mut self,
payload: UserAgentResponsePayload, payload: AuthResponsePayload,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
self.bi self.bi
.send(Ok(UserAgentResponse { .send(Ok(UserAgentResponse {
id: Some(self.request_tracker.current_request_id()), id: Some(self.request_tracker.current_request_id()),
payload: Some(payload), payload: Some(UserAgentResponsePayload::Auth(proto_auth::Response {
payload: Some(payload),
})),
})) }))
.await .await
} }
@@ -56,19 +62,19 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
use auth::{Error, Outbound}; use auth::{Error, Outbound};
let payload = match item { let payload = match item {
Ok(Outbound::AuthChallenge { nonce }) => { Ok(Outbound::AuthChallenge { nonce }) => {
UserAgentResponsePayload::AuthChallenge(ProtoAuthChallenge { nonce }) AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce })
} }
Ok(Outbound::AuthSuccess) => { Ok(Outbound::AuthSuccess) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::Success.into()) AuthResponsePayload::Result(ProtoAuthResult::Success.into())
} }
Err(Error::UnregisteredPublicKey) => { Err(Error::UnregisteredPublicKey) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::InvalidKey.into()) AuthResponsePayload::Result(ProtoAuthResult::InvalidKey.into())
} }
Err(Error::InvalidChallengeSolution) => { Err(Error::InvalidChallengeSolution) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::InvalidSignature.into()) AuthResponsePayload::Result(ProtoAuthResult::InvalidSignature.into())
} }
Err(Error::InvalidBootstrapToken) => { Err(Error::InvalidBootstrapToken) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::TokenInvalid.into()) AuthResponsePayload::Result(ProtoAuthResult::TokenInvalid.into())
} }
Err(Error::Internal { details }) => { Err(Error::Internal { details }) => {
return self.bi.send(Err(Status::internal(details))).await; return self.bi.send(Err(Status::internal(details))).await;
@@ -112,32 +118,31 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
return None; return None;
}; };
let UserAgentRequestPayload::Auth(auth_request) = payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported user-agent auth request",
)))
.await;
return None;
};
let Some(payload) = auth_request.payload else {
warn!(
event = "received auth request with empty payload",
"grpc.useragent.auth_adapter"
);
return None;
};
match payload { match payload {
UserAgentRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
pubkey, pubkey,
bootstrap_token, bootstrap_token,
key_type, ..
}) => { }) => {
let Ok(key_type) = ProtoKeyType::try_from(key_type) else { let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else {
warn!(
event = "received request with invalid key type",
"grpc.useragent.auth_adapter"
);
return None;
};
let key_type = match key_type {
ProtoKeyType::Ed25519 => KeyType::Ed25519,
ProtoKeyType::EcdsaSecp256k1 => KeyType::EcdsaSecp256k1,
ProtoKeyType::Rsa => KeyType::Rsa,
ProtoKeyType::Unspecified => {
warn!(
event = "received request with unspecified key type",
"grpc.useragent.auth_adapter"
);
return None;
}
};
let Ok(pubkey) = AuthPublicKey::try_from((key_type, pubkey)) else {
warn!( warn!(
event = "received request with invalid public key", event = "received request with invalid public key",
"grpc.useragent.auth_adapter" "grpc.useragent.auth_adapter"
@@ -150,17 +155,8 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
bootstrap_token, bootstrap_token,
}) })
} }
UserAgentRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
signature, Some(auth::Inbound::AuthChallengeSolution { signature })
}) => Some(auth::Inbound::AuthChallengeSolution { signature }),
_ => {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported user-agent auth request",
)))
.await;
None
} }
} }
} }
@@ -172,7 +168,7 @@ pub async fn start(
conn: &mut UserAgentConnection, conn: &mut UserAgentConnection,
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>, bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &mut RequestTracker, request_tracker: &mut RequestTracker,
) -> Result<AuthPublicKey, auth::Error> { ) -> Result<authn::PublicKey, auth::Error> {
let transport = AuthTransportAdapter::new(bi, request_tracker); let transport = AuthTransportAdapter::new(bi, request_tracker);
auth::authenticate(conn, transport).await auth::authenticate(conn, transport).await
} }

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