Compare commits
18 Commits
Client-key
...
enforcing-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763058b014 | ||
|
|
1497884ce6 | ||
|
|
b3464cf8a6 | ||
|
|
46d1318b6f | ||
| 9c80d51d45 | |||
|
|
33456a644d | ||
|
|
5bc0c42cc7 | ||
|
|
f6b62ab884 | ||
|
|
2dd5a3f32f | ||
|
|
1aca9d4007 | ||
| 5ee1b49c43 | |||
|
|
00745bb381 | ||
|
|
b122aa464c | ||
|
|
9fab945a00 | ||
|
|
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 |
|
||||||
|
|||||||
11
mise.lock
11
mise.lock
@@ -48,6 +48,10 @@ 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"
|
||||||
@@ -111,30 +115,37 @@ backend = "core:python"
|
|||||||
[tools.python."platforms.linux-arm64"]
|
[tools.python."platforms.linux-arm64"]
|
||||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.linux-arm64-musl"]
|
[tools.python."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.linux-x64"]
|
[tools.python."platforms.linux-x64"]
|
||||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.linux-x64-musl"]
|
[tools.python."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.macos-arm64"]
|
[tools.python."platforms.macos-arm64"]
|
||||||
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
|
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.macos-x64"]
|
[tools.python."platforms.macos-x64"]
|
||||||
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
|
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.windows-x64"]
|
[tools.python."platforms.windows-x64"]
|
||||||
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
|
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.rust]]
|
[[tools.rust]]
|
||||||
version = "1.93.0"
|
version = "1.93.0"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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', 'protobufs/**/*.proto']
|
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ message GasLimitExceededViolation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message EvalViolation {
|
message EvalViolation {
|
||||||
|
message ChainIdMismatch {
|
||||||
|
uint64 expected = 1;
|
||||||
|
uint64 actual = 2;
|
||||||
|
}
|
||||||
oneof kind {
|
oneof kind {
|
||||||
bytes invalid_target = 1; // 20-byte Ethereum address
|
bytes invalid_target = 1; // 20-byte Ethereum address
|
||||||
GasLimitExceededViolation gas_limit_exceeded = 2;
|
GasLimitExceededViolation gas_limit_exceeded = 2;
|
||||||
@@ -43,6 +47,8 @@ message EvalViolation {
|
|||||||
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
||||||
google.protobuf.Empty invalid_time = 5;
|
google.protobuf.Empty invalid_time = 5;
|
||||||
google.protobuf.Empty invalid_transaction_type = 6;
|
google.protobuf.Empty invalid_transaction_type = 6;
|
||||||
|
|
||||||
|
ChainIdMismatch chain_id_mismatch = 7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
server/.cargo/mutants.toml
Normal file
1
server/.cargo/mutants.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test_tool = "nextest"
|
||||||
2
server/.gitignore
vendored
Normal file
2
server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mutants.out/
|
||||||
|
mutants.out.old/
|
||||||
30
server/Cargo.lock
generated
30
server/Cargo.lock
generated
@@ -743,18 +743,24 @@ dependencies = [
|
|||||||
"k256",
|
"k256",
|
||||||
"kameo",
|
"kameo",
|
||||||
"memsafe",
|
"memsafe",
|
||||||
|
"mutants",
|
||||||
"pem",
|
"pem",
|
||||||
|
"proptest",
|
||||||
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"restructed",
|
"restructed",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
"rstest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
|
"serde_with",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"smlang",
|
"smlang",
|
||||||
"spki",
|
"spki",
|
||||||
"strum 0.28.0",
|
"strum 0.28.0",
|
||||||
|
"subtle",
|
||||||
"test-log",
|
"test-log",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1955,6 +1961,7 @@ version = "3.0.0-rc.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
|
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"signature 3.0.0-rc.10",
|
"signature 3.0.0-rc.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1967,6 +1974,7 @@ dependencies = [
|
|||||||
"curve25519-dalek 5.0.0-pre.6",
|
"curve25519-dalek 5.0.0-pre.6",
|
||||||
"ed25519",
|
"ed25519",
|
||||||
"rand_core 0.10.0",
|
"rand_core 0.10.0",
|
||||||
|
"serde",
|
||||||
"sha2 0.11.0-rc.5",
|
"sha2 0.11.0-rc.5",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -2052,7 +2060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3171,6 +3179,12 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mutants"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -3187,7 +3201,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3212,6 +3226,7 @@ dependencies = [
|
|||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"serde",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -3634,9 +3649,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proptest"
|
name = "proptest"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
@@ -4151,6 +4166,7 @@ dependencies = [
|
|||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"serde",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"signature 2.2.0",
|
"signature 2.2.0",
|
||||||
"spki",
|
"spki",
|
||||||
@@ -4286,7 +4302,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4702,7 +4718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4896,7 +4912,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -42,4 +42,6 @@ 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"
|
||||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
prost = "0.14.3"
|
||||||
|
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||||
|
mutants = "0.0.4"
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ disallowed-methods = [
|
|||||||
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||||
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||||
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||||
|
|
||||||
|
{ path = "arbiter_server::crypto::integrity::v1::lookup_verified_allow_unavailable", reason = "This function allows integrity checks to be bypassed when vault key material is unavailable, which can lead to silent security failures if used incorrectly. It should only be used in specific contexts where this behavior is acceptable, and its use should be carefully audited." },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ pub struct ArbiterEvmWallet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ArbiterEvmWallet {
|
impl ArbiterEvmWallet {
|
||||||
|
#[expect(
|
||||||
|
dead_code,
|
||||||
|
reason = "constructor may be used in future extensions, e.g. to support wallet listing"
|
||||||
|
)]
|
||||||
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
|
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
|
||||||
Self {
|
Self {
|
||||||
transport,
|
transport,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
] }
|
] }
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
|
ed25519-dalek.features = ["serde"]
|
||||||
arbiter-proto.path = "../arbiter-proto"
|
arbiter-proto.path = "../arbiter-proto"
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -46,15 +47,23 @@ 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
|
k256.workspace = true
|
||||||
|
k256.features = ["serde"]
|
||||||
rsa.workspace = true
|
rsa.workspace = true
|
||||||
|
rsa.features = ["serde"]
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hmac = "0.12"
|
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"
|
||||||
|
serde_with = "3.18.0"
|
||||||
|
mutants.workspace = true
|
||||||
|
subtle = "2.6.1"
|
||||||
|
|
||||||
[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"] }
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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,
|
||||||
pubkey_integrity_tag blob,
|
|
||||||
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
|
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
|
||||||
created_at integer not null default(unixepoch ('now')),
|
created_at integer not null default(unixepoch ('now')),
|
||||||
updated_at integer not null default(unixepoch ('now'))
|
updated_at integer not null default(unixepoch ('now'))
|
||||||
@@ -192,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);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl;
|
|||||||
use kameo::{Actor, messages};
|
use kameo::{Actor, messages};
|
||||||
|
|
||||||
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
|
use rand::{RngExt, distr::Alphanumeric, 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};
|
||||||
@@ -44,14 +45,14 @@ pub struct Bootstrapper {
|
|||||||
|
|
||||||
impl Bootstrapper {
|
impl Bootstrapper {
|
||||||
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
|
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
|
||||||
let 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?;
|
||||||
@@ -69,7 +70,13 @@ impl Bootstrapper {
|
|||||||
#[message]
|
#[message]
|
||||||
pub fn is_correct_token(&self, token: String) -> bool {
|
pub fn is_correct_token(&self, token: String) -> bool {
|
||||||
match &self.token {
|
match &self.token {
|
||||||
Some(expected) => *expected == token,
|
Some(expected) => {
|
||||||
|
let expected_bytes = expected.as_bytes();
|
||||||
|
let token_bytes = token.as_bytes();
|
||||||
|
|
||||||
|
let choice = expected_bytes.ct_eq(token_bytes);
|
||||||
|
bool::from(choice)
|
||||||
|
}
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ use diesel::{
|
|||||||
};
|
};
|
||||||
use diesel_async::RunQueryDsl as _;
|
use diesel_async::RunQueryDsl as _;
|
||||||
use ed25519_dalek::{Signature, VerifyingKey};
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
use kameo::error::SendError;
|
use kameo::{actor::ActorRef, 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},
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
models::{ProgramClientMetadata, SqliteTimestamp},
|
models::{ProgramClientMetadata, SqliteTimestamp},
|
||||||
@@ -30,6 +32,8 @@ pub enum Error {
|
|||||||
DatabasePoolUnavailable,
|
DatabasePoolUnavailable,
|
||||||
#[error("Database operation failed")]
|
#[error("Database operation failed")]
|
||||||
DatabaseOperationFailed,
|
DatabaseOperationFailed,
|
||||||
|
#[error("Integrity check failed")]
|
||||||
|
IntegrityCheckFailed,
|
||||||
#[error("Invalid challenge solution")]
|
#[error("Invalid challenge solution")]
|
||||||
InvalidChallengeSolution,
|
InvalidChallengeSolution,
|
||||||
#[error("Client approval request failed")]
|
#[error("Client approval request failed")]
|
||||||
@@ -38,6 +42,13 @@ pub enum Error {
|
|||||||
Transport,
|
Transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<diesel::result::Error> for Error {
|
||||||
|
fn from(e: diesel::result::Error) -> Self {
|
||||||
|
error!(?e, "Database error");
|
||||||
|
Self::DatabaseOperationFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ApproveError {
|
pub enum ApproveError {
|
||||||
#[error("Internal error")]
|
#[error("Internal error")]
|
||||||
@@ -65,17 +76,69 @@ pub enum Outbound {
|
|||||||
AuthSuccess,
|
AuthSuccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ClientInfo {
|
/// Returns the current nonce and client ID for a registered client.
|
||||||
pub id: i32,
|
|
||||||
pub current_nonce: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically reads and increments the nonce for a known 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: &VerifyingKey,
|
||||||
) -> Result<Option<ClientInfo>, Error> {
|
) -> Result<Option<(i32, i32)>, Error> {
|
||||||
|
let pubkey_bytes = pubkey.as_bytes().to_vec();
|
||||||
|
let mut conn = db.get().await.map_err(|e| {
|
||||||
|
error!(error = ?e, "Database pool error");
|
||||||
|
Error::DatabasePoolUnavailable
|
||||||
|
})?;
|
||||||
|
program_client::table
|
||||||
|
.filter(program_client::public_key.eq(&pubkey_bytes))
|
||||||
|
.select((program_client::id, program_client::nonce))
|
||||||
|
.first::<(i32, i32)>(&mut conn)
|
||||||
|
.await
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(error = ?e, "Database error");
|
||||||
|
Error::DatabaseOperationFailed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_integrity(
|
||||||
|
db: &db::DatabasePool,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
pubkey: &VerifyingKey,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let mut db_conn = db.get().await.map_err(|e| {
|
||||||
|
error!(error = ?e, "Database pool error");
|
||||||
|
Error::DatabasePoolUnavailable
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?.ok_or_else(|| {
|
||||||
|
error!("Client not found during integrity verification");
|
||||||
|
Error::DatabaseOperationFailed
|
||||||
|
})?;
|
||||||
|
|
||||||
|
integrity::verify_entity(
|
||||||
|
&mut db_conn,
|
||||||
|
keyholder,
|
||||||
|
&ClientCredentials {
|
||||||
|
pubkey: *pubkey,
|
||||||
|
nonce,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(?e, "Integrity verification failed");
|
||||||
|
Error::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: &VerifyingKey,
|
||||||
|
) -> Result<i32, Error> {
|
||||||
let pubkey_bytes = pubkey.as_bytes().to_vec();
|
let pubkey_bytes = pubkey.as_bytes().to_vec();
|
||||||
|
|
||||||
let mut conn = db.get().await.map_err(|e| {
|
let mut conn = db.get().await.map_err(|e| {
|
||||||
@@ -84,34 +147,34 @@ async fn get_client_and_nonce(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
conn.exclusive_transaction(|conn| {
|
conn.exclusive_transaction(|conn| {
|
||||||
|
let keyholder = keyholder.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,
|
||||||
|
nonce: new_nonce,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(?e, "Integrity sign failed after nonce update");
|
||||||
|
Error::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(
|
||||||
@@ -139,45 +202,63 @@ async fn approve_new_client(
|
|||||||
|
|
||||||
async fn insert_client(
|
async fn insert_client(
|
||||||
db: &db::DatabasePool,
|
db: &db::DatabasePool,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
pubkey: &VerifyingKey,
|
pubkey: &VerifyingKey,
|
||||||
metadata: &ClientMetadata,
|
metadata: &ClientMetadata,
|
||||||
) -> Result<i32, Error> {
|
) -> Result<i32, Error> {
|
||||||
use crate::db::schema::{client_metadata, program_client};
|
use crate::db::schema::{client_metadata, program_client};
|
||||||
|
let metadata = metadata.clone();
|
||||||
|
|
||||||
let mut conn = db.get().await.map_err(|e| {
|
let mut conn = db.get().await.map_err(|e| {
|
||||||
error!(error = ?e, "Database pool error");
|
error!(error = ?e, "Database pool error");
|
||||||
Error::DatabasePoolUnavailable
|
Error::DatabasePoolUnavailable
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let metadata_id = insert_into(client_metadata::table)
|
conn.exclusive_transaction(|conn| {
|
||||||
.values((
|
let keyholder = keyholder.clone();
|
||||||
client_metadata::name.eq(&metadata.name),
|
Box::pin(async move {
|
||||||
client_metadata::description.eq(&metadata.description),
|
const NONCE_START: i32 = 1;
|
||||||
client_metadata::version.eq(&metadata.version),
|
|
||||||
))
|
|
||||||
.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.as_bytes().to_vec()),
|
||||||
|
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,
|
||||||
|
nonce: NONCE_START,
|
||||||
|
},
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(error = ?e, "Failed to sign integrity tag for new client key");
|
||||||
|
Error::DatabaseOperationFailed
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(client_id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_client_metadata(
|
async fn sync_client_metadata(
|
||||||
@@ -295,8 +376,11 @@ where
|
|||||||
return Err(Error::Transport);
|
return Err(Error::Transport);
|
||||||
};
|
};
|
||||||
|
|
||||||
let info = match get_client_and_nonce(&props.db, &pubkey).await? {
|
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
|
||||||
Some(nonce) => nonce,
|
Some((id, _)) => {
|
||||||
|
verify_integrity(&props.db, &props.actors.key_holder, &pubkey).await?;
|
||||||
|
id
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
approve_new_client(
|
approve_new_client(
|
||||||
&props.actors,
|
&props.actors,
|
||||||
@@ -306,16 +390,13 @@ where
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
|
insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
|
||||||
ClientInfo {
|
|
||||||
id: client_id,
|
|
||||||
current_nonce: 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
sync_client_metadata(&props.db, info.id, &metadata).await?;
|
sync_client_metadata(&props.db, client_id, &metadata).await?;
|
||||||
challenge_client(transport, pubkey, info.current_nonce).await?;
|
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
|
||||||
|
challenge_client(transport, pubkey, challenge_nonce).await?;
|
||||||
|
|
||||||
transport
|
transport
|
||||||
.send(Ok(Outbound::AuthSuccess))
|
.send(Ok(Outbound::AuthSuccess))
|
||||||
@@ -325,5 +406,5 @@ where
|
|||||||
Error::Transport
|
Error::Transport
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(info.id)
|
Ok(client_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use tracing::{error, info};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{GlobalActors, client::session::ClientSession},
|
actors::{GlobalActors, client::session::ClientSession},
|
||||||
|
crypto::integrity::{Integrable, hashing::Hashable},
|
||||||
db,
|
db,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,6 +14,22 @@ pub struct ClientProfile {
|
|||||||
pub metadata: ClientMetadata,
|
pub metadata: ClientMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ClientCredentials {
|
||||||
|
pub pubkey: ed25519_dalek::VerifyingKey,
|
||||||
|
pub nonce: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Integrable for ClientCredentials {
|
||||||
|
const KIND: &'static str = "client_credentials";
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for ClientCredentials {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.pubkey.as_bytes());
|
||||||
|
self.nonce.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ClientConnection {
|
pub struct ClientConnection {
|
||||||
pub(crate) db: db::DatabasePool,
|
pub(crate) db: db::DatabasePool,
|
||||||
pub(crate) actors: GlobalActors,
|
pub(crate) actors: GlobalActors,
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ use rand::{SeedableRng, rng, rngs::StdRng};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
||||||
|
crypto::integrity,
|
||||||
db::{
|
db::{
|
||||||
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,6 +57,9 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
Database(#[from] DatabaseError),
|
Database(#[from] DatabaseError),
|
||||||
|
|
||||||
|
#[error("Integrity violation: {0}")]
|
||||||
|
Integrity(#[from] integrity::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Actor)]
|
#[derive(Actor)]
|
||||||
@@ -71,7 +75,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,
|
||||||
@@ -132,46 +136,59 @@ impl EvmActor {
|
|||||||
&mut self,
|
&mut self,
|
||||||
basic: SharedGrantSettings,
|
basic: SharedGrantSettings,
|
||||||
grant: SpecificGrant,
|
grant: SpecificGrant,
|
||||||
) -> Result<i32, DatabaseError> {
|
) -> Result<integrity::Verified<i32>, Error> {
|
||||||
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(Error::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(Error::from),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
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))
|
||||||
Ok(())
|
// .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(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||||
Ok(self
|
match self.engine.list_all_grants().await {
|
||||||
.engine
|
Ok(grants) => Ok(grants),
|
||||||
.list_all_grants()
|
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
|
||||||
.await
|
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
|
||||||
.map_err(DatabaseError::from)?)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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};
|
||||||
@@ -12,7 +13,7 @@ use crate::{
|
|||||||
crypto::{
|
crypto::{
|
||||||
KeyCell, derive_key,
|
KeyCell, derive_key,
|
||||||
encryption::v1::{self, Nonce},
|
encryption::v1::{self, Nonce},
|
||||||
integrity::v1::compute_integrity_tag,
|
integrity::v1::HmacSha256,
|
||||||
},
|
},
|
||||||
safe_cell::SafeCell,
|
safe_cell::SafeCell,
|
||||||
};
|
};
|
||||||
@@ -250,22 +251,6 @@ impl KeyHolder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signs a generic integrity payload using the vault-derived integrity key
|
|
||||||
#[message]
|
|
||||||
pub fn sign_integrity_tag(
|
|
||||||
&mut self,
|
|
||||||
purpose_tag: Vec<u8>,
|
|
||||||
data_parts: Vec<Vec<u8>>,
|
|
||||||
) -> Result<Vec<u8>, Error> {
|
|
||||||
let State::Unsealed { root_key, .. } = &mut self.state else {
|
|
||||||
return Err(Error::NotBootstrapped);
|
|
||||||
};
|
|
||||||
|
|
||||||
let tag =
|
|
||||||
compute_integrity_tag(root_key, &purpose_tag, data_parts.iter().map(Vec::as_slice));
|
|
||||||
Ok(tag.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||||
let State::Unsealed { root_key, .. } = &mut self.state else {
|
let State::Unsealed { root_key, .. } = &mut self.state else {
|
||||||
@@ -339,6 +324,60 @@ 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 mut hmac = root_key
|
||||||
|
.0
|
||||||
|
.read_inline(|k| match HmacSha256::new_from_slice(k) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||||
|
});
|
||||||
|
hmac.update(&root_key_history_id.to_be_bytes());
|
||||||
|
hmac.update(&mac_input);
|
||||||
|
|
||||||
|
let mac = hmac.finalize().into_bytes().to_vec();
|
||||||
|
Ok((*root_key_history_id, mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub fn verify_integrity(
|
||||||
|
&mut self,
|
||||||
|
mac_input: Vec<u8>,
|
||||||
|
expected_mac: Vec<u8>,
|
||||||
|
key_version: i32,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let 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 mut hmac = root_key
|
||||||
|
.0
|
||||||
|
.read_inline(|k| match HmacSha256::new_from_slice(k) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => unreachable!("HMAC accepts keys of any size"),
|
||||||
|
});
|
||||||
|
hmac.update(&key_version.to_be_bytes());
|
||||||
|
hmac.update(&mac_input);
|
||||||
|
|
||||||
|
Ok(hmac.verify_slice(&expected_mac).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub fn seal(&mut self) -> Result<(), Error> {
|
pub fn seal(&mut self) -> Result<(), Error> {
|
||||||
let State::Unsealed {
|
let State::Unsealed {
|
||||||
|
|||||||
@@ -30,10 +30,26 @@ pub enum Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
fn internal(details: impl Into<String>) -> Self {
|
#[track_caller]
|
||||||
Self::Internal {
|
pub(super) fn internal(details: impl Into<String>, err: &impl std::fmt::Debug) -> Self {
|
||||||
details: details.into(),
|
let details = details.into();
|
||||||
}
|
let caller = std::panic::Location::caller();
|
||||||
|
error!(
|
||||||
|
caller_file = %caller.file(),
|
||||||
|
caller_line = caller.line(),
|
||||||
|
caller_column = caller.column(),
|
||||||
|
details = %details,
|
||||||
|
error = ?err,
|
||||||
|
"Internal error"
|
||||||
|
);
|
||||||
|
|
||||||
|
Self::Internal { details }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<diesel::result::Error> for Error {
|
||||||
|
fn from(e: diesel::result::Error) -> Self {
|
||||||
|
Self::internal("Database error", &e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
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::error::SendError;
|
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,
|
||||||
keyholder::{self, SignIntegrityTag},
|
keyholder::KeyHolder,
|
||||||
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
|
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
|
||||||
},
|
},
|
||||||
crypto::integrity::v1::USERAGENT_INTEGRITY_TAG,
|
crypto::integrity,
|
||||||
db::schema,
|
db::{DatabasePool, schema::useragent_client},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AttestationStatus {
|
|
||||||
Attested,
|
|
||||||
NotAttested,
|
|
||||||
Unavailable,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ChallengeRequest {
|
pub struct ChallengeRequest {
|
||||||
pub pubkey: AuthPublicKey,
|
pub pubkey: AuthPublicKey,
|
||||||
}
|
}
|
||||||
@@ -50,72 +43,166 @@ smlang::statemachine!(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
async fn create_nonce(
|
/// Returns the current nonce, ready to use for the challenge nonce.
|
||||||
db: &crate::db::DatabasePool,
|
async fn get_current_nonce_and_id(
|
||||||
pubkey_bytes: &[u8],
|
db: &DatabasePool,
|
||||||
key_type: crate::db::models::KeyType,
|
key: &AuthPublicKey,
|
||||||
) -> Result<i32, Error> {
|
) -> Result<(i32, i32), Error> {
|
||||||
let mut db_conn = db.get().await.map_err(|e| {
|
let mut db_conn = db
|
||||||
error!(error = ?e, "Database pool error");
|
.get()
|
||||||
Error::internal("Database unavailable")
|
.await
|
||||||
})?;
|
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||||
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_stored_bytes()))
|
||||||
.filter(schema::useragent_client::key_type.eq(key_type))
|
.filter(useragent_client::key_type.eq(key.key_type()))
|
||||||
.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()))
|
|
||||||
.filter(schema::useragent_client::key_type.eq(key_type))
|
|
||||||
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
|
|
||||||
.execute(conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Result::<_, diesel::result::Error>::Ok(current_nonce)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| {
|
.map_err(|e| Error::internal("Database operation failed", &e))?
|
||||||
error!(error = ?e, "Database error");
|
|
||||||
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(
|
async fn verify_integrity(
|
||||||
db: &crate::db::DatabasePool,
|
db: &DatabasePool,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
pubkey: &AuthPublicKey,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let mut db_conn = db
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||||
|
|
||||||
|
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
|
||||||
|
|
||||||
|
let attestation_status = integrity::check_entity_attestation(
|
||||||
|
&mut db_conn,
|
||||||
|
keyholder,
|
||||||
|
&UserAgentCredentials {
|
||||||
|
pubkey: pubkey.clone(),
|
||||||
|
nonce,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal("Integrity verification failed", &e))?;
|
||||||
|
|
||||||
|
use integrity::AttestationStatus as AS;
|
||||||
|
// SAFETY (policy): challenge auth must work in both vault states.
|
||||||
|
// While sealed, integrity checks can only report `Unavailable` because key material is not
|
||||||
|
// accessible. While unsealed, the same check can report `Attested`.
|
||||||
|
// This path intentionally accepts both outcomes to keep challenge auth available across state
|
||||||
|
// transitions; stricter verification is enforced in sensitive post-auth flows.
|
||||||
|
match attestation_status {
|
||||||
|
AS::Attested | AS::Unavailable => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_nonce(
|
||||||
|
db: &DatabasePool,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
pubkey: &AuthPublicKey,
|
||||||
|
) -> Result<i32, Error> {
|
||||||
|
let mut db_conn = db
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||||
|
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_stored_bytes()))
|
||||||
|
.filter(useragent_client::key_type.eq(pubkey.key_type()))
|
||||||
|
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
|
||||||
|
.returning((useragent_client::id, useragent_client::nonce))
|
||||||
|
.get_result(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal("Database operation failed", &e))?;
|
||||||
|
|
||||||
|
integrity::sign_entity(
|
||||||
|
conn,
|
||||||
|
keyholder,
|
||||||
|
&UserAgentCredentials {
|
||||||
|
pubkey: pubkey.clone(),
|
||||||
|
nonce: new_nonce,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal("Database error", &e))?;
|
||||||
|
|
||||||
|
Result::<_, Error>::Ok(new_nonce)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(new_nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_key(
|
||||||
|
db: &DatabasePool,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
pubkey: &AuthPublicKey,
|
pubkey: &AuthPublicKey,
|
||||||
integrity_tag: Option<Vec<u8>>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let pubkey_bytes = pubkey.to_stored_bytes();
|
let pubkey_bytes = pubkey.to_stored_bytes();
|
||||||
let key_type = pubkey.key_type();
|
let key_type = pubkey.key_type();
|
||||||
let mut conn = db.get().await.map_err(|e| {
|
let mut conn = db
|
||||||
error!(error = ?e, "Database pool error");
|
.get()
|
||||||
Error::internal("Database unavailable")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
diesel::insert_into(schema::useragent_client::table)
|
|
||||||
.values((
|
|
||||||
schema::useragent_client::public_key.eq(pubkey_bytes),
|
|
||||||
schema::useragent_client::nonce.eq(1),
|
|
||||||
schema::useragent_client::key_type.eq(key_type),
|
|
||||||
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| Error::internal("Database unavailable", &e))?;
|
||||||
error!(error = ?e, "Database error");
|
|
||||||
Error::internal("Database operation failed")
|
conn.transaction(|conn| {
|
||||||
})?;
|
Box::pin(async move {
|
||||||
|
const NONCE_START: i32 = 1;
|
||||||
|
|
||||||
|
let id: i32 = diesel::insert_into(useragent_client::table)
|
||||||
|
.values((
|
||||||
|
useragent_client::public_key.eq(pubkey_bytes),
|
||||||
|
useragent_client::nonce.eq(NONCE_START),
|
||||||
|
useragent_client::key_type.eq(key_type),
|
||||||
|
))
|
||||||
|
.returning(useragent_client::id)
|
||||||
|
.get_result(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::internal("Database operation failed", &e))?;
|
||||||
|
|
||||||
|
if let Err(e) = integrity::sign_entity(
|
||||||
|
conn,
|
||||||
|
keyholder,
|
||||||
|
&UserAgentCredentials {
|
||||||
|
pubkey: pubkey.clone(),
|
||||||
|
nonce: NONCE_START,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match e {
|
||||||
|
integrity::Error::Keyholder(
|
||||||
|
crate::actors::keyholder::Error::NotBootstrapped,
|
||||||
|
) => {
|
||||||
|
// IMPORTANT: bootstrap-token auth must work before the vault has a root key.
|
||||||
|
// We intentionally allow creating the DB row first and backfill envelopes
|
||||||
|
// after bootstrap/unseal to keep the bootstrap flow possible.
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(Error::internal("Failed to register public key", &other));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result::<_, Error>::Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -141,15 +228,9 @@ where
|
|||||||
&mut self,
|
&mut self,
|
||||||
ChallengeRequest { pubkey }: ChallengeRequest,
|
ChallengeRequest { pubkey }: ChallengeRequest,
|
||||||
) -> Result<ChallengeContext, Self::Error> {
|
) -> Result<ChallengeContext, Self::Error> {
|
||||||
match self.verify_pubkey_attestation_status(&pubkey).await? {
|
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
|
||||||
AttestationStatus::Attested | AttestationStatus::Unavailable => {}
|
|
||||||
AttestationStatus::NotAttested => {
|
|
||||||
return Err(Error::InvalidChallengeSolution);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stored_bytes = pubkey.to_stored_bytes();
|
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
|
||||||
let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
|
|
||||||
|
|
||||||
self.transport
|
self.transport
|
||||||
.send(Ok(Outbound::AuthChallenge { nonce }))
|
.send(Ok(Outbound::AuthChallenge { nonce }))
|
||||||
@@ -179,32 +260,31 @@ where
|
|||||||
token: token.clone(),
|
token: token.clone(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| Error::internal("Failed to consume bootstrap token", &e))?;
|
||||||
error!(?e, "Failed to consume bootstrap token");
|
|
||||||
Error::internal("Failed to consume bootstrap token")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !token_ok {
|
if !token_ok {
|
||||||
error!("Invalid bootstrap token provided");
|
error!("Invalid bootstrap token provided");
|
||||||
return Err(Error::InvalidBootstrapToken);
|
return Err(Error::InvalidBootstrapToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
let integrity_tag = self
|
match token_ok {
|
||||||
.try_sign_pubkey_integrity_tag(&pubkey)
|
true => {
|
||||||
.await
|
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
|
||||||
.map_err(|err| {
|
self.transport
|
||||||
error!(?err, "Failed to sign user-agent pubkey integrity tag");
|
.send(Ok(Outbound::AuthSuccess))
|
||||||
Error::internal("Failed to sign user-agent pubkey integrity tag")
|
.await
|
||||||
})?;
|
.map_err(|_| Error::Transport)?;
|
||||||
|
Ok(pubkey)
|
||||||
register_key(&self.conn.db, &pubkey, integrity_tag).await?;
|
}
|
||||||
|
false => {
|
||||||
self.transport
|
error!("Invalid bootstrap token provided");
|
||||||
.send(Ok(Outbound::AuthSuccess))
|
self.transport
|
||||||
.await
|
.send(Err(Error::InvalidBootstrapToken))
|
||||||
.map_err(|_| Error::Transport)?;
|
.await
|
||||||
|
.map_err(|_| Error::Transport)?;
|
||||||
Ok(pubkey)
|
Err(Error::InvalidBootstrapToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(missing_docs)]
|
#[allow(missing_docs)]
|
||||||
@@ -264,93 +344,3 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> AuthContext<'_, T>
|
|
||||||
where
|
|
||||||
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
|
|
||||||
{
|
|
||||||
async fn try_sign_pubkey_integrity_tag(
|
|
||||||
&self,
|
|
||||||
pubkey: &AuthPublicKey,
|
|
||||||
) -> Result<Option<Vec<u8>>, Error> {
|
|
||||||
let signed = self
|
|
||||||
.conn
|
|
||||||
.actors
|
|
||||||
.key_holder
|
|
||||||
.ask(SignIntegrityTag {
|
|
||||||
purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(),
|
|
||||||
data_parts: vec![
|
|
||||||
(pubkey.key_type() as i32).to_be_bytes().to_vec(),
|
|
||||||
pubkey.to_stored_bytes(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match signed {
|
|
||||||
Ok(tag) => Ok(Some(tag)),
|
|
||||||
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(None),
|
|
||||||
Err(SendError::HandlerError(err)) => {
|
|
||||||
error!(
|
|
||||||
?err,
|
|
||||||
"Keyholder failed to sign user-agent pubkey integrity tag"
|
|
||||||
);
|
|
||||||
Err(Error::internal(
|
|
||||||
"Keyholder failed to sign user-agent pubkey integrity tag",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
?err,
|
|
||||||
"Failed to contact keyholder for user-agent pubkey integrity tag"
|
|
||||||
);
|
|
||||||
Err(Error::internal(
|
|
||||||
"Failed to contact keyholder for user-agent pubkey integrity tag",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify_pubkey_attestation_status(
|
|
||||||
&self,
|
|
||||||
pubkey: &AuthPublicKey,
|
|
||||||
) -> Result<AttestationStatus, Error> {
|
|
||||||
let stored_tag: Option<Option<Vec<u8>>> = {
|
|
||||||
let mut conn = self.conn.db.get().await.map_err(|e| {
|
|
||||||
error!(error = ?e, "Database pool error");
|
|
||||||
Error::internal("Database unavailable")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
schema::useragent_client::table
|
|
||||||
.filter(schema::useragent_client::public_key.eq(pubkey.to_stored_bytes()))
|
|
||||||
.filter(schema::useragent_client::key_type.eq(pubkey.key_type()))
|
|
||||||
.select(schema::useragent_client::pubkey_integrity_tag)
|
|
||||||
.first::<Option<Vec<u8>>>(&mut conn)
|
|
||||||
.await
|
|
||||||
.optional()
|
|
||||||
.map_err(|e| {
|
|
||||||
error!(error = ?e, "Database error");
|
|
||||||
Error::internal("Database operation failed")
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(stored_tag) = stored_tag else {
|
|
||||||
return Err(Error::UnregisteredPublicKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else {
|
|
||||||
return Ok(AttestationStatus::Unavailable);
|
|
||||||
};
|
|
||||||
|
|
||||||
match stored_tag {
|
|
||||||
Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested),
|
|
||||||
Some(_) => {
|
|
||||||
error!("User-agent pubkey integrity tag mismatch");
|
|
||||||
Ok(AttestationStatus::NotAttested)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
error!("Missing pubkey integrity tag for registered key while vault is unsealed");
|
|
||||||
Ok(AttestationStatus::NotAttested)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::{GlobalActors, client::ClientProfile},
|
actors::{GlobalActors, client::ClientProfile},
|
||||||
|
crypto::integrity::Integrable,
|
||||||
db::{self, models::KeyType},
|
db::{self, models::KeyType},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,6 +14,16 @@ pub enum AuthPublicKey {
|
|||||||
Rsa(rsa::RsaPublicKey),
|
Rsa(rsa::RsaPublicKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UserAgentCredentials {
|
||||||
|
pub pubkey: AuthPublicKey,
|
||||||
|
pub nonce: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Integrable for UserAgentCredentials {
|
||||||
|
const KIND: &'static str = "useragent_credentials";
|
||||||
|
}
|
||||||
|
|
||||||
impl AuthPublicKey {
|
impl AuthPublicKey {
|
||||||
/// Canonical bytes stored in DB and echoed back in the challenge.
|
/// Canonical bytes stored in DB and echoed back in the challenge.
|
||||||
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
|
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
|
||||||
@@ -92,3 +103,18 @@ pub mod session;
|
|||||||
|
|
||||||
pub use auth::authenticate;
|
pub use auth::authenticate;
|
||||||
pub use session::UserAgentSession;
|
pub use session::UserAgentSession;
|
||||||
|
|
||||||
|
use crate::crypto::integrity::hashing::Hashable;
|
||||||
|
|
||||||
|
impl Hashable for AuthPublicKey {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.to_stored_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for UserAgentCredentials {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.pubkey.hash(hasher);
|
||||||
|
self.nonce.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use kameo::prelude::Context;
|
|||||||
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::keyholder::KeyHolderState;
|
use crate::actors::keyholder::KeyHolderState;
|
||||||
use crate::actors::user_agent::session::Error;
|
use crate::actors::user_agent::session::Error;
|
||||||
use crate::db::models::{
|
use crate::db::models::{
|
||||||
@@ -18,6 +17,10 @@ use crate::db::models::{
|
|||||||
};
|
};
|
||||||
use crate::evm::policies::{Grant, SpecificGrant};
|
use crate::evm::policies::{Grant, SpecificGrant};
|
||||||
use crate::safe_cell::SafeCell;
|
use crate::safe_cell::SafeCell;
|
||||||
|
use crate::{
|
||||||
|
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
|
||||||
|
crypto::integrity::{self, Verified},
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
evm::{
|
evm::{
|
||||||
@@ -29,11 +32,66 @@ use crate::{
|
|||||||
UserAgentSession,
|
UserAgentSession,
|
||||||
state::{UnsealContext, UserAgentEvents, UserAgentStates},
|
state::{UnsealContext, UserAgentEvents, UserAgentStates},
|
||||||
},
|
},
|
||||||
|
user_agent::{AuthPublicKey, UserAgentCredentials},
|
||||||
},
|
},
|
||||||
|
db::schema::useragent_client,
|
||||||
safe_cell::SafeCellHandle as _,
|
safe_cell::SafeCellHandle as _,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn is_vault_sealed_from_evm<M>(err: &SendError<M, crate::actors::evm::Error>) -> bool {
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
SendError::HandlerError(crate::actors::evm::Error::Keyholder(
|
||||||
|
keyholder::Error::NotBootstrapped
|
||||||
|
)) | SendError::HandlerError(crate::actors::evm::Error::Integrity(
|
||||||
|
crate::crypto::integrity::Error::Keyholder(keyholder::Error::NotBootstrapped)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
impl UserAgentSession {
|
impl UserAgentSession {
|
||||||
|
async fn backfill_useragent_integrity(&self) -> Result<(), Error> {
|
||||||
|
let mut conn = self.props.db.get().await?;
|
||||||
|
let keyholder = self.props.actors.key_holder.clone();
|
||||||
|
|
||||||
|
conn.transaction(|conn| {
|
||||||
|
Box::pin(async move {
|
||||||
|
let rows: Vec<(i32, i32, Vec<u8>, crate::db::models::KeyType)> =
|
||||||
|
useragent_client::table
|
||||||
|
.select((
|
||||||
|
useragent_client::id,
|
||||||
|
useragent_client::nonce,
|
||||||
|
useragent_client::public_key,
|
||||||
|
useragent_client::key_type,
|
||||||
|
))
|
||||||
|
.load(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (id, nonce, public_key, key_type) in rows {
|
||||||
|
let pubkey = AuthPublicKey::try_from((key_type, public_key)).map_err(|e| {
|
||||||
|
Error::internal(format!("Invalid user-agent key in db: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
integrity::sign_entity(
|
||||||
|
conn,
|
||||||
|
&keyholder,
|
||||||
|
&UserAgentCredentials { pubkey, nonce },
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
Error::internal(format!("Failed to backfill user-agent integrity: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result::<_, Error>::Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
|
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
|
||||||
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");
|
||||||
@@ -120,6 +178,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]
|
||||||
@@ -182,6 +249,7 @@ impl UserAgentSession {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
self.backfill_useragent_integrity().await?;
|
||||||
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(())
|
||||||
@@ -243,6 +311,7 @@ impl UserAgentSession {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
self.backfill_useragent_integrity().await?;
|
||||||
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(())
|
||||||
@@ -316,12 +385,15 @@ 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>>, GrantMutationError> {
|
||||||
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) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
|
||||||
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(GrantMutationError::Internal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +403,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<Verified<i32>, GrantMutationError> {
|
||||||
match self
|
match self
|
||||||
.props
|
.props
|
||||||
.actors
|
.actors
|
||||||
@@ -340,26 +412,33 @@ impl UserAgentSession {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(grant_id) => Ok(grant_id),
|
Ok(grant_id) => Ok(grant_id),
|
||||||
|
Err(err) if is_vault_sealed_from_evm(&err) => 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
|
||||||
.evm
|
.evm
|
||||||
.ask(UseragentDeleteGrant { grant_id })
|
.ask(UseragentDeleteGrant {
|
||||||
|
_grant_id: grant_id,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if is_vault_sealed_from_evm(&err) => 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
pub mod v1;
|
pub mod v1;
|
||||||
|
|
||||||
|
pub use v1::*;
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
pub mod v1;
|
pub mod v1;
|
||||||
|
|
||||||
|
pub use v1::*;
|
||||||
|
|||||||
@@ -1,78 +1,681 @@
|
|||||||
use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _};
|
use crate::actors::keyholder;
|
||||||
use chacha20poly1305::Key;
|
use hmac::Hmac;
|
||||||
use hmac::Mac as _;
|
use sha2::Sha256;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes();
|
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
|
||||||
pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes();
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
use kameo::{actor::ActorRef, error::SendError};
|
||||||
|
use sha2::Digest as _;
|
||||||
|
|
||||||
/// Computes an integrity tag for a specific domain and payload shape.
|
pub mod hashing;
|
||||||
pub fn compute_integrity_tag<'a, I>(
|
use self::hashing::Hashable;
|
||||||
integrity_key: &mut KeyCell,
|
|
||||||
purpose_tag: &[u8],
|
use crate::{
|
||||||
data_parts: I,
|
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
|
||||||
) -> [u8; 32]
|
db::{
|
||||||
|
self,
|
||||||
|
models::{IntegrityEnvelope as IntegrityEnvelopeRow, NewIntegrityEnvelope},
|
||||||
|
schema::integrity_envelope,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] db::DatabaseError),
|
||||||
|
|
||||||
|
#[error("KeyHolder error: {0}")]
|
||||||
|
Keyholder(#[from] keyholder::Error),
|
||||||
|
|
||||||
|
#[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)]
|
||||||
|
#[must_use]
|
||||||
|
pub enum AttestationStatus {
|
||||||
|
Attested,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Verified<T>(T);
|
||||||
|
|
||||||
|
impl<T> AsRef<T> for Verified<T> {
|
||||||
|
fn as_ref(&self) -> &T {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Verified<T> {
|
||||||
|
pub fn into_inner(self) -> T {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Deref for Verified<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EntityId(Vec<u8>);
|
||||||
|
|
||||||
|
impl Deref for EntityId {
|
||||||
|
type Target = [u8];
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for EntityId {
|
||||||
|
fn from(value: i32) -> Self {
|
||||||
|
Self(value.to_be_bytes().to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&'_ [u8]> for EntityId {
|
||||||
|
fn from(bytes: &'_ [u8]) -> Self {
|
||||||
|
Self(bytes.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup_verified<E, C, F, Fut>(
|
||||||
|
conn: &mut C,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
entity_id: impl Into<EntityId>,
|
||||||
|
load: F,
|
||||||
|
) -> Result<Verified<E>, Error>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = &'a [u8]>,
|
C: AsyncConnection<Backend = Sqlite>,
|
||||||
|
E: Integrable,
|
||||||
|
F: FnOnce(&mut C) -> Fut,
|
||||||
|
Fut: Future<Output = Result<E, db::DatabaseError>>,
|
||||||
{
|
{
|
||||||
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
|
let entity = load(conn).await?;
|
||||||
|
verify_entity(conn, keyholder, &entity, entity_id).await?;
|
||||||
|
Ok(Verified(entity))
|
||||||
|
}
|
||||||
|
|
||||||
let mut output_tag = [0u8; 32];
|
pub async fn lookup_verified_allow_unavailable<E, C, F, Fut>(
|
||||||
integrity_key.0.read_inline(|integrity_key_bytes: &Key| {
|
conn: &mut C,
|
||||||
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref())
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
.expect("HMAC key initialization must not fail for 32-byte key");
|
entity_id: impl Into<EntityId>,
|
||||||
mac.update(purpose_tag);
|
load: F,
|
||||||
for data_part in data_parts {
|
) -> Result<Verified<E>, Error>
|
||||||
mac.update(data_part);
|
where
|
||||||
|
C: AsyncConnection<Backend = Sqlite>,
|
||||||
|
E: Integrable+ 'static,
|
||||||
|
F: FnOnce(&mut C) -> Fut,
|
||||||
|
Fut: Future<Output = Result<E, db::DatabaseError>>,
|
||||||
|
{
|
||||||
|
let entity = load(conn).await?;
|
||||||
|
match check_entity_attestation(conn, keyholder, &entity, entity_id.into()).await? {
|
||||||
|
// IMPORTANT: allow_unavailable mode must succeed with an unattested result when vault key
|
||||||
|
// material is unavailable, otherwise integrity checks can be silently bypassed while sealed.
|
||||||
|
AttestationStatus::Attested | AttestationStatus::Unavailable => Ok(Verified(entity)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup_verified_from_query<E, Id, C, F>(
|
||||||
|
conn: &mut C,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
load: F,
|
||||||
|
) -> Result<Verified<E>, Error>
|
||||||
|
where
|
||||||
|
C: AsyncConnection<Backend = Sqlite> + Send,
|
||||||
|
E: Integrable,
|
||||||
|
Id: Into<EntityId>,
|
||||||
|
F: for<'a> FnOnce(
|
||||||
|
&'a mut C,
|
||||||
|
) -> Pin<
|
||||||
|
Box<dyn Future<Output = Result<(Id, E), db::DatabaseError>> + Send + 'a>,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
let (entity_id, entity) = load(conn).await?;
|
||||||
|
verify_entity(conn, keyholder, &entity, entity_id).await?;
|
||||||
|
Ok(Verified(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
entity: &E,
|
||||||
|
as_entity_id: Id,
|
||||||
|
) -> Result<Verified<Id>, Error> {
|
||||||
|
let payload_hash = payload_hash(entity);
|
||||||
|
|
||||||
|
let entity_id = as_entity_id.clone().into();
|
||||||
|
|
||||||
|
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
|
||||||
|
|
||||||
|
let (key_version, mac) = keyholder
|
||||||
|
.ask(SignIntegrity { mac_input })
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
|
||||||
|
_ => Error::KeyholderSend,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
insert_into(integrity_envelope::table)
|
||||||
|
.values(NewIntegrityEnvelope {
|
||||||
|
entity_kind: E::KIND.to_owned(),
|
||||||
|
entity_id: entity_id.to_vec(),
|
||||||
|
payload_version: E::VERSION,
|
||||||
|
key_version,
|
||||||
|
mac: mac.to_vec(),
|
||||||
|
})
|
||||||
|
.on_conflict((
|
||||||
|
integrity_envelope::entity_id,
|
||||||
|
integrity_envelope::entity_kind,
|
||||||
|
))
|
||||||
|
.do_update()
|
||||||
|
.set((
|
||||||
|
integrity_envelope::payload_version.eq(E::VERSION),
|
||||||
|
integrity_envelope::key_version.eq(key_version),
|
||||||
|
integrity_envelope::mac.eq(mac),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.map_err(db::DatabaseError::from)?;
|
||||||
|
|
||||||
|
Ok(Verified(as_entity_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_entity_attestation<E: Integrable>(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
entity: &E,
|
||||||
|
entity_id: impl Into<EntityId>,
|
||||||
|
) -> Result<AttestationStatus, Error> {
|
||||||
|
let entity_id = entity_id.into();
|
||||||
|
let envelope: IntegrityEnvelopeRow = integrity_envelope::table
|
||||||
|
.filter(integrity_envelope::entity_kind.eq(E::KIND))
|
||||||
|
.filter(integrity_envelope::entity_id.eq(&*entity_id))
|
||||||
|
.first(conn)
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
diesel::result::Error::NotFound => Error::MissingEnvelope {
|
||||||
|
entity_kind: E::KIND,
|
||||||
|
},
|
||||||
|
other => Error::Database(db::DatabaseError::from(other)),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if envelope.payload_version != E::VERSION {
|
||||||
|
return Err(Error::PayloadVersionMismatch {
|
||||||
|
entity_kind: E::KIND,
|
||||||
|
expected: E::VERSION,
|
||||||
|
found: envelope.payload_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_hash = payload_hash(entity);
|
||||||
|
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
|
||||||
|
|
||||||
|
let result = 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(Error::MacMismatch {
|
||||||
|
entity_kind: E::KIND,
|
||||||
|
}),
|
||||||
|
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => {
|
||||||
|
Ok(AttestationStatus::Unavailable)
|
||||||
}
|
}
|
||||||
output_tag.copy_from_slice(&mac.finalize().into_bytes());
|
Err(_) => Err(Error::KeyholderSend),
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
output_tag
|
pub async fn verify_entity<'a, E: Integrable>(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
keyholder: &ActorRef<KeyHolder>,
|
||||||
|
entity: &'a E,
|
||||||
|
entity_id: impl Into<EntityId>,
|
||||||
|
) -> Result<Verified<&'a E>, Error> {
|
||||||
|
match check_entity_attestation::<E>(conn, keyholder, entity, entity_id).await? {
|
||||||
|
AttestationStatus::Attested => Ok(Verified(entity)),
|
||||||
|
AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_envelope<E: Integrable>(
|
||||||
|
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
|
entity_id: impl Into<EntityId>,
|
||||||
|
) -> Result<usize, Error> {
|
||||||
|
let entity_id = entity_id.into();
|
||||||
|
|
||||||
|
let affected = diesel::delete(
|
||||||
|
integrity_envelope::table
|
||||||
|
.filter(integrity_envelope::entity_kind.eq(E::KIND))
|
||||||
|
.filter(integrity_envelope::entity_id.eq(&*entity_id)),
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.map_err(db::DatabaseError::from)?;
|
||||||
|
|
||||||
|
Ok(affected)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||||
|
use diesel_async::RunQueryDsl;
|
||||||
|
use kameo::{actor::ActorRef, prelude::Spawn};
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
crypto::{derive_key, encryption::v1::generate_salt},
|
actors::keyholder::{Bootstrap, KeyHolder},
|
||||||
|
db::{self, schema},
|
||||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{USERAGENT_INTEGRITY_TAG, compute_integrity_tag};
|
use super::hashing::Hashable;
|
||||||
|
use super::{
|
||||||
|
check_entity_attestation, AttestationStatus, Error, Integrable, lookup_verified,
|
||||||
|
lookup_verified_allow_unavailable, lookup_verified_from_query, sign_entity, verify_entity,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[derive(Clone, Debug)]
|
||||||
pub fn integrity_tag_deterministic() {
|
struct DummyEntity {
|
||||||
let salt = generate_salt();
|
payload_version: i32,
|
||||||
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
|
payload: Vec<u8>,
|
||||||
let key_type = 1i32.to_be_bytes();
|
|
||||||
let t1 = compute_integrity_tag(
|
|
||||||
&mut integrity_key,
|
|
||||||
USERAGENT_INTEGRITY_TAG,
|
|
||||||
[key_type.as_slice(), b"pubkey".as_ref()],
|
|
||||||
);
|
|
||||||
let t2 = compute_integrity_tag(
|
|
||||||
&mut integrity_key,
|
|
||||||
USERAGENT_INTEGRITY_TAG,
|
|
||||||
[key_type.as_slice(), b"pubkey".as_ref()],
|
|
||||||
);
|
|
||||||
assert_eq!(t1, t2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
impl Hashable for DummyEntity {
|
||||||
pub fn integrity_tag_changes_with_payload() {
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
let salt = generate_salt();
|
self.payload_version.hash(hasher);
|
||||||
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
|
self.payload.hash(hasher);
|
||||||
let key_type_1 = 1i32.to_be_bytes();
|
}
|
||||||
let key_type_2 = 2i32.to_be_bytes();
|
}
|
||||||
let t1 = compute_integrity_tag(
|
impl Integrable for DummyEntity {
|
||||||
&mut integrity_key,
|
const KIND: &'static str = "dummy_entity";
|
||||||
USERAGENT_INTEGRITY_TAG,
|
}
|
||||||
[key_type_1.as_slice(), b"pubkey".as_ref()],
|
|
||||||
);
|
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
|
||||||
let t2 = compute_integrity_tag(
|
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
|
||||||
&mut integrity_key,
|
actor
|
||||||
USERAGENT_INTEGRITY_TAG,
|
.ask(Bootstrap {
|
||||||
[key_type_2.as_slice(), b"pubkey".as_ref()],
|
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
|
||||||
);
|
})
|
||||||
assert_ne!(t1, t2);
|
.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();
|
||||||
|
|
||||||
|
const ENTITY_ID: &[u8] = b"entity-id-7";
|
||||||
|
|
||||||
|
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");
|
||||||
|
let _ = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.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();
|
||||||
|
|
||||||
|
const ENTITY_ID: &[u8] = b"entity-id-11";
|
||||||
|
|
||||||
|
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 = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.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();
|
||||||
|
|
||||||
|
const ENTITY_ID: &[u8] = b"entity-id-21";
|
||||||
|
|
||||||
|
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 = check_entity_attestation(&mut conn, &keyholder, &tampered, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn allow_unavailable_lookup_passes_while_sealed() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
const ENTITY_ID: &[u8] = b"entity-id-31";
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(keyholder);
|
||||||
|
|
||||||
|
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
|
||||||
|
let status = check_entity_attestation(&mut conn, &sealed_keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, AttestationStatus::Unavailable);
|
||||||
|
|
||||||
|
#[expect(clippy::disallowed_methods, reason = "test only")]
|
||||||
|
lookup_verified_allow_unavailable(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async {
|
||||||
|
Ok::<_, db::DatabaseError>(DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strict_verify_fails_closed_while_sealed() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
const ENTITY_ID: &[u8] = b"entity-id-41";
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(keyholder);
|
||||||
|
|
||||||
|
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
|
||||||
|
|
||||||
|
let err = verify_entity(&mut conn, &sealed_keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
|
||||||
|
));
|
||||||
|
|
||||||
|
let err = lookup_verified(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async {
|
||||||
|
Ok::<_, db::DatabaseError>(DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lookup_verified_supports_loaded_aggregate() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
const ENTITY_ID: i32 = 77;
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| async {
|
||||||
|
Ok::<_, db::DatabaseError>(DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verified.payload, b"payload-v1".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lookup_verified_allow_unavailable_works_while_sealed() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
const ENTITY_ID: i32 = 78;
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(keyholder);
|
||||||
|
|
||||||
|
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
|
||||||
|
|
||||||
|
#[expect(clippy::disallowed_methods, reason = "test only")]
|
||||||
|
lookup_verified_allow_unavailable(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async {
|
||||||
|
Ok::<_, db::DatabaseError>(DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn extension_trait_lookup_verified_required_works() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
const ENTITY_ID: i32 = 79;
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| {
|
||||||
|
Box::pin(async {
|
||||||
|
Ok::<_, db::DatabaseError>(DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verified.payload, b"payload-v1".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lookup_verified_from_query_helpers_work() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let keyholder = bootstrapped_keyholder(&db).await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
const ENTITY_ID: i32 = 80;
|
||||||
|
|
||||||
|
let entity = DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let verified = lookup_verified_from_query(&mut conn, &keyholder, |_| {
|
||||||
|
Box::pin(async {
|
||||||
|
Ok::<_, db::DatabaseError>((
|
||||||
|
ENTITY_ID,
|
||||||
|
DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verified.payload, b"payload-v1".to_vec());
|
||||||
|
|
||||||
|
drop(keyholder);
|
||||||
|
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
|
||||||
|
|
||||||
|
let err = lookup_verified_from_query(&mut conn, &sealed_keyholder, |_| {
|
||||||
|
Box::pin(async {
|
||||||
|
Ok::<_, db::DatabaseError>((
|
||||||
|
ENTITY_ID,
|
||||||
|
DummyEntity {
|
||||||
|
payload_version: 1,
|
||||||
|
payload: b"payload-v1".to_vec(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs
Normal file
107
server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use hmac::digest::Digest;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
|
||||||
|
pub trait Hashable {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H);
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_numeric {
|
||||||
|
($($t:ty),*) => {
|
||||||
|
$(
|
||||||
|
impl Hashable for $t {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(&self.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
|
||||||
|
|
||||||
|
impl Hashable for &[u8] {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for String {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
let ref_sorted = {
|
||||||
|
let mut sorted = self.iter().collect::<Vec<_>>();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
sorted
|
||||||
|
};
|
||||||
|
for item in ref_sorted {
|
||||||
|
item.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hashable + PartialOrd> Hashable for HashSet<T> {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
let ref_sorted = {
|
||||||
|
let mut sorted = self.iter().collect::<Vec<_>>();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
sorted
|
||||||
|
};
|
||||||
|
for item in ref_sorted {
|
||||||
|
item.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hashable> Hashable for Option<T> {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
match self {
|
||||||
|
Some(value) => {
|
||||||
|
hasher.update([1]);
|
||||||
|
value.hash(hasher);
|
||||||
|
}
|
||||||
|
None => hasher.update([0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hashable> Hashable for Box<T> {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
self.as_ref().hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hashable> Hashable for &T {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
(*self).hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for alloy::primitives::Address {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.as_slice());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for alloy::primitives::U256 {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.to_be_bytes::<32>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for chrono::Duration {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.num_seconds().to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for chrono::DateTime<chrono::Utc> {
|
||||||
|
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||||
|
hasher.update(self.timestamp_millis().to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,11 +102,21 @@ impl KeyCell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User password might be of different length, have not enough entropy, etc...
|
|
||||||
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
|
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
|
||||||
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
|
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
|
||||||
|
let params = {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
argon2::Params::new(8, 1, 1, None).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
argon2::Params::new(262_144, 3, 4, None).unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
|
|
||||||
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
|
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||||
let mut key = SafeCell::new(Key::default());
|
let mut key = SafeCell::new(Key::default());
|
||||||
password.read_inline(|password_source| {
|
password.read_inline(|password_source| {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
|
|||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[mutants::skip]
|
||||||
pub async fn create_test_pool() -> DatabasePool {
|
pub async fn create_test_pool() -> DatabasePool {
|
||||||
use rand::distr::{Alphanumeric, SampleString as _};
|
use rand::distr::{Alphanumeric, SampleString as _};
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -242,7 +242,6 @@ pub struct UseragentClient {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub nonce: i32,
|
pub nonce: i32,
|
||||||
pub public_key: Vec<u8>,
|
pub public_key: Vec<u8>,
|
||||||
pub pubkey_integrity_tag: Option<Vec<u8>>,
|
|
||||||
pub created_at: SqliteTimestamp,
|
pub created_at: SqliteTimestamp,
|
||||||
pub updated_at: SqliteTimestamp,
|
pub updated_at: SqliteTimestamp,
|
||||||
pub key_type: KeyType,
|
pub key_type: KeyType,
|
||||||
@@ -377,3 +376,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,
|
||||||
@@ -178,7 +191,6 @@ diesel::table! {
|
|||||||
id -> Integer,
|
id -> Integer,
|
||||||
nonce -> Integer,
|
nonce -> Integer,
|
||||||
public_key -> Binary,
|
public_key -> Binary,
|
||||||
pubkey_integrity_tag -> Nullable<Binary>,
|
|
||||||
key_type -> Integer,
|
key_type -> Integer,
|
||||||
created_at -> Integer,
|
created_at -> Integer,
|
||||||
updated_at -> Integer,
|
updated_at -> Integer,
|
||||||
@@ -220,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,
|
||||||
|
|||||||
@@ -8,8 +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 kameo::actor::ActorRef;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
actors::keyholder::KeyHolder,
|
||||||
|
crypto::integrity::{self, Verified},
|
||||||
db::{
|
db::{
|
||||||
self, DatabaseError,
|
self, DatabaseError,
|
||||||
models::{
|
models::{
|
||||||
@@ -18,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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -36,6 +39,9 @@ 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}")]
|
||||||
|
Integrity(#[from] integrity::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -57,6 +63,15 @@ pub enum AnalyzeError {
|
|||||||
UnsupportedTransactionType,
|
UnsupportedTransactionType,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ListError {
|
||||||
|
#[error("Database error")]
|
||||||
|
Database(#[from] crate::db::DatabaseError),
|
||||||
|
|
||||||
|
#[error("Integrity verification failed for grant")]
|
||||||
|
Integrity(#[from] integrity::Error),
|
||||||
|
}
|
||||||
|
|
||||||
/// Controls whether a transaction should be executed or only validated
|
/// Controls whether a transaction should be executed or only validated
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunKind {
|
pub enum RunKind {
|
||||||
@@ -75,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);
|
||||||
@@ -115,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 {
|
||||||
@@ -123,18 +147,51 @@ 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 verified_settings =
|
||||||
|
match integrity::lookup_verified_from_query(&mut conn, &self.keyholder, |conn| {
|
||||||
|
let context = context.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let grant = P::try_find_grant(&context, conn)
|
||||||
|
.await
|
||||||
|
.map_err(DatabaseError::from)?
|
||||||
|
.ok_or_else(|| DatabaseError::from(diesel::result::Error::NotFound))?;
|
||||||
|
|
||||||
|
Ok::<_, DatabaseError>((grant.common_settings_id, grant.settings))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(verified) => verified,
|
||||||
|
Err(integrity::Error::Database(DatabaseError::Connection(
|
||||||
|
diesel::result::Error::NotFound,
|
||||||
|
))) => return Err(PolicyError::NoMatchingGrant),
|
||||||
|
Err(err) => return Err(PolicyError::Integrity(err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut grant = P::try_find_grant(&context, &mut conn)
|
||||||
.await
|
.await
|
||||||
.map_err(DatabaseError::from)?
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(PolicyError::NoMatchingGrant)?;
|
.ok_or(PolicyError::NoMatchingGrant)?;
|
||||||
|
|
||||||
let mut violations =
|
// IMPORTANT: policy evaluation uses extra non-integrity fields from Grant
|
||||||
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
// (e.g., per-policy ids), so we currently reload Grant after the query-native
|
||||||
.await
|
// integrity check over canonicalized settings.
|
||||||
.map_err(DatabaseError::from)?;
|
grant.settings = verified_settings.into_inner();
|
||||||
|
|
||||||
|
let mut violations = check_shared_constraints(
|
||||||
|
&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
|
||||||
@@ -143,12 +200,14 @@ 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 as i32,
|
||||||
eth_value: utils::u256_to_bytes(context.value).to_vec(),
|
eth_value: utils::u256_to_bytes(context.value).to_vec(),
|
||||||
@@ -172,15 +231,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: CombinedSettings<P::Settings>,
|
||||||
) -> Result<i32, DatabaseError> {
|
) -> Result<Verified<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| {
|
||||||
@@ -189,25 +252,25 @@ impl Engine {
|
|||||||
|
|
||||||
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
|
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
|
||||||
.values(&NewEvmBasicGrant {
|
.values(&NewEvmBasicGrant {
|
||||||
chain_id: full_grant.basic.chain as i32,
|
chain_id: full_grant.shared.chain as i32,
|
||||||
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),
|
||||||
@@ -217,7 +280,14 @@ 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 verified_entity_id =
|
||||||
|
integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||||
|
|
||||||
|
QueryResult::Ok(verified_entity_id)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -225,33 +295,46 @@ 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<Vec<Grant<Y>>, ListError>
|
||||||
|
where
|
||||||
|
Y: From<Kind::Settings>,
|
||||||
|
{
|
||||||
|
let all_grants = Kind::find_all_grants(conn)
|
||||||
|
.await
|
||||||
|
.map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
|
let mut verified_grants = Vec::with_capacity(all_grants.len());
|
||||||
|
|
||||||
|
// Verify integrity of all grants before returning any results.
|
||||||
|
for grant in all_grants {
|
||||||
|
integrity::verify_entity(
|
||||||
|
conn,
|
||||||
|
&self.keyholder,
|
||||||
|
&grant.settings,
|
||||||
|
grant.common_settings_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
verified_grants.push(Grant {
|
||||||
|
id: grant.id,
|
||||||
|
common_settings_id: grant.common_settings_id,
|
||||||
|
settings: grant.settings.generalize(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(verified_grants)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -297,3 +380,255 @@ impl Engine {
|
|||||||
Err(VetError::UnsupportedTransactionType)
|
Err(VetError::UnsupportedTransactionType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use alloy::primitives::{Address, Bytes, U256, address};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use diesel::{SelectableHelper, insert_into};
|
||||||
|
use diesel_async::RunQueryDsl;
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
use crate::db::{
|
||||||
|
self, DatabaseConnection,
|
||||||
|
models::{
|
||||||
|
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
||||||
|
},
|
||||||
|
schema::{evm_basic_grant, evm_transaction_log},
|
||||||
|
};
|
||||||
|
use crate::evm::policies::{
|
||||||
|
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::check_shared_constraints;
|
||||||
|
|
||||||
|
const WALLET_ACCESS_ID: i32 = 1;
|
||||||
|
const CHAIN_ID: u64 = 1;
|
||||||
|
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
|
||||||
|
|
||||||
|
fn context() -> EvalContext {
|
||||||
|
EvalContext {
|
||||||
|
target: EvmWalletAccess {
|
||||||
|
id: WALLET_ACCESS_ID,
|
||||||
|
wallet_id: 10,
|
||||||
|
client_id: 20,
|
||||||
|
created_at: SqliteTimestamp(Utc::now()),
|
||||||
|
},
|
||||||
|
chain: CHAIN_ID,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: U256::ZERO,
|
||||||
|
calldata: Bytes::new(),
|
||||||
|
max_fee_per_gas: 100,
|
||||||
|
max_priority_fee_per_gas: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_settings() -> SharedGrantSettings {
|
||||||
|
SharedGrantSettings {
|
||||||
|
wallet_access_id: WALLET_ACCESS_ID,
|
||||||
|
chain: CHAIN_ID,
|
||||||
|
valid_from: None,
|
||||||
|
valid_until: None,
|
||||||
|
max_gas_fee_per_gas: None,
|
||||||
|
max_priority_fee_per_gas: None,
|
||||||
|
rate_limit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_basic_grant(
|
||||||
|
conn: &mut DatabaseConnection,
|
||||||
|
shared: &SharedGrantSettings,
|
||||||
|
) -> EvmBasicGrant {
|
||||||
|
insert_into(evm_basic_grant::table)
|
||||||
|
.values(NewEvmBasicGrant {
|
||||||
|
wallet_access_id: shared.wallet_access_id,
|
||||||
|
chain_id: shared.chain as i32,
|
||||||
|
valid_from: shared.valid_from.map(SqliteTimestamp),
|
||||||
|
valid_until: shared.valid_until.map(SqliteTimestamp),
|
||||||
|
max_gas_fee_per_gas: shared
|
||||||
|
.max_gas_fee_per_gas
|
||||||
|
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
|
||||||
|
max_priority_fee_per_gas: shared
|
||||||
|
.max_priority_fee_per_gas
|
||||||
|
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
|
||||||
|
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
|
||||||
|
rate_limit_window_secs: shared
|
||||||
|
.rate_limit
|
||||||
|
.as_ref()
|
||||||
|
.map(|limit| limit.window.num_seconds() as i32),
|
||||||
|
revoked_at: None,
|
||||||
|
})
|
||||||
|
.returning(EvmBasicGrant::as_select())
|
||||||
|
.get_result(conn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::matching_chain(CHAIN_ID, false)]
|
||||||
|
#[case::mismatching_chain(CHAIN_ID + 1, true)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn check_shared_constraints_enforces_chain_id(
|
||||||
|
#[case] context_chain: u64,
|
||||||
|
#[case] expect_mismatch: bool,
|
||||||
|
) {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let context = EvalContext {
|
||||||
|
chain: context_chain,
|
||||||
|
..context()
|
||||||
|
};
|
||||||
|
|
||||||
|
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
violations
|
||||||
|
.iter()
|
||||||
|
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
|
||||||
|
expect_mismatch
|
||||||
|
);
|
||||||
|
|
||||||
|
if expect_mismatch {
|
||||||
|
assert_eq!(violations.len(), 1);
|
||||||
|
} else {
|
||||||
|
assert!(violations.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
|
||||||
|
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
|
||||||
|
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
|
||||||
|
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn check_shared_constraints_enforces_validity_window(
|
||||||
|
#[case] valid_from: Option<chrono::DateTime<Utc>>,
|
||||||
|
#[case] valid_until: Option<chrono::DateTime<Utc>>,
|
||||||
|
#[case] expect_invalid_time: bool,
|
||||||
|
) {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let shared = SharedGrantSettings {
|
||||||
|
valid_from,
|
||||||
|
valid_until,
|
||||||
|
..shared_settings()
|
||||||
|
};
|
||||||
|
|
||||||
|
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
violations
|
||||||
|
.iter()
|
||||||
|
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
|
||||||
|
expect_invalid_time
|
||||||
|
);
|
||||||
|
|
||||||
|
if expect_invalid_time {
|
||||||
|
assert_eq!(violations.len(), 1);
|
||||||
|
} else {
|
||||||
|
assert!(violations.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
|
||||||
|
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
|
||||||
|
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
|
||||||
|
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn check_shared_constraints_enforces_gas_fee_caps(
|
||||||
|
#[case] max_gas_fee_per_gas: Option<U256>,
|
||||||
|
#[case] max_priority_fee_per_gas: Option<U256>,
|
||||||
|
#[case] actual_max_fee_per_gas: u128,
|
||||||
|
#[case] actual_max_priority_fee_per_gas: u128,
|
||||||
|
#[case] expect_gas_limit_violation: bool,
|
||||||
|
) {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let context = EvalContext {
|
||||||
|
max_fee_per_gas: actual_max_fee_per_gas,
|
||||||
|
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
|
||||||
|
..context()
|
||||||
|
};
|
||||||
|
|
||||||
|
let shared = SharedGrantSettings {
|
||||||
|
max_gas_fee_per_gas,
|
||||||
|
max_priority_fee_per_gas,
|
||||||
|
..shared_settings()
|
||||||
|
};
|
||||||
|
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
violations
|
||||||
|
.iter()
|
||||||
|
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
|
||||||
|
expect_gas_limit_violation
|
||||||
|
);
|
||||||
|
|
||||||
|
if expect_gas_limit_violation {
|
||||||
|
assert_eq!(violations.len(), 1);
|
||||||
|
} else {
|
||||||
|
assert!(violations.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::under_rate_limit(2, false)]
|
||||||
|
#[case::at_rate_limit(1, true)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn check_shared_constraints_enforces_rate_limit(
|
||||||
|
#[case] rate_limit_count: u32,
|
||||||
|
#[case] expect_rate_limit_violation: bool,
|
||||||
|
) {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
|
let shared = SharedGrantSettings {
|
||||||
|
rate_limit: Some(TransactionRateLimit {
|
||||||
|
count: rate_limit_count,
|
||||||
|
window: Duration::hours(1),
|
||||||
|
}),
|
||||||
|
..shared_settings()
|
||||||
|
};
|
||||||
|
|
||||||
|
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
|
||||||
|
|
||||||
|
insert_into(evm_transaction_log::table)
|
||||||
|
.values(NewEvmTransactionLog {
|
||||||
|
grant_id: basic_grant.id,
|
||||||
|
wallet_access_id: WALLET_ACCESS_ID,
|
||||||
|
chain_id: CHAIN_ID as i32,
|
||||||
|
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
|
||||||
|
signed_at: SqliteTimestamp(Utc::now()),
|
||||||
|
})
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
violations
|
||||||
|
.iter()
|
||||||
|
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
|
||||||
|
expect_rate_limit_violation
|
||||||
|
);
|
||||||
|
|
||||||
|
if expect_rate_limit_violation {
|
||||||
|
assert_eq!(violations.len(), 1);
|
||||||
|
} else {
|
||||||
|
assert!(violations.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
crypto::integrity::v1::Integrable,
|
||||||
db::models::{self, EvmBasicGrant, EvmWalletAccess},
|
db::models::{self, EvmBasicGrant, EvmWalletAccess},
|
||||||
evm::utils,
|
evm::utils,
|
||||||
};
|
};
|
||||||
@@ -55,6 +56,9 @@ pub enum EvalViolation {
|
|||||||
|
|
||||||
#[error("Transaction type is not allowed by this grant")]
|
#[error("Transaction type is not allowed by this grant")]
|
||||||
InvalidTransactionType,
|
InvalidTransactionType,
|
||||||
|
|
||||||
|
#[error("Mismatching chain ID")]
|
||||||
|
MismatchingChainId { expected: ChainId, actual: ChainId },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type DatabaseID = i32;
|
pub type DatabaseID = i32;
|
||||||
@@ -62,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>;
|
||||||
@@ -124,13 +127,13 @@ 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)]
|
||||||
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)]
|
||||||
pub struct VolumeRateLimit {
|
pub struct VolumeRateLimit {
|
||||||
pub max_volume: U256,
|
pub max_volume: U256,
|
||||||
pub window: Duration,
|
pub window: Duration,
|
||||||
@@ -151,7 +154,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
|
||||||
@@ -197,7 +200,57 @@ pub enum SpecificGrant {
|
|||||||
TokenTransfer(token_transfers::Settings),
|
TokenTransfer(token_transfers::Settings),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct FullGrant<PolicyGrant> {
|
#[derive(Debug, Clone)]
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::crypto::integrity::hashing::Hashable;
|
||||||
|
|
||||||
|
impl Hashable for TransactionRateLimit {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.count.hash(hasher);
|
||||||
|
self.window.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for VolumeRateLimit {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.max_volume.hash(hasher);
|
||||||
|
self.window.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashable for SharedGrantSettings {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.wallet_access_id.hash(hasher);
|
||||||
|
self.chain.hash(hasher);
|
||||||
|
self.valid_from.hash(hasher);
|
||||||
|
self.valid_until.hash(hasher);
|
||||||
|
self.max_gas_fee_per_gas.hash(hasher);
|
||||||
|
self.max_priority_fee_per_gas.hash(hasher);
|
||||||
|
self.rate_limit.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P: Hashable> Hashable for CombinedSettings<P> {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.shared.hash(hasher);
|
||||||
|
self.specific.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite;
|
|||||||
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
|
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
|
||||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
|
||||||
|
use crate::crypto::integrity::v1::Integrable;
|
||||||
use crate::db::models::{
|
use crate::db::models::{
|
||||||
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
|
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
|
||||||
NewEvmEtherTransferLimit, SqliteTimestamp,
|
NewEvmEtherTransferLimit, SqliteTimestamp,
|
||||||
};
|
};
|
||||||
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
|
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
|
||||||
use crate::evm::policies::{
|
use crate::evm::policies::{
|
||||||
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
|
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
@@ -56,6 +57,18 @@ pub struct Settings {
|
|||||||
pub target: Vec<Address>,
|
pub target: Vec<Address>,
|
||||||
pub limit: VolumeRateLimit,
|
pub limit: VolumeRateLimit,
|
||||||
}
|
}
|
||||||
|
impl Integrable for Settings {
|
||||||
|
const KIND: &'static str = "EtherTransfer";
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::crypto::integrity::hashing::Hashable;
|
||||||
|
|
||||||
|
impl Hashable for Settings {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.target.hash(hasher);
|
||||||
|
self.limit.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<Settings> for SpecificGrant {
|
impl From<Settings> for SpecificGrant {
|
||||||
fn from(val: Settings) -> SpecificGrant {
|
fn from(val: Settings) -> SpecificGrant {
|
||||||
@@ -95,17 +108,18 @@ async fn check_rate_limits(
|
|||||||
db: &mut impl AsyncConnection<Backend = Sqlite>,
|
db: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
) -> QueryResult<Vec<EvalViolation>> {
|
) -> QueryResult<Vec<EvalViolation>> {
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
let window = grant.settings.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.common_settings_id, window, db).await?;
|
||||||
|
|
||||||
let window_start = chrono::Utc::now() - grant.settings.limit.window;
|
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
|
||||||
let prospective_cumulative_volume: U256 = past_transaction
|
let prospective_cumulative_volume: U256 = past_transaction
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, timestamp)| timestamp >= &window_start)
|
.filter(|(_, timestamp)| timestamp >= &window_start)
|
||||||
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
|
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
|
||||||
|
|
||||||
if prospective_cumulative_volume > grant.settings.limit.max_volume {
|
if prospective_cumulative_volume > grant.settings.specific.limit.max_volume {
|
||||||
violations.push(EvalViolation::VolumetricLimitExceeded);
|
violations.push(EvalViolation::VolumetricLimitExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +152,7 @@ impl Policy for EtherTransfer {
|
|||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
|
|
||||||
// Check if the target address is within the grant's allowed targets
|
// Check if the target address is within the grant's allowed targets
|
||||||
if !grant.settings.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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,20 +250,21 @@ impl Policy for EtherTransfer {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let settings = Settings {
|
|
||||||
target: targets,
|
|
||||||
limit: VolumeRateLimit {
|
|
||||||
max_volume: utils::try_bytes_to_u256(&limit.max_volume)
|
|
||||||
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
|
|
||||||
window: chrono::Duration::seconds(limit.window_secs as i64),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
||||||
|
target: targets,
|
||||||
|
limit: VolumeRateLimit {
|
||||||
|
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(|err| {
|
||||||
|
diesel::result::Error::DeserializationError(Box::new(err))
|
||||||
|
})?,
|
||||||
|
window: chrono::Duration::seconds(limit.window_secs as i64),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,15 +342,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 as i64),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -207,9 +212,11 @@ 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(1u64));
|
let context = ctx(ALLOWED, U256::from(1u64));
|
||||||
let m = EtherTransfer::analyze(&context).unwrap();
|
let m = EtherTransfer::analyze(&context).unwrap();
|
||||||
@@ -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 crate::crypto::integrity::hashing::Hashable;
|
||||||
|
|
||||||
|
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
|
||||||
|
let mut shuffled = addrs.clone();
|
||||||
|
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
|
||||||
|
|
||||||
|
let limit = VolumeRateLimit {
|
||||||
|
max_volume: U256::from(max_volume),
|
||||||
|
window: Duration::seconds(window_secs),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut h1 = sha2::Sha256::new();
|
||||||
|
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
|
||||||
|
|
||||||
|
let mut h2 = sha2::Sha256::new();
|
||||||
|
Settings { target: shuffled, limit }.hash(&mut h2);
|
||||||
|
|
||||||
|
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn find_all_grants_empty_db() {
|
async fn find_all_grants_empty_db() {
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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::sqlite::Sqlite;
|
||||||
|
use diesel::{ExpressionMethods, prelude::*};
|
||||||
|
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 {
|
||||||
pub(crate) token: &'static TokenInfo,
|
pub token: &'static TokenInfo,
|
||||||
pub(crate) to: Address,
|
pub to: Address,
|
||||||
pub(crate) 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 {
|
||||||
@@ -64,6 +68,20 @@ pub struct Settings {
|
|||||||
pub target: Option<Address>,
|
pub target: Option<Address>,
|
||||||
pub volume_limits: Vec<VolumeRateLimit>,
|
pub volume_limits: Vec<VolumeRateLimit>,
|
||||||
}
|
}
|
||||||
|
impl Integrable for Settings {
|
||||||
|
const KIND: &'static str = "TokenTransfer";
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::crypto::integrity::hashing::Hashable;
|
||||||
|
|
||||||
|
impl Hashable for Settings {
|
||||||
|
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
|
||||||
|
self.token_contract.hash(hasher);
|
||||||
|
self.target.hash(hasher);
|
||||||
|
self.volume_limits.hash(hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<Settings> for SpecificGrant {
|
impl From<Settings> for SpecificGrant {
|
||||||
fn from(val: Settings) -> SpecificGrant {
|
fn from(val: Settings) -> SpecificGrant {
|
||||||
SpecificGrant::TokenTransfer(val)
|
SpecificGrant::TokenTransfer(val)
|
||||||
@@ -106,13 +124,20 @@ async fn check_volume_rate_limits(
|
|||||||
) -> QueryResult<Vec<EvalViolation>> {
|
) -> QueryResult<Vec<EvalViolation>> {
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
|
|
||||||
let Some(longest_window) = grant.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 = chrono::Utc::now() - limit.window;
|
||||||
let prospective_cumulative_volume: U256 = past_transfers
|
let prospective_cumulative_volume: U256 = past_transfers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -158,7 +183,7 @@ 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 });
|
||||||
@@ -261,17 +286,17 @@ impl Policy for TokenTransfer {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let settings = Settings {
|
|
||||||
token_contract: Address::from(token_contract),
|
|
||||||
target,
|
|
||||||
volume_limits,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
||||||
|
token_contract: Address::from(token_contract),
|
||||||
|
target,
|
||||||
|
volume_limits,
|
||||||
|
},
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +394,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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -247,9 +254,11 @@ async fn evaluate_passes_volume_at_exact_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);
|
||||||
@@ -290,9 +299,11 @@ 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(1u64));
|
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
|
||||||
let context = ctx(DAI, calldata);
|
let context = ctx(DAI, calldata);
|
||||||
@@ -313,9 +324,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 +362,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 +408,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 crate::crypto::integrity::hashing::Hashable;
|
||||||
|
|
||||||
|
let limits: Vec<VolumeRateLimit> = raw_limits
|
||||||
|
.iter()
|
||||||
|
.map(|(max_vol, window_secs)| VolumeRateLimit {
|
||||||
|
max_volume: U256::from(*max_vol),
|
||||||
|
window: Duration::seconds(*window_secs),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut shuffled = limits.clone();
|
||||||
|
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
|
||||||
|
|
||||||
|
let mut h1 = sha2::Sha256::new();
|
||||||
|
Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1);
|
||||||
|
|
||||||
|
let mut h2 = sha2::Sha256::new();
|
||||||
|
Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2);
|
||||||
|
|
||||||
|
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn find_all_grants_empty_db() {
|
async fn find_all_grants_empty_db() {
|
||||||
@@ -434,9 +482,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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ impl<'a> AuthTransportAdapter<'a> {
|
|||||||
auth::Error::ApproveError(auth::ApproveError::Internal)
|
auth::Error::ApproveError(auth::ApproveError::Internal)
|
||||||
| auth::Error::DatabasePoolUnavailable
|
| auth::Error::DatabasePoolUnavailable
|
||||||
| auth::Error::DatabaseOperationFailed
|
| auth::Error::DatabaseOperationFailed
|
||||||
|
| auth::Error::IntegrityCheckFailed
|
||||||
| auth::Error::Transport => ProtoAuthResult::Internal,
|
| auth::Error::Transport => ProtoAuthResult::Internal,
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use arbiter_proto::proto::{
|
|||||||
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
|
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
|
||||||
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
|
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
|
||||||
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
|
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
|
||||||
eval_violation::Kind as ProtoEvalViolationKind,
|
eval_violation as proto_eval_violation, eval_violation::Kind as ProtoEvalViolationKind,
|
||||||
specific_meaning::Meaning as ProtoSpecificMeaningKind,
|
specific_meaning::Meaning as ProtoSpecificMeaningKind,
|
||||||
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
|
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
|
||||||
},
|
},
|
||||||
@@ -79,6 +79,12 @@ impl Convert for EvalViolation {
|
|||||||
EvalViolation::InvalidTransactionType => {
|
EvalViolation::InvalidTransactionType => {
|
||||||
ProtoEvalViolationKind::InvalidTransactionType(())
|
ProtoEvalViolationKind::InvalidTransactionType(())
|
||||||
}
|
}
|
||||||
|
EvalViolation::MismatchingChainId { expected, actual } => {
|
||||||
|
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ProtoEvalViolation { kind: Some(kind) }
|
ProtoEvalViolation { kind: Some(kind) }
|
||||||
@@ -108,12 +114,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,13 +114,16 @@ async fn handle_grant_list(
|
|||||||
grants: grants
|
grants: grants
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|grant| GrantEntry {
|
.map(|grant| GrantEntry {
|
||||||
id: grant.id,
|
id: grant.common_settings_id,
|
||||||
wallet_access_id: grant.shared.wallet_access_id,
|
wallet_access_id: grant.settings.shared.wallet_access_id,
|
||||||
shared: Some(grant.shared.convert()),
|
shared: Some(grant.settings.shared.convert()),
|
||||||
specific: Some(grant.settings.convert()),
|
specific: Some(grant.settings.specific.convert()),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}),
|
}),
|
||||||
|
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
|
||||||
|
EvmGrantListResult::Error(ProtoEvmError::VaultSealed.into())
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = ?err, "Failed to list EVM grants");
|
warn!(error = ?err, "Failed to list EVM grants");
|
||||||
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
|
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
|
||||||
@@ -147,7 +150,10 @@ async fn handle_grant_create(
|
|||||||
.try_convert()?;
|
.try_convert()?;
|
||||||
|
|
||||||
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.into_inner()),
|
||||||
|
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 +177,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())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use tracing::info;
|
|||||||
const PORT: u16 = 50051;
|
const PORT: u16 = 50051;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
#[mutants::skip]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
aws_lc_rs::default_provider().install_default().unwrap();
|
aws_lc_rs::default_provider().install_default().unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
use arbiter_proto::ClientMetadata;
|
use arbiter_proto::ClientMetadata;
|
||||||
use arbiter_proto::transport::{Receiver, Sender};
|
use arbiter_proto::transport::{Receiver, Sender};
|
||||||
use arbiter_server::actors::GlobalActors;
|
|
||||||
use arbiter_server::{
|
use arbiter_server::{
|
||||||
actors::client::{ClientConnection, auth, connect_client},
|
actors::{
|
||||||
db,
|
GlobalActors,
|
||||||
|
client::{ClientConnection, ClientCredentials, auth, connect_client},
|
||||||
|
keyholder::Bootstrap,
|
||||||
|
},
|
||||||
|
crypto::integrity,
|
||||||
|
db::{self, schema},
|
||||||
|
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||||
};
|
};
|
||||||
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
|
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
@@ -21,7 +26,8 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
|
|||||||
|
|
||||||
async fn insert_registered_client(
|
async fn insert_registered_client(
|
||||||
db: &db::DatabasePool,
|
db: &db::DatabasePool,
|
||||||
pubkey: Vec<u8>,
|
actors: &GlobalActors,
|
||||||
|
pubkey: ed25519_dalek::VerifyingKey,
|
||||||
metadata: &ClientMetadata,
|
metadata: &ClientMetadata,
|
||||||
) {
|
) {
|
||||||
use arbiter_server::db::schema::{client_metadata, program_client};
|
use arbiter_server::db::schema::{client_metadata, program_client};
|
||||||
@@ -37,23 +43,64 @@ async fn insert_registered_client(
|
|||||||
.get_result(&mut conn)
|
.get_result(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
insert_into(program_client::table)
|
let client_id: i32 = insert_into(program_client::table)
|
||||||
.values((
|
.values((
|
||||||
program_client::public_key.eq(pubkey),
|
program_client::public_key.eq(pubkey.to_bytes().to_vec()),
|
||||||
program_client::metadata_id.eq(metadata_id),
|
program_client::metadata_id.eq(metadata_id),
|
||||||
))
|
))
|
||||||
|
.returning(program_client::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
integrity::sign_entity(
|
||||||
|
&mut conn,
|
||||||
|
&actors.key_holder,
|
||||||
|
&ClientCredentials { pubkey, nonce: 1 },
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let sentinel_key = ed25519_dalek::SigningKey::generate(&mut rand::rng())
|
||||||
|
.verifying_key()
|
||||||
|
.to_bytes()
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
insert_into(schema::useragent_client::table)
|
||||||
|
.values((
|
||||||
|
schema::useragent_client::public_key.eq(sentinel_key),
|
||||||
|
schema::useragent_client::key_type.eq(1i32),
|
||||||
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
|
||||||
|
insert_bootstrap_sentinel_useragent(db).await;
|
||||||
|
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors
|
||||||
|
.key_holder
|
||||||
|
.ask(Bootstrap {
|
||||||
|
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
actors
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
pub async fn test_unregistered_pubkey_rejected() {
|
pub async fn test_unregistered_pubkey_rejected() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
let actors = spawn_test_actors(&db).await;
|
||||||
let props = ClientConnection::new(db.clone(), actors);
|
let props = ClientConnection::new(db.clone(), actors);
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let mut server_transport = server_transport;
|
let mut server_transport = server_transport;
|
||||||
@@ -78,20 +125,19 @@ pub async fn test_unregistered_pubkey_rejected() {
|
|||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
pub async fn test_challenge_auth() {
|
pub async fn test_challenge_auth() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = spawn_test_actors(&db).await;
|
||||||
|
|
||||||
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||||
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
|
|
||||||
|
|
||||||
insert_registered_client(
|
insert_registered_client(
|
||||||
&db,
|
&db,
|
||||||
pubkey_bytes.clone(),
|
&actors,
|
||||||
|
new_key.verifying_key(),
|
||||||
&metadata("client", Some("desc"), Some("1.0.0")),
|
&metadata("client", Some("desc"), Some("1.0.0")),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
|
||||||
|
|
||||||
let props = ClientConnection::new(db.clone(), actors);
|
let props = ClientConnection::new(db.clone(), actors);
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let mut server_transport = server_transport;
|
let mut server_transport = server_transport;
|
||||||
@@ -147,34 +193,13 @@ pub async fn test_challenge_auth() {
|
|||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
pub async fn test_metadata_unchanged_does_not_append_history() {
|
pub async fn test_metadata_unchanged_does_not_append_history() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
let actors = spawn_test_actors(&db).await;
|
||||||
let props = ClientConnection::new(db.clone(), actors);
|
|
||||||
|
|
||||||
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||||
let requested = metadata("client", Some("desc"), Some("1.0.0"));
|
let requested = metadata("client", Some("desc"), Some("1.0.0"));
|
||||||
|
|
||||||
{
|
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
|
||||||
use arbiter_server::db::schema::{client_metadata, program_client};
|
|
||||||
let mut conn = db.get().await.unwrap();
|
let props = ClientConnection::new(db.clone(), actors);
|
||||||
let metadata_id: i32 = insert_into(client_metadata::table)
|
|
||||||
.values((
|
|
||||||
client_metadata::name.eq(&requested.name),
|
|
||||||
client_metadata::description.eq(&requested.description),
|
|
||||||
client_metadata::version.eq(&requested.version),
|
|
||||||
))
|
|
||||||
.returning(client_metadata::id)
|
|
||||||
.get_result(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
insert_into(program_client::table)
|
|
||||||
.values((
|
|
||||||
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
|
|
||||||
program_client::metadata_id.eq(metadata_id),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
@@ -225,33 +250,18 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
|
|||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
|
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
let actors = spawn_test_actors(&db).await;
|
||||||
let props = ClientConnection::new(db.clone(), actors);
|
|
||||||
|
|
||||||
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||||
|
|
||||||
{
|
insert_registered_client(
|
||||||
use arbiter_server::db::schema::{client_metadata, program_client};
|
&db,
|
||||||
let mut conn = db.get().await.unwrap();
|
&actors,
|
||||||
let metadata_id: i32 = insert_into(client_metadata::table)
|
new_key.verifying_key(),
|
||||||
.values((
|
&metadata("client", Some("old"), Some("1.0.0")),
|
||||||
client_metadata::name.eq("client"),
|
)
|
||||||
client_metadata::description.eq(Some("old")),
|
.await;
|
||||||
client_metadata::version.eq(Some("1.0.0")),
|
|
||||||
))
|
let props = ClientConnection::new(db.clone(), actors);
|
||||||
.returning(client_metadata::id)
|
|
||||||
.get_result(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
insert_into(program_client::table)
|
|
||||||
.values((
|
|
||||||
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
|
|
||||||
program_client::metadata_id.eq(metadata_id),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
@@ -322,3 +332,59 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[test_log::test]
|
||||||
|
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = spawn_test_actors(&db).await;
|
||||||
|
|
||||||
|
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||||
|
let requested = metadata("client", Some("desc"), Some("1.0.0"));
|
||||||
|
|
||||||
|
{
|
||||||
|
use arbiter_server::db::schema::{client_metadata, program_client};
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let metadata_id: i32 = insert_into(client_metadata::table)
|
||||||
|
.values((
|
||||||
|
client_metadata::name.eq(&requested.name),
|
||||||
|
client_metadata::description.eq(&requested.description),
|
||||||
|
client_metadata::version.eq(&requested.version),
|
||||||
|
))
|
||||||
|
.returning(client_metadata::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_into(program_client::table)
|
||||||
|
.values((
|
||||||
|
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
|
||||||
|
program_client::metadata_id.eq(metadata_id),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
|
let props = ClientConnection::new(db.clone(), actors);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut server_transport = server_transport;
|
||||||
|
connect_client(props, &mut server_transport).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
test_transport
|
||||||
|
.send(auth::Inbound::AuthChallengeRequest {
|
||||||
|
pubkey: new_key.verifying_key(),
|
||||||
|
metadata: requested,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = test_transport
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("should receive auth rejection");
|
||||||
|
assert!(matches!(response, Err(auth::Error::IntegrityCheckFailed)));
|
||||||
|
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use arbiter_server::{
|
|||||||
GlobalActors,
|
GlobalActors,
|
||||||
bootstrap::GetToken,
|
bootstrap::GetToken,
|
||||||
keyholder::Bootstrap,
|
keyholder::Bootstrap,
|
||||||
user_agent::{AuthPublicKey, UserAgentConnection, auth},
|
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
|
||||||
},
|
},
|
||||||
|
crypto::integrity,
|
||||||
db::{self, schema},
|
db::{self, schema},
|
||||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||||
};
|
};
|
||||||
@@ -20,6 +21,13 @@ use super::common::ChannelTransport;
|
|||||||
pub async fn test_bootstrap_token_auth() {
|
pub async fn test_bootstrap_token_auth() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors
|
||||||
|
.key_holder
|
||||||
|
.ask(Bootstrap {
|
||||||
|
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
|
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
@@ -99,20 +107,39 @@ pub async fn test_bootstrap_invalid_token_auth() {
|
|||||||
pub async fn test_challenge_auth() {
|
pub async fn test_challenge_auth() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors
|
||||||
|
.key_holder
|
||||||
|
.ask(Bootstrap {
|
||||||
|
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||||
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
|
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut conn = db.get().await.unwrap();
|
let mut conn = db.get().await.unwrap();
|
||||||
insert_into(schema::useragent_client::table)
|
let id: i32 = insert_into(schema::useragent_client::table)
|
||||||
.values((
|
.values((
|
||||||
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
|
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
|
||||||
schema::useragent_client::key_type.eq(1i32),
|
schema::useragent_client::key_type.eq(1i32),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.returning(schema::useragent_client::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
integrity::sign_entity(
|
||||||
|
&mut conn,
|
||||||
|
&actors.key_holder,
|
||||||
|
&UserAgentCredentials {
|
||||||
|
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
|
||||||
|
nonce: 1,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
@@ -187,7 +214,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
|
|||||||
.values((
|
.values((
|
||||||
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
|
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
|
||||||
schema::useragent_client::key_type.eq(1i32),
|
schema::useragent_client::key_type.eq(1i32),
|
||||||
schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])),
|
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await
|
.await
|
||||||
@@ -211,7 +237,7 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
task.await.unwrap(),
|
task.await.unwrap(),
|
||||||
Err(auth::Error::InvalidChallengeSolution)
|
Err(auth::Error::Internal { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,20 +246,39 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
|
|||||||
pub async fn test_challenge_auth_rejects_invalid_signature() {
|
pub async fn test_challenge_auth_rejects_invalid_signature() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors
|
||||||
|
.key_holder
|
||||||
|
.ask(Bootstrap {
|
||||||
|
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
|
||||||
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
|
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut conn = db.get().await.unwrap();
|
let mut conn = db.get().await.unwrap();
|
||||||
insert_into(schema::useragent_client::table)
|
let id: i32 = insert_into(schema::useragent_client::table)
|
||||||
.values((
|
.values((
|
||||||
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
|
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
|
||||||
schema::useragent_client::key_type.eq(1i32),
|
schema::useragent_client::key_type.eq(1i32),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.returning(schema::useragent_client::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
integrity::sign_entity(
|
||||||
|
&mut conn,
|
||||||
|
&actors.key_holder,
|
||||||
|
&UserAgentCredentials {
|
||||||
|
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
|
||||||
|
nonce: 1,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let (server_transport, mut test_transport) = ChannelTransport::new();
|
let (server_transport, mut test_transport) = ChannelTransport::new();
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use arbiter_server::{
|
|||||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||||
};
|
};
|
||||||
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
|
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
|
||||||
use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into};
|
|
||||||
use diesel_async::RunQueryDsl;
|
|
||||||
use kameo::actor::Spawn as _;
|
use kameo::actor::Spawn as _;
|
||||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||||
|
|
||||||
@@ -152,42 +150,3 @@ pub async fn test_unseal_retry_after_invalid_key() {
|
|||||||
assert!(matches!(response, Ok(())));
|
assert!(matches!(response, Ok(())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[test_log::test]
|
|
||||||
pub async fn test_unseal_backfills_missing_pubkey_integrity_tags() {
|
|
||||||
let seal_key = b"test-seal-key";
|
|
||||||
let (db, user_agent) = setup_sealed_user_agent(seal_key).await;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut conn = db.get().await.unwrap();
|
|
||||||
insert_into(arbiter_server::db::schema::useragent_client::table)
|
|
||||||
.values((
|
|
||||||
arbiter_server::db::schema::useragent_client::public_key
|
|
||||||
.eq(vec![1u8, 2u8, 3u8, 4u8]),
|
|
||||||
arbiter_server::db::schema::useragent_client::key_type.eq(1i32),
|
|
||||||
arbiter_server::db::schema::useragent_client::pubkey_integrity_tag
|
|
||||||
.eq(Option::<Vec<u8>>::None),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await;
|
|
||||||
let response = user_agent.ask(encrypted_key).await;
|
|
||||||
assert!(matches!(response, Ok(())));
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut conn = db.get().await.unwrap();
|
|
||||||
let tags: Vec<Option<Vec<u8>>> = arbiter_server::db::schema::useragent_client::table
|
|
||||||
.select(arbiter_server::db::schema::useragent_client::pubkey_integrity_tag)
|
|
||||||
.load(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
tags.iter()
|
|
||||||
.all(|tag| matches!(tag, Some(v) if v.len() == 32))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user