Compare commits
4 Commits
Client-key
...
aeed664e9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeed664e9a | ||
|
|
4057c1fc12 | ||
|
|
f5eb51978d | ||
|
|
d997e0f843 |
205
ARCHITECTURE.md
205
ARCHITECTURE.md
@@ -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:00–20:00 on Mondays and Thursdays.
|
- **Time-window restrictions** — e.g., signing allowed only 08:00–20:00 on Mondays and Thursdays.
|
||||||
|
|||||||
@@ -128,6 +128,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 |
|
||||||
|
|||||||
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -744,6 +744,7 @@ dependencies = [
|
|||||||
"kameo",
|
"kameo",
|
||||||
"memsafe",
|
"memsafe",
|
||||||
"pem",
|
"pem",
|
||||||
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
|||||||
rsa = { version = "0.9", features = ["sha2"] }
|
rsa = { version = "0.9", features = ["sha2"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
spki = "0.7"
|
spki = "0.7"
|
||||||
|
prost = "0.14.3"
|
||||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||||
@@ -11,7 +11,7 @@ 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
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ pub mod proto {
|
|||||||
pub mod evm {
|
pub mod evm {
|
||||||
tonic::include_proto!("arbiter.evm");
|
tonic::include_proto!("arbiter.evm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod integrity {
|
||||||
|
tonic::include_proto!("arbiter.integrity");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ hmac = "0.12"
|
|||||||
spki.workspace = true
|
spki.workspace = true
|
||||||
alloy.workspace = true
|
alloy.workspace = true
|
||||||
prost-types.workspace = true
|
prost-types.workspace = true
|
||||||
|
prost.workspace = true
|
||||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
|
||||||
|
|||||||
@@ -192,3 +192,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);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use kameo::{Actor, actor::ActorRef, messages};
|
|||||||
use rand::{SeedableRng, rng, rngs::StdRng};
|
use rand::{SeedableRng, rng, rngs::StdRng};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState},
|
||||||
db::{
|
db::{
|
||||||
DatabaseError, DatabasePool,
|
DatabaseError, DatabasePool,
|
||||||
models::{self, SqliteTimestamp},
|
models::{self, SqliteTimestamp},
|
||||||
@@ -20,6 +20,7 @@ use crate::{
|
|||||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
integrity,
|
||||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
Database(#[from] DatabaseError),
|
Database(#[from] DatabaseError),
|
||||||
|
|
||||||
|
#[error("Vault is sealed")]
|
||||||
|
#[diagnostic(code(arbiter::evm::vault_sealed))]
|
||||||
|
VaultSealed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Actor)]
|
#[derive(Actor)]
|
||||||
@@ -71,7 +76,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,
|
||||||
@@ -79,6 +84,20 @@ impl EvmActor {
|
|||||||
engine,
|
engine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_unsealed(&self) -> Result<(), Error> {
|
||||||
|
let state = self
|
||||||
|
.keyholder
|
||||||
|
.ask(GetState)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::KeyholderSend)?;
|
||||||
|
|
||||||
|
if state != KeyHolderState::Unsealed {
|
||||||
|
return Err(Error::VaultSealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[messages]
|
#[messages]
|
||||||
@@ -132,7 +151,9 @@ impl EvmActor {
|
|||||||
&mut self,
|
&mut self,
|
||||||
basic: SharedGrantSettings,
|
basic: SharedGrantSettings,
|
||||||
grant: SpecificGrant,
|
grant: SpecificGrant,
|
||||||
) -> Result<i32, DatabaseError> {
|
) -> Result<i32, Error> {
|
||||||
|
self.ensure_unsealed().await?;
|
||||||
|
|
||||||
match grant {
|
match grant {
|
||||||
SpecificGrant::EtherTransfer(settings) => {
|
SpecificGrant::EtherTransfer(settings) => {
|
||||||
self.engine
|
self.engine
|
||||||
@@ -141,6 +162,7 @@ impl EvmActor {
|
|||||||
specific: settings,
|
specific: settings,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
SpecificGrant::TokenTransfer(settings) => {
|
SpecificGrant::TokenTransfer(settings) => {
|
||||||
self.engine
|
self.engine
|
||||||
@@ -149,29 +171,43 @@ impl EvmActor {
|
|||||||
specific: settings,
|
specific: settings,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
||||||
|
self.ensure_unsealed().await?;
|
||||||
|
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
diesel::update(schema::evm_basic_grant::table)
|
let keyholder = self.keyholder.clone();
|
||||||
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
|
||||||
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
|
||||||
.execute(&mut conn)
|
Box::pin(async move {
|
||||||
.await
|
diesel::update(schema::evm_basic_grant::table)
|
||||||
.map_err(DatabaseError::from)?;
|
.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?;
|
||||||
|
integrity::sign_entity(conn, &keyholder, &signed)
|
||||||
|
.await
|
||||||
|
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||||
|
|
||||||
|
diesel::result::QueryResult::Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||||
Ok(self
|
Ok(self.engine.list_all_grants().await?)
|
||||||
.engine
|
|
||||||
.list_all_grants()
|
|
||||||
.await
|
|
||||||
.map_err(DatabaseError::from)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ use diesel::{
|
|||||||
dsl::{insert_into, update},
|
dsl::{insert_into, update},
|
||||||
};
|
};
|
||||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
use hmac::{Hmac, Mac as _};
|
||||||
use kameo::{Actor, Reply, messages};
|
use kameo::{Actor, Reply, messages};
|
||||||
|
use sha2::Sha256;
|
||||||
use strum::{EnumDiscriminants, IntoDiscriminant};
|
use strum::{EnumDiscriminants, IntoDiscriminant};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
@@ -24,6 +26,13 @@ use crate::{
|
|||||||
},
|
},
|
||||||
safe_cell::SafeCellHandle as _,
|
safe_cell::SafeCellHandle as _,
|
||||||
};
|
};
|
||||||
|
use encryption::v1::{self, KeyCell, Nonce};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
|
||||||
|
|
||||||
|
pub mod encryption;
|
||||||
|
|
||||||
#[derive(Default, EnumDiscriminants)]
|
#[derive(Default, EnumDiscriminants)]
|
||||||
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
|
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
|
||||||
@@ -133,6 +142,19 @@ impl KeyHolder {
|
|||||||
Ok(nonce)
|
Ok(nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn derive_integrity_key(root_key: &mut KeyCell) -> [u8; 32] {
|
||||||
|
root_key.0.read_inline(|root_key_bytes| {
|
||||||
|
let mut hmac = match HmacSha256::new_from_slice(root_key_bytes.as_slice()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||||
|
};
|
||||||
|
hmac.update(INTEGRITY_SUBKEY_TAG);
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
out.copy_from_slice(&hmac.finalize().into_bytes());
|
||||||
|
out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||||
if !matches!(self.state, State::Unbootstrapped) {
|
if !matches!(self.state, State::Unbootstrapped) {
|
||||||
@@ -339,6 +361,59 @@ impl KeyHolder {
|
|||||||
self.state.discriminant()
|
self.state.discriminant()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
|
||||||
|
let State::Unsealed {
|
||||||
|
root_key,
|
||||||
|
root_key_history_id,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotBootstrapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
let integrity_key = Self::derive_integrity_key(root_key);
|
||||||
|
|
||||||
|
let mut hmac = match HmacSha256::new_from_slice(&integrity_key) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||||
|
};
|
||||||
|
hmac.update(&root_key_history_id.to_be_bytes());
|
||||||
|
hmac.update(&mac_input);
|
||||||
|
|
||||||
|
let mac = hmac.finalize().into_bytes().to_vec();
|
||||||
|
Ok((*root_key_history_id, mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub fn verify_integrity(
|
||||||
|
&mut self,
|
||||||
|
mac_input: Vec<u8>,
|
||||||
|
expected_mac: Vec<u8>,
|
||||||
|
key_version: i32,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let State::Unsealed {
|
||||||
|
root_key,
|
||||||
|
root_key_history_id,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotBootstrapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
if *root_key_history_id != key_version {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let integrity_key = Self::derive_integrity_key(root_key);
|
||||||
|
let mut hmac = match HmacSha256::new_from_slice(&integrity_key) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||||
|
};
|
||||||
|
hmac.update(&key_version.to_be_bytes());
|
||||||
|
hmac.update(&mac_input);
|
||||||
|
|
||||||
|
Ok(hmac.verify_slice(&expected_mac).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub fn seal(&mut self) -> Result<(), Error> {
|
pub fn seal(&mut self) -> Result<(), Error> {
|
||||||
let State::Unsealed {
|
let State::Unsealed {
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ pub enum SignTransactionError {
|
|||||||
Internal,
|
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]
|
||||||
@@ -331,7 +340,7 @@ impl UserAgentSession {
|
|||||||
&mut self,
|
&mut self,
|
||||||
basic: crate::evm::policies::SharedGrantSettings,
|
basic: crate::evm::policies::SharedGrantSettings,
|
||||||
grant: crate::evm::policies::SpecificGrant,
|
grant: crate::evm::policies::SpecificGrant,
|
||||||
) -> Result<i32, Error> {
|
) -> Result<i32, GrantMutationError> {
|
||||||
match self
|
match self
|
||||||
.props
|
.props
|
||||||
.actors
|
.actors
|
||||||
@@ -340,15 +349,21 @@ impl UserAgentSession {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(grant_id) => Ok(grant_id),
|
Ok(grant_id) => Ok(grant_id),
|
||||||
|
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
|
||||||
|
Err(GrantMutationError::VaultSealed)
|
||||||
|
}
|
||||||
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> {
|
pub(crate) async fn handle_grant_delete(
|
||||||
|
&mut self,
|
||||||
|
grant_id: i32,
|
||||||
|
) -> Result<(), GrantMutationError> {
|
||||||
match self
|
match self
|
||||||
.props
|
.props
|
||||||
.actors
|
.actors
|
||||||
@@ -357,9 +372,12 @@ impl UserAgentSession {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
|
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
|
||||||
|
Err(GrantMutationError::VaultSealed)
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!(?err, "EVM grant delete failed");
|
error!(?err, "EVM grant delete failed");
|
||||||
Err(Error::internal("Failed to delete EVM grant"))
|
Err(GrantMutationError::Internal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::db::schema::{
|
|||||||
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
|
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
|
||||||
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
|
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
|
||||||
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
|
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
|
||||||
root_key_history, tls_history,
|
integrity_envelope, root_key_history, tls_history,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use diesel::{prelude::*, sqlite::Sqlite};
|
use diesel::{prelude::*, sqlite::Sqlite};
|
||||||
@@ -377,3 +377,22 @@ pub struct EvmTokenTransferLog {
|
|||||||
pub value: Vec<u8>,
|
pub value: Vec<u8>,
|
||||||
pub created_at: SqliteTimestamp,
|
pub created_at: SqliteTimestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||||
|
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
|
||||||
|
#[view(
|
||||||
|
NewIntegrityEnvelope,
|
||||||
|
derive(Insertable),
|
||||||
|
omit(id, signed_at, created_at),
|
||||||
|
attributes_with = "deriveless"
|
||||||
|
)]
|
||||||
|
pub struct IntegrityEnvelope {
|
||||||
|
pub id: i32,
|
||||||
|
pub entity_kind: String,
|
||||||
|
pub entity_id: Vec<u8>,
|
||||||
|
pub payload_version: i32,
|
||||||
|
pub key_version: i32,
|
||||||
|
pub mac: Vec<u8>,
|
||||||
|
pub signed_at: SqliteTimestamp,
|
||||||
|
pub created_at: SqliteTimestamp,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -220,6 +233,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,
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ use alloy::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
||||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
use kameo::actor::ActorRef;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
actors::keyholder::KeyHolder,
|
||||||
db::{
|
db::{
|
||||||
self, DatabaseError,
|
self, DatabaseError,
|
||||||
models::{
|
models::{
|
||||||
@@ -22,6 +24,7 @@ use crate::{
|
|||||||
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
|
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
|
||||||
token_transfers::TokenTransfer,
|
token_transfers::TokenTransfer,
|
||||||
},
|
},
|
||||||
|
integrity,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod policies;
|
pub mod policies;
|
||||||
@@ -36,6 +39,10 @@ pub enum PolicyError {
|
|||||||
Violations(Vec<EvalViolation>),
|
Violations(Vec<EvalViolation>),
|
||||||
#[error("No matching grant found")]
|
#[error("No matching grant found")]
|
||||||
NoMatchingGrant,
|
NoMatchingGrant,
|
||||||
|
|
||||||
|
#[error("Integrity error: {0}")]
|
||||||
|
#[diagnostic(code(arbiter_server::evm::policy_error::integrity))]
|
||||||
|
Integrity(#[from] integrity::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -115,6 +122,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 {
|
||||||
@@ -123,7 +131,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)
|
||||||
@@ -131,6 +142,14 @@ impl Engine {
|
|||||||
.map_err(DatabaseError::from)?
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(PolicyError::NoMatchingGrant)?;
|
.ok_or(PolicyError::NoMatchingGrant)?;
|
||||||
|
|
||||||
|
let signed_grant = integrity::evm::SignedEvmGrant::from_active_grant(&Grant {
|
||||||
|
id: grant.id,
|
||||||
|
shared_grant_id: grant.shared_grant_id,
|
||||||
|
shared: grant.shared.clone(),
|
||||||
|
settings: grant.settings.clone().into(),
|
||||||
|
});
|
||||||
|
integrity::verify_entity(&mut conn, &self.keyholder, &signed_grant).await?;
|
||||||
|
|
||||||
let mut violations =
|
let mut violations =
|
||||||
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
||||||
.await
|
.await
|
||||||
@@ -143,7 +162,9 @@ 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)
|
||||||
@@ -172,15 +193,19 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
pub fn new(db: db::DatabasePool) -> Self {
|
pub 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: FullGrant<P::Settings>,
|
||||||
) -> Result<i32, DatabaseError> {
|
) -> Result<i32, DatabaseError>
|
||||||
|
where
|
||||||
|
P::Settings: Clone,
|
||||||
|
{
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
let keyholder = self.keyholder.clone();
|
||||||
|
|
||||||
let id = conn
|
let id = conn
|
||||||
.transaction(|conn| {
|
.transaction(|conn| {
|
||||||
@@ -217,7 +242,20 @@ 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?;
|
||||||
|
|
||||||
|
let signed_grant = integrity::evm::SignedEvmGrant {
|
||||||
|
basic_grant_id: basic_grant.id,
|
||||||
|
shared: full_grant.basic.clone(),
|
||||||
|
specific: full_grant.specific.clone().into(),
|
||||||
|
revoked_at: basic_grant.revoked_at.map(Into::into),
|
||||||
|
};
|
||||||
|
|
||||||
|
integrity::sign_entity(conn, &keyholder, &signed_grant)
|
||||||
|
.await
|
||||||
|
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||||
|
|
||||||
|
QueryResult::Ok(basic_grant.id)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -253,6 +291,16 @@ impl Engine {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for grant in &grants {
|
||||||
|
let signed = integrity::evm::SignedEvmGrant::from_active_grant(grant);
|
||||||
|
integrity::verify_entity(&mut conn, &self.keyholder, &signed)
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
integrity::Error::Database(db_err) => db_err,
|
||||||
|
_ => DatabaseError::Connection(diesel::result::Error::RollbackTransaction),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(grants)
|
Ok(grants)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ 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 as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ impl Convert for VetError {
|
|||||||
violations: violations.into_iter().map(Convert::convert).collect(),
|
violations: violations.into_iter().map(Convert::convert).collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
PolicyError::Database(_) => {
|
PolicyError::Database(_)| PolicyError::Integrity(_) => {
|
||||||
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
|
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
|
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use crate::{
|
|||||||
actors::user_agent::{
|
actors::user_agent::{
|
||||||
UserAgentSession,
|
UserAgentSession,
|
||||||
session::connection::{
|
session::connection::{
|
||||||
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
|
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
|
||||||
HandleGrantList, HandleSignTransaction,
|
HandleGrantDelete, HandleGrantList, HandleSignTransaction,
|
||||||
SignTransactionError as SessionSignTransactionError,
|
SignTransactionError as SessionSignTransactionError,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@ async fn handle_grant_list(
|
|||||||
grants: grants
|
grants: grants
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|grant| GrantEntry {
|
.map(|grant| GrantEntry {
|
||||||
id: grant.id,
|
id: grant.shared_grant_id,
|
||||||
wallet_access_id: grant.shared.wallet_access_id,
|
wallet_access_id: grant.shared.wallet_access_id,
|
||||||
shared: Some(grant.shared.convert()),
|
shared: Some(grant.shared.convert()),
|
||||||
specific: Some(grant.settings.convert()),
|
specific: Some(grant.settings.convert()),
|
||||||
@@ -148,6 +148,9 @@ async fn handle_grant_create(
|
|||||||
|
|
||||||
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
|
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
|
||||||
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
|
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
|
||||||
|
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
|
||||||
|
EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into())
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = ?err, "Failed to create EVM grant");
|
warn!(error = ?err, "Failed to create EVM grant");
|
||||||
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
|
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
|
||||||
@@ -171,6 +174,9 @@ async fn handle_grant_delete(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => EvmGrantDeleteResult::Ok(()),
|
Ok(()) => EvmGrantDeleteResult::Ok(()),
|
||||||
|
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
|
||||||
|
EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into())
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = ?err, "Failed to delete EVM grant");
|
warn!(error = ?err, "Failed to delete EVM grant");
|
||||||
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
|
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
|
||||||
|
|||||||
336
server/crates/arbiter-server/src/integrity/evm.rs
Normal file
336
server/crates/arbiter-server/src/integrity/evm.rs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
use alloy::primitives::Address;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use diesel::sqlite::Sqlite;
|
||||||
|
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, SelectableHelper as _};
|
||||||
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
use prost::Message;
|
||||||
|
use prost_types::Timestamp;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{models, schema},
|
||||||
|
evm::policies::{Grant, SharedGrantSettings, SpecificGrant, VolumeRateLimit},
|
||||||
|
integrity::IntegrityEntity,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const EVM_GRANT_ENTITY_KIND: &str = "evm_grant";
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegrityVolumeRateLimit {
|
||||||
|
#[prost(bytes, tag = "1")]
|
||||||
|
pub max_volume: Vec<u8>,
|
||||||
|
#[prost(int64, tag = "2")]
|
||||||
|
pub window_secs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegrityTransactionRateLimit {
|
||||||
|
#[prost(uint32, tag = "1")]
|
||||||
|
pub count: u32,
|
||||||
|
#[prost(int64, tag = "2")]
|
||||||
|
pub window_secs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegritySharedGrantSettings {
|
||||||
|
#[prost(int32, tag = "1")]
|
||||||
|
pub wallet_access_id: i32,
|
||||||
|
#[prost(uint64, tag = "2")]
|
||||||
|
pub chain_id: u64,
|
||||||
|
#[prost(message, optional, tag = "3")]
|
||||||
|
pub valid_from: Option<::prost_types::Timestamp>,
|
||||||
|
#[prost(message, optional, tag = "4")]
|
||||||
|
pub valid_until: Option<::prost_types::Timestamp>,
|
||||||
|
#[prost(bytes, optional, tag = "5")]
|
||||||
|
pub max_gas_fee_per_gas: Option<Vec<u8>>,
|
||||||
|
#[prost(bytes, optional, tag = "6")]
|
||||||
|
pub max_priority_fee_per_gas: Option<Vec<u8>>,
|
||||||
|
#[prost(message, optional, tag = "7")]
|
||||||
|
pub rate_limit: Option<IntegrityTransactionRateLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegrityEtherTransferSettings {
|
||||||
|
#[prost(bytes, repeated, tag = "1")]
|
||||||
|
pub targets: Vec<Vec<u8>>,
|
||||||
|
#[prost(message, optional, tag = "2")]
|
||||||
|
pub limit: Option<IntegrityVolumeRateLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegrityTokenTransferSettings {
|
||||||
|
#[prost(bytes, tag = "1")]
|
||||||
|
pub token_contract: Vec<u8>,
|
||||||
|
#[prost(bytes, optional, tag = "2")]
|
||||||
|
pub target: Option<Vec<u8>>,
|
||||||
|
#[prost(message, repeated, tag = "3")]
|
||||||
|
pub volume_limits: Vec<IntegrityVolumeRateLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegritySpecificGrant {
|
||||||
|
#[prost(oneof = "integrity_specific_grant::Grant", tags = "1, 2")]
|
||||||
|
pub grant: Option<integrity_specific_grant::Grant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod integrity_specific_grant {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Oneof)]
|
||||||
|
pub enum Grant {
|
||||||
|
#[prost(message, tag = "1")]
|
||||||
|
EtherTransfer(IntegrityEtherTransferSettings),
|
||||||
|
#[prost(message, tag = "2")]
|
||||||
|
TokenTransfer(IntegrityTokenTransferSettings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct IntegrityEvmGrantPayloadV1 {
|
||||||
|
#[prost(int32, tag = "1")]
|
||||||
|
pub basic_grant_id: i32,
|
||||||
|
#[prost(message, optional, tag = "2")]
|
||||||
|
pub shared: Option<IntegritySharedGrantSettings>,
|
||||||
|
#[prost(message, optional, tag = "3")]
|
||||||
|
pub specific: Option<IntegritySpecificGrant>,
|
||||||
|
#[prost(message, optional, tag = "4")]
|
||||||
|
pub revoked_at: Option<::prost_types::Timestamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SignedEvmGrant {
|
||||||
|
pub basic_grant_id: i32,
|
||||||
|
pub shared: SharedGrantSettings,
|
||||||
|
pub specific: SpecificGrant,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedEvmGrant {
|
||||||
|
pub fn from_active_grant(grant: &Grant<SpecificGrant>) -> Self {
|
||||||
|
Self {
|
||||||
|
basic_grant_id: grant.shared_grant_id,
|
||||||
|
shared: grant.shared.clone(),
|
||||||
|
specific: grant.settings.clone(),
|
||||||
|
revoked_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp(value: DateTime<Utc>) -> Timestamp {
|
||||||
|
Timestamp {
|
||||||
|
seconds: value.timestamp(),
|
||||||
|
nanos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_shared(shared: &SharedGrantSettings) -> IntegritySharedGrantSettings {
|
||||||
|
IntegritySharedGrantSettings {
|
||||||
|
wallet_access_id: shared.wallet_access_id,
|
||||||
|
chain_id: shared.chain,
|
||||||
|
valid_from: shared.valid_from.map(timestamp),
|
||||||
|
valid_until: shared.valid_until.map(timestamp),
|
||||||
|
max_gas_fee_per_gas: shared
|
||||||
|
.max_gas_fee_per_gas
|
||||||
|
.map(|v| v.to_le_bytes::<32>().to_vec()),
|
||||||
|
max_priority_fee_per_gas: shared
|
||||||
|
.max_priority_fee_per_gas
|
||||||
|
.map(|v| v.to_le_bytes::<32>().to_vec()),
|
||||||
|
rate_limit: shared
|
||||||
|
.rate_limit
|
||||||
|
.as_ref()
|
||||||
|
.map(|rl| IntegrityTransactionRateLimit {
|
||||||
|
count: rl.count,
|
||||||
|
window_secs: rl.window.num_seconds(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_volume_limit(limit: &VolumeRateLimit) -> IntegrityVolumeRateLimit {
|
||||||
|
IntegrityVolumeRateLimit {
|
||||||
|
max_volume: limit.max_volume.to_le_bytes::<32>().to_vec(),
|
||||||
|
window_secs: limit.window.num_seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<alloy::primitives::U256> {
|
||||||
|
let bytes: [u8; 32] = bytes.try_into().map_err(|_| {
|
||||||
|
diesel::result::Error::DeserializationError(
|
||||||
|
format!("Expected 32-byte U256 payload, got {}", bytes.len()).into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(alloy::primitives::U256::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_specific(specific: &SpecificGrant) -> IntegritySpecificGrant {
|
||||||
|
let grant = match specific {
|
||||||
|
SpecificGrant::EtherTransfer(settings) => {
|
||||||
|
let mut targets: Vec<Vec<u8>> =
|
||||||
|
settings.target.iter().map(|addr| addr.to_vec()).collect();
|
||||||
|
targets.sort_unstable();
|
||||||
|
|
||||||
|
integrity_specific_grant::Grant::EtherTransfer(IntegrityEtherTransferSettings {
|
||||||
|
targets,
|
||||||
|
limit: Some(encode_volume_limit(&settings.limit)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SpecificGrant::TokenTransfer(settings) => {
|
||||||
|
let mut volume_limits: Vec<IntegrityVolumeRateLimit> = settings
|
||||||
|
.volume_limits
|
||||||
|
.iter()
|
||||||
|
.map(encode_volume_limit)
|
||||||
|
.collect();
|
||||||
|
volume_limits.sort_by(|left, right| {
|
||||||
|
left.window_secs
|
||||||
|
.cmp(&right.window_secs)
|
||||||
|
.then_with(|| left.max_volume.cmp(&right.max_volume))
|
||||||
|
});
|
||||||
|
|
||||||
|
integrity_specific_grant::Grant::TokenTransfer(IntegrityTokenTransferSettings {
|
||||||
|
token_contract: settings.token_contract.to_vec(),
|
||||||
|
target: settings.target.map(|a| a.to_vec()),
|
||||||
|
volume_limits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
IntegritySpecificGrant { grant: Some(grant) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntegrityEntity for SignedEvmGrant {
|
||||||
|
fn entity_kind(&self) -> &'static str {
|
||||||
|
EVM_GRANT_ENTITY_KIND
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entity_id_bytes(&self) -> Vec<u8> {
|
||||||
|
self.basic_grant_id.to_be_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_version(&self) -> i32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_payload_bytes(&self) -> Vec<u8> {
|
||||||
|
IntegrityEvmGrantPayloadV1 {
|
||||||
|
basic_grant_id: self.basic_grant_id,
|
||||||
|
shared: Some(encode_shared(&self.shared)),
|
||||||
|
specific: Some(encode_specific(&self.specific)),
|
||||||
|
revoked_at: self.revoked_at.map(timestamp),
|
||||||
|
}
|
||||||
|
.encode_to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_signed_grant_by_basic_id(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
basic_grant_id: i32,
|
||||||
|
) -> diesel::result::QueryResult<SignedEvmGrant> {
|
||||||
|
let basic: models::EvmBasicGrant = schema::evm_basic_grant::table
|
||||||
|
.filter(schema::evm_basic_grant::id.eq(basic_grant_id))
|
||||||
|
.select(models::EvmBasicGrant::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let specific_token: Option<models::EvmTokenTransferGrant> =
|
||||||
|
schema::evm_token_transfer_grant::table
|
||||||
|
.filter(schema::evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
|
||||||
|
.select(models::EvmTokenTransferGrant::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.await
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
let revoked_at = basic.revoked_at.clone().map(Into::into);
|
||||||
|
let shared = SharedGrantSettings::try_from_model(basic)?;
|
||||||
|
|
||||||
|
if let Some(token) = specific_token {
|
||||||
|
let limits: Vec<models::EvmTokenTransferVolumeLimit> =
|
||||||
|
schema::evm_token_transfer_volume_limit::table
|
||||||
|
.filter(schema::evm_token_transfer_volume_limit::grant_id.eq(token.id))
|
||||||
|
.select(models::EvmTokenTransferVolumeLimit::as_select())
|
||||||
|
.load(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let token_contract: [u8; 20] = token.token_contract.try_into().map_err(|_| {
|
||||||
|
diesel::result::Error::DeserializationError(
|
||||||
|
"Invalid token contract address length".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let target = match token.receiver {
|
||||||
|
None => None,
|
||||||
|
Some(bytes) => {
|
||||||
|
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
|
||||||
|
diesel::result::Error::DeserializationError(
|
||||||
|
"Invalid receiver address length".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Some(Address::from(arr))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let volume_limits = limits
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
Ok(VolumeRateLimit {
|
||||||
|
max_volume: try_bytes_to_u256(&row.max_volume)?,
|
||||||
|
window: chrono::Duration::seconds(row.window_secs as i64),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<diesel::result::QueryResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
return Ok(SignedEvmGrant {
|
||||||
|
basic_grant_id,
|
||||||
|
shared,
|
||||||
|
specific: SpecificGrant::TokenTransfer(
|
||||||
|
crate::evm::policies::token_transfers::Settings {
|
||||||
|
token_contract: Address::from(token_contract),
|
||||||
|
target,
|
||||||
|
volume_limits,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
revoked_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ether: models::EvmEtherTransferGrant = schema::evm_ether_transfer_grant::table
|
||||||
|
.filter(schema::evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
|
||||||
|
.select(models::EvmEtherTransferGrant::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let targets_rows: Vec<models::EvmEtherTransferGrantTarget> =
|
||||||
|
schema::evm_ether_transfer_grant_target::table
|
||||||
|
.filter(schema::evm_ether_transfer_grant_target::grant_id.eq(ether.id))
|
||||||
|
.select(models::EvmEtherTransferGrantTarget::as_select())
|
||||||
|
.load(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let limit: models::EvmEtherTransferLimit = schema::evm_ether_transfer_limit::table
|
||||||
|
.filter(schema::evm_ether_transfer_limit::id.eq(ether.limit_id))
|
||||||
|
.select(models::EvmEtherTransferLimit::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let targets = targets_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let arr: [u8; 20] = row.address.try_into().map_err(|_| {
|
||||||
|
diesel::result::Error::DeserializationError(
|
||||||
|
"Invalid ether target address length".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Address::from(arr))
|
||||||
|
})
|
||||||
|
.collect::<diesel::result::QueryResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(SignedEvmGrant {
|
||||||
|
basic_grant_id,
|
||||||
|
shared,
|
||||||
|
specific: SpecificGrant::EtherTransfer(crate::evm::policies::ether_transfer::Settings {
|
||||||
|
target: targets,
|
||||||
|
limit: VolumeRateLimit {
|
||||||
|
max_volume: try_bytes_to_u256(&limit.max_volume)?,
|
||||||
|
window: chrono::Duration::seconds(limit.window_secs as i64),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
revoked_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
307
server/crates/arbiter-server/src/integrity/mod.rs
Normal file
307
server/crates/arbiter-server/src/integrity/mod.rs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
|
||||||
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
use kameo::actor::ActorRef;
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
|
||||||
|
db::{
|
||||||
|
self,
|
||||||
|
models::{IntegrityEnvelope, NewIntegrityEnvelope},
|
||||||
|
schema::integrity_envelope,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
|
||||||
|
|
||||||
|
pub mod evm;
|
||||||
|
|
||||||
|
pub trait IntegrityEntity {
|
||||||
|
fn entity_kind(&self) -> &'static str;
|
||||||
|
fn entity_id_bytes(&self) -> Vec<u8>;
|
||||||
|
fn payload_version(&self) -> i32;
|
||||||
|
fn canonical_payload_bytes(&self) -> Vec<u8>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
#[diagnostic(code(arbiter::integrity::database))]
|
||||||
|
Database(#[from] db::DatabaseError),
|
||||||
|
|
||||||
|
#[error("KeyHolder error: {0}")]
|
||||||
|
#[diagnostic(code(arbiter::integrity::keyholder))]
|
||||||
|
Keyholder(#[from] crate::actors::keyholder::Error),
|
||||||
|
|
||||||
|
#[error("KeyHolder mailbox error")]
|
||||||
|
#[diagnostic(code(arbiter::integrity::keyholder_send))]
|
||||||
|
KeyholderSend,
|
||||||
|
|
||||||
|
#[error("Integrity envelope is missing for entity {entity_kind}")]
|
||||||
|
#[diagnostic(code(arbiter::integrity::missing_envelope))]
|
||||||
|
MissingEnvelope { entity_kind: &'static str },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
|
||||||
|
)]
|
||||||
|
#[diagnostic(code(arbiter::integrity::payload_version_mismatch))]
|
||||||
|
PayloadVersionMismatch {
|
||||||
|
entity_kind: &'static str,
|
||||||
|
expected: i32,
|
||||||
|
found: i32,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Integrity MAC mismatch for entity {entity_kind}")]
|
||||||
|
#[diagnostic(code(arbiter::integrity::mac_mismatch))]
|
||||||
|
MacMismatch { entity_kind: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_hash(payload: &[u8]) -> [u8; 32] {
|
||||||
|
Sha256::digest(payload).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||||
|
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async fn sign_entity(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
entity: &impl IntegrityEntity,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let entity_kind = entity.entity_kind();
|
||||||
|
let entity_id = entity.entity_id_bytes();
|
||||||
|
let payload_version = entity.payload_version();
|
||||||
|
let payload = entity.canonical_payload_bytes();
|
||||||
|
let payload_hash = payload_hash(&payload);
|
||||||
|
let mac_input = build_mac_input(entity_kind, &entity_id, payload_version, &payload_hash);
|
||||||
|
|
||||||
|
let (key_version, mac) = keyholder
|
||||||
|
.ask(SignIntegrity { mac_input })
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
|
||||||
|
_ => Error::KeyholderSend,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
diesel::delete(integrity_envelope::table)
|
||||||
|
.filter(integrity_envelope::entity_kind.eq(entity_kind))
|
||||||
|
.filter(integrity_envelope::entity_id.eq(&entity_id))
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.map_err(db::DatabaseError::from)?;
|
||||||
|
|
||||||
|
insert_into(integrity_envelope::table)
|
||||||
|
.values(NewIntegrityEnvelope {
|
||||||
|
entity_kind: entity_kind.to_string(),
|
||||||
|
entity_id,
|
||||||
|
payload_version,
|
||||||
|
key_version,
|
||||||
|
mac,
|
||||||
|
})
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.map_err(db::DatabaseError::from)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_entity(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
entity: &impl IntegrityEntity,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let entity_kind = entity.entity_kind();
|
||||||
|
let entity_id = entity.entity_id_bytes();
|
||||||
|
let expected_payload_version = entity.payload_version();
|
||||||
|
|
||||||
|
let envelope: IntegrityEnvelope = integrity_envelope::table
|
||||||
|
.filter(integrity_envelope::entity_kind.eq(entity_kind))
|
||||||
|
.filter(integrity_envelope::entity_id.eq(&entity_id))
|
||||||
|
.first(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind },
|
||||||
|
other => Error::Database(db::DatabaseError::from(other)),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if envelope.payload_version != expected_payload_version {
|
||||||
|
return Err(Error::PayloadVersionMismatch {
|
||||||
|
entity_kind,
|
||||||
|
expected: expected_payload_version,
|
||||||
|
found: envelope.payload_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = entity.canonical_payload_bytes();
|
||||||
|
let payload_hash = payload_hash(&payload);
|
||||||
|
let mac_input = build_mac_input(
|
||||||
|
entity_kind,
|
||||||
|
&entity_id,
|
||||||
|
envelope.payload_version,
|
||||||
|
&payload_hash,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ok = keyholder
|
||||||
|
.ask(VerifyIntegrity {
|
||||||
|
mac_input,
|
||||||
|
expected_mac: envelope.mac,
|
||||||
|
key_version: envelope.key_version,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
|
||||||
|
_ => Error::KeyholderSend,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return Err(Error::MacMismatch { entity_kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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},
|
||||||
|
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Error, IntegrityEntity, sign_entity, verify_entity};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DummyEntity {
|
||||||
|
id: i32,
|
||||||
|
payload_version: i32,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntegrityEntity for DummyEntity {
|
||||||
|
fn entity_kind(&self) -> &'static str {
|
||||||
|
"dummy_entity"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entity_id_bytes(&self) -> Vec<u8> {
|
||||||
|
self.id.to_be_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_version(&self) -> i32 {
|
||||||
|
self.payload_version
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_payload_bytes(&self) -> Vec<u8> {
|
||||||
|
self.payload.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
|
||||||
|
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
|
||||||
|
actor
|
||||||
|
.ask(Bootstrap {
|
||||||
|
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
actor
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sign_writes_envelope_and_verify_passes() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
id: 7,
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity).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.entity_id_bytes()))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count, 1, "envelope row must be created exactly once");
|
||||||
|
verify_entity(&mut conn, &keyholder, &entity).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tampered_mac_fails_verification() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
id: 11,
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity).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.entity_id_bytes()))
|
||||||
|
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = verify_entity(&mut conn, &keyholder, &entity)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changed_payload_fails_verification() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
id: 21,
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity).await.unwrap();
|
||||||
|
|
||||||
|
let tampered = DummyEntity {
|
||||||
|
payload: b"payload-v1-but-tampered".to_vec(),
|
||||||
|
..entity
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = verify_entity(&mut conn, &keyholder, &tampered)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod crypto;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod evm;
|
pub mod evm;
|
||||||
pub mod grpc;
|
pub mod grpc;
|
||||||
|
pub mod integrity;
|
||||||
pub mod safe_cell;
|
pub mod safe_cell;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user