18 Commits

Author SHA1 Message Date
CleverWild
763058b014 feat(server): unify integrity API and propagate verified IDs through auth/EVM flows
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 21:12:36 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
hdbg
00745bb381 tests(server): fixed for new integrity checks
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 14:49:02 +02:00
hdbg
b122aa464c refactor(server): rework envelopes and integrity check
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-05 14:17:00 +02:00
hdbg
9fab945a00 fix(server): remove stale mentions of miette
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:45:24 +02:00
CleverWild
aeed664e9a chore: inline integrity proto types
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:44:21 +02:00
CleverWild
4057c1fc12 feat(server): integrity envelope engine for EVM grants with HMAC verification 2026-04-05 10:44:21 +02:00
hdbg
f5eb51978d docs: add recovery operators and multi-operator details 2026-04-05 08:27:24 +00:00
hdbg
d997e0f843 docs: add multi-operator governance section 2026-04-05 08:27:24 +00:00
44 changed files with 2636 additions and 707 deletions

View File

@@ -11,6 +11,7 @@ Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **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.
@@ -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:
@@ -72,19 +215,19 @@ This layered design enables:
- **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.
### 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.
---
## 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**.
### 5.2 Unseal Flow
### 6.2 Unseal Flow
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.
- **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:
@@ -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**.
- 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**.
### 6.2 EVM Policies
### 7.2 EVM Policies
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:
@@ -147,7 +324,7 @@ 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.
#### 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:

View File

@@ -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.
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
| Method | Purpose |

View File

@@ -48,6 +48,10 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3"
backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]]
version = "0.9.126"
backend = "cargo:cargo-nextest"
@@ -111,30 +115,37 @@ backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
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"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]]
version = "1.93.0"

View File

@@ -12,6 +12,7 @@ protoc = "29.6"
python = "3.14.3"
ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9"
"cargo:cargo-mutants" = "27.0.0"
[tasks.codegen]
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']

View File

@@ -36,6 +36,10 @@ message GasLimitExceededViolation {
}
message EvalViolation {
message ChainIdMismatch {
uint64 expected = 1;
uint64 actual = 2;
}
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
@@ -43,6 +47,8 @@ message EvalViolation {
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7;
}
}

View File

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

2
server/.gitignore vendored Normal file
View File

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

30
server/Cargo.lock generated
View File

@@ -743,18 +743,24 @@ dependencies = [
"k256",
"kameo",
"memsafe",
"mutants",
"pem",
"proptest",
"prost",
"prost-types",
"rand 0.10.0",
"rcgen",
"restructed",
"rsa",
"rstest",
"rustls",
"secrecy",
"serde_with",
"sha2 0.10.9",
"smlang",
"spki",
"strum 0.28.0",
"subtle",
"test-log",
"thiserror 2.0.18",
"tokio",
@@ -1955,6 +1961,7 @@ version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"serde",
"signature 3.0.0-rc.10",
]
@@ -1967,6 +1974,7 @@ dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"rand_core 0.10.0",
"serde",
"sha2 0.11.0-rc.5",
"subtle",
"zeroize",
@@ -2052,7 +2060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3171,6 +3179,12 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "mutants"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56"
[[package]]
name = "nom"
version = "7.1.3"
@@ -3187,7 +3201,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3212,6 +3226,7 @@ dependencies = [
"num-iter",
"num-traits",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
]
@@ -3634,9 +3649,9 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
@@ -4151,6 +4166,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"serde",
"sha2 0.10.9",
"signature 2.2.0",
"spki",
@@ -4286,7 +4302,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4702,7 +4718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4896,7 +4912,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -42,4 +42,6 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"

View File

@@ -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::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 = "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." },
]

View File

@@ -59,6 +59,10 @@ pub struct 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 {
Self {
transport,

View File

@@ -11,7 +11,7 @@ tokio.workspace = true
futures.workspace = true
hex = "0.4.3"
tonic-prost = "0.14.5"
prost = "0.14.3"
prost.workspace = true
kameo.workspace = true
url = "2.5.8"
miette.workspace = true

View File

@@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [
"tokio",
] }
ed25519-dalek.workspace = true
ed25519-dalek.features = ["serde"]
arbiter-proto.path = "../arbiter-proto"
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -46,15 +47,23 @@ restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6"
k256.workspace = true
k256.features = ["serde"]
rsa.workspace = true
rsa.features = ["serde"]
sha2.workspace = true
hmac = "0.12"
spki.workspace = true
alloy.workspace = true
prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
[dev-dependencies]
insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] }

View File

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

View File

@@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use crate::db::{self, DatabasePool, schema};
@@ -44,14 +45,14 @@ pub struct Bootstrapper {
impl Bootstrapper {
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
.count()
.get_result(&mut conn)
.await?;
drop(conn);
schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?
};
let token = if row_count == 0 {
let token = generate_token().await?;
@@ -69,7 +70,13 @@ impl Bootstrapper {
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
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,
}
}

View File

@@ -9,14 +9,16 @@ use diesel::{
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use kameo::{actor::ActorRef, error::SendError};
use tracing::error;
use crate::{
actors::{
client::{ClientConnection, ClientProfile},
client::{ClientConnection, ClientCredentials, ClientProfile},
flow_coordinator::{self, RequestClientApproval},
keyholder::KeyHolder,
},
crypto::integrity::{self},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
@@ -30,6 +32,8 @@ pub enum Error {
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")]
@@ -38,6 +42,13 @@ pub enum Error {
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)]
pub enum ApproveError {
#[error("Internal error")]
@@ -65,17 +76,69 @@ pub enum Outbound {
AuthSuccess,
}
pub struct ClientInfo {
pub id: i32,
pub current_nonce: i32,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns the current nonce and client ID for a registered client.
/// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce(
async fn get_current_nonce_and_id(
db: &db::DatabasePool,
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 mut conn = db.get().await.map_err(|e| {
@@ -84,34 +147,34 @@ async fn get_client_and_nonce(
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
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))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(conn)
.await
.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)
.set(program_client::nonce.eq(program_client::nonce + 1))
.returning((program_client::id, program_client::nonce))
.get_result(conn)
.await?;
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
integrity::sign_entity(
conn,
&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
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn approve_new_client(
@@ -139,45 +202,63 @@ async fn approve_new_client(
async fn insert_client(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
metadata: &ClientMetadata,
) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client};
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
const NONCE_START: i32 = 1;
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(1), // pre-incremented; challenge uses 0
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
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(
@@ -295,8 +376,11 @@ where
return Err(Error::Transport);
};
let info = match get_client_and_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
Some((id, _)) => {
verify_integrity(&props.db, &props.actors.key_holder, &pubkey).await?;
id
}
None => {
approve_new_client(
&props.actors,
@@ -306,16 +390,13 @@ where
},
)
.await?;
let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
ClientInfo {
id: client_id,
current_nonce: 0,
}
insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
}
};
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
@@ -325,5 +406,5 @@ where
Error::Transport
})?;
Ok(info.id)
Ok(client_id)
}

View File

@@ -4,6 +4,7 @@ use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::{Integrable, hashing::Hashable},
db,
};
@@ -13,6 +14,22 @@ pub struct ClientProfile {
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(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,

View File

@@ -8,15 +8,16 @@ use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self, SqliteTimestamp},
models::{self},
schema,
},
evm::{
self, RunKind,
self, ListError, RunKind,
policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
},
@@ -56,6 +57,9 @@ pub enum Error {
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Actor)]
@@ -71,7 +75,7 @@ impl EvmActor {
// is it safe to seed rng from system once?
// todo: audit
let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone());
let engine = evm::Engine::new(db.clone(), keyholder.clone());
Self {
keyholder,
db,
@@ -132,46 +136,59 @@ impl EvmActor {
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, DatabaseError> {
) -> Result<integrity::Verified<i32>, Error> {
match grant {
SpecificGrant::EtherTransfer(settings) => {
self.engine
.create_grant::<EtherTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
SpecificGrant::EtherTransfer(settings) => self
.engine
.create_grant::<EtherTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from),
SpecificGrant::TokenTransfer(settings) => self
.engine
.create_grant::<TokenTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from),
}
}
#[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok(())
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 keyholder = self.keyholder.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
// let 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]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
Ok(self
.engine
.list_all_grants()
.await
.map_err(DatabaseError::from)?)
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
}
}
#[message]

View File

@@ -4,6 +4,7 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
@@ -12,7 +13,7 @@ use crate::{
crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::compute_integrity_tag,
integrity::v1::HmacSha256,
},
safe_cell::SafeCell,
};
@@ -250,22 +251,6 @@ impl KeyHolder {
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]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -339,6 +324,60 @@ impl KeyHolder {
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]
pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed {

View File

@@ -30,10 +30,26 @@ pub enum Error {
}
impl Error {
fn internal(details: impl Into<String>) -> Self {
Self::Internal {
details: details.into(),
}
#[track_caller]
pub(super) fn internal(details: impl Into<String>, err: &impl std::fmt::Debug) -> Self {
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)
}
}

View File

@@ -1,27 +1,20 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use kameo::error::SendError;
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
keyholder::{self, SignIntegrityTag},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
keyholder::KeyHolder,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
},
crypto::integrity::v1::USERAGENT_INTEGRITY_TAG,
db::schema,
crypto::integrity,
db::{DatabasePool, schema::useragent_client},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
NotAttested,
Unavailable,
}
pub struct ChallengeRequest {
pub pubkey: AuthPublicKey,
}
@@ -50,72 +43,166 @@ smlang::statemachine!(
}
);
async fn create_nonce(
db: &crate::db::DatabasePool,
pubkey_bytes: &[u8],
key_type: crate::db::models::KeyType,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
/// Returns the current nonce, ready to use for the challenge nonce.
async fn get_current_nonce_and_id(
db: &DatabasePool,
key: &AuthPublicKey,
) -> Result<(i32, i32), Error> {
let mut db_conn = db
.get()
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.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)
useragent_client::table
.filter(useragent_client::public_key.eq(key.to_stored_bytes()))
.filter(useragent_client::key_type.eq(key.key_type()))
.select((useragent_client::id, useragent_client::nonce))
.first::<(i32, i32)>(conn)
.await
})
})
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
.map_err(|e| Error::internal("Database operation failed", &e))?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn register_key(
db: &crate::db::DatabasePool,
async fn verify_integrity(
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,
integrity_tag: Option<Vec<u8>>,
) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
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)
let mut conn = db
.get()
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
.map_err(|e| Error::internal("Database unavailable", &e))?;
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(())
}
@@ -141,15 +228,9 @@ where
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
match self.verify_pubkey_attestation_status(&pubkey).await? {
AttestationStatus::Attested | AttestationStatus::Unavailable => {}
AttestationStatus::NotAttested => {
return Err(Error::InvalidChallengeSolution);
}
}
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
@@ -179,32 +260,31 @@ where
token: token.clone(),
})
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
.map_err(|e| Error::internal("Failed to consume bootstrap token", &e))?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
let integrity_tag = self
.try_sign_pubkey_integrity_tag(&pubkey)
.await
.map_err(|err| {
error!(?err, "Failed to sign user-agent pubkey integrity tag");
Error::internal("Failed to sign user-agent pubkey integrity tag")
})?;
register_key(&self.conn.db, &pubkey, integrity_tag).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
match token_ok {
true => {
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
}
#[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)
}
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::{
actors::{GlobalActors, client::ClientProfile},
crypto::integrity::Integrable,
db::{self, models::KeyType},
};
@@ -13,6 +14,16 @@ pub enum AuthPublicKey {
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 {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// 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 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);
}
}

View File

@@ -10,7 +10,6 @@ use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::db::models::{
@@ -18,6 +17,10 @@ use crate::db::models::{
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
crypto::integrity::{self, Verified},
};
use crate::{
actors::{
evm::{
@@ -29,11 +32,66 @@ use crate::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
user_agent::{AuthPublicKey, UserAgentCredentials},
},
db::schema::useragent_client,
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 {
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> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
@@ -120,6 +178,15 @@ pub enum SignTransactionError {
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
@@ -182,6 +249,7 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_useragent_integrity().await?;
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -243,6 +311,7 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_useragent_integrity().await?;
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -316,12 +385,15 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[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 {
Ok(grants) => Ok(grants),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
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,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
) -> Result<Verified<i32>, GrantMutationError> {
match self
.props
.actors
@@ -340,26 +412,33 @@ impl UserAgentSession {
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant"))
Err(GrantMutationError::Internal)
}
}
}
#[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
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.ask(UseragentDeleteGrant {
_grant_id: grant_id,
})
.await
{
Ok(()) => Ok(()),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant"))
Err(GrantMutationError::Internal)
}
}
}

View File

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

View File

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

View File

@@ -1,78 +1,681 @@
use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _};
use chacha20poly1305::Key;
use hmac::Mac as _;
use crate::actors::keyholder;
use hmac::Hmac;
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();
pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes();
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _;
/// Computes an integrity tag for a specific domain and payload shape.
pub fn compute_integrity_tag<'a, I>(
integrity_key: &mut KeyCell,
purpose_tag: &[u8],
data_parts: I,
) -> [u8; 32]
pub mod hashing;
use self::hashing::Hashable;
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
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
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];
integrity_key.0.read_inline(|integrity_key_bytes: &Key| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref())
.expect("HMAC key initialization must not fail for 32-byte key");
mac.update(purpose_tag);
for data_part in data_parts {
mac.update(data_part);
pub async fn lookup_verified_allow_unavailable<E, C, F, Fut>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
entity_id: impl Into<EntityId>,
load: F,
) -> Result<Verified<E>, Error>
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)]
mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use sha2::Digest;
use crate::{
crypto::{derive_key, encryption::v1::generate_salt},
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
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]
pub fn integrity_tag_deterministic() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
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);
#[derive(Clone, Debug)]
struct DummyEntity {
payload_version: i32,
payload: Vec<u8>,
}
#[test]
pub fn integrity_tag_changes_with_payload() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type_1 = 1i32.to_be_bytes();
let key_type_2 = 2i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_1.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_2.as_slice(), b"pubkey".as_ref()],
);
assert_ne!(t1, t2);
impl Hashable for DummyEntity {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.payload_version.hash(hasher);
self.payload.hash(hasher);
}
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
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)
));
}
}

View 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());
}
}

View File

@@ -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.
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)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {

View File

@@ -133,6 +133,7 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool)
}
#[mutants::skip]
pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _};

View File

@@ -5,7 +5,7 @@ use crate::db::schema::{
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_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
root_key_history, tls_history,
integrity_envelope, root_key_history, tls_history,
};
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite};
@@ -242,7 +242,6 @@ pub struct UseragentClient {
pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>,
pub pubkey_integrity_tag: Option<Vec<u8>>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
pub key_type: KeyType,
@@ -377,3 +376,22 @@ pub struct EvmTokenTransferLog {
pub value: Vec<u8>,
pub created_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
#[view(
NewIntegrityEnvelope,
derive(Insertable),
omit(id, signed_at, created_at),
attributes_with = "deriveless"
)]
pub struct IntegrityEnvelope {
pub id: i32,
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}

View File

@@ -139,6 +139,19 @@ diesel::table! {
}
}
diesel::table! {
integrity_envelope (id) {
id -> Integer,
entity_kind -> Text,
entity_id -> Binary,
payload_version -> Integer,
key_version -> Integer,
mac -> Binary,
signed_at -> Integer,
created_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -178,7 +191,6 @@ diesel::table! {
id -> Integer,
nonce -> Integer,
public_key -> Binary,
pubkey_integrity_tag -> Nullable<Binary>,
key_type -> Integer,
created_at -> Integer,
updated_at -> Integer,
@@ -220,6 +232,7 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_transaction_log,
evm_wallet,
evm_wallet_access,
integrity_envelope,
program_client,
root_key_history,
tls_history,

View File

@@ -8,8 +8,11 @@ use alloy::{
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::keyholder::KeyHolder,
crypto::integrity::{self, Verified},
db::{
self, DatabaseError,
models::{
@@ -18,8 +21,8 @@ use crate::{
schema::{self, evm_transaction_log},
},
evm::policies::{
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
};
@@ -36,6 +39,9 @@ pub enum PolicyError {
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
NoMatchingGrant,
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error)]
@@ -57,6 +63,15 @@ pub enum AnalyzeError {
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
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind {
@@ -75,6 +90,14 @@ async fn check_shared_constraints(
let mut violations = Vec::new();
let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
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
pub struct Engine {
db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>,
}
impl Engine {
@@ -123,18 +147,51 @@ impl Engine {
context: EvalContext,
meaning: &P::Meaning,
run_kind: RunKind,
) -> Result<(), PolicyError> {
) -> Result<(), PolicyError>
where
P::Settings: Clone,
{
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
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await
.map_err(DatabaseError::from)?;
// IMPORTANT: policy evaluation uses extra non-integrity fields from Grant
// (e.g., per-policy ids), so we currently reload Grant after the query-native
// integrity check over canonicalized settings.
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(
P::evaluate(&context, meaning, &grant, &mut conn)
.await
@@ -143,12 +200,14 @@ impl Engine {
if !violations.is_empty() {
return Err(PolicyError::Violations(violations));
} else if run_kind == RunKind::Execution {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.shared_grant_id,
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
@@ -172,15 +231,19 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool) -> Self {
Self { db }
pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db, keyholder }
}
pub async fn create_grant<P: Policy>(
&self,
full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError> {
full_grant: CombinedSettings<P::Settings>,
) -> Result<Verified<i32>, DatabaseError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn
.transaction(|conn| {
@@ -189,25 +252,25 @@ impl Engine {
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.basic.chain as i32,
wallet_access_id: full_grant.basic.wallet_access_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp),
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.basic
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.basic
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.basic
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.basic
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
@@ -217,7 +280,14 @@ impl Engine {
.get_result(conn)
.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?;
@@ -225,33 +295,46 @@ impl Engine {
Ok(id)
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
let mut conn = self.db.get().await?;
async fn list_one_kind<Kind: Policy, Y>(
&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();
grants.extend(
EtherTransfer::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::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),
}),
);
grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?);
grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?);
Ok(grants)
}
@@ -297,3 +380,255 @@ impl Engine {
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());
}
}
}

View File

@@ -10,6 +10,7 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
use thiserror::Error;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
@@ -55,6 +56,9 @@ pub enum EvalViolation {
#[error("Transaction type is not allowed by this grant")]
InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
}
pub type DatabaseID = i32;
@@ -62,13 +66,12 @@ pub type DatabaseID = i32;
#[derive(Debug)]
pub struct Grant<PolicySettings> {
pub id: DatabaseID,
pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub shared: SharedGrantSettings,
pub settings: PolicySettings,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub settings: CombinedSettings<PolicySettings>,
}
pub 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>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -124,13 +127,13 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VolumeRateLimit {
pub max_volume: U256,
pub window: Duration,
@@ -151,7 +154,7 @@ pub struct SharedGrantSettings {
}
impl SharedGrantSettings {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
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
@@ -197,7 +200,57 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
pub struct FullGrant<PolicyGrant> {
pub basic: SharedGrantSettings,
#[derive(Debug, Clone)]
pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
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);
}
}

View File

@@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
};
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use crate::{
db::{
@@ -56,6 +57,18 @@ pub struct Settings {
pub target: Vec<Address>,
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 {
fn from(val: Settings) -> SpecificGrant {
@@ -95,17 +108,18 @@ async fn check_rate_limits(
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
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
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
.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);
}
@@ -138,7 +152,7 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new();
// 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 });
}
@@ -236,20 +250,21 @@ impl Policy for EtherTransfer {
})
.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 {
id: grant.id,
shared_grant_id: grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
common_settings_id: grant.basic_grant_id,
settings: CombinedSettings {
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 {
id: specific.id,
shared_grant_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
target: targets,
limit: VolumeRateLimit {
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),
common_settings_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings {
target: targets,
limit: VolumeRateLimit {
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),
},
},
},
})

View File

@@ -11,7 +11,10 @@ use crate::db::{
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
@@ -81,8 +84,6 @@ fn shared() -> SharedGrantSettings {
}
}
// ── analyze ─────────────────────────────────────────────────────────────
#[test]
fn analyze_matches_empty_calldata() {
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());
}
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test]
async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await;
@@ -108,9 +107,11 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(vec![ALLOWED], 1_000_000),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -127,9 +128,11 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(vec![ALLOWED], 1_000_000),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
};
let context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -167,9 +170,11 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -207,9 +212,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -248,9 +255,11 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -263,8 +272,6 @@ async fn evaluate_passes_at_exactly_volume_limit() {
);
}
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test]
async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await;
@@ -282,8 +289,11 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some());
let g = found.unwrap();
assert_eq!(g.settings.target, vec![ALLOWED]);
assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64));
assert_eq!(g.settings.specific.target, vec![ALLOWED]);
assert_eq!(
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
}
#[tokio::test]
@@ -320,7 +330,36 @@ async fn try_find_grant_wrong_target_returns_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]
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();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target, vec![ALLOWED]);
assert_eq!(all[0].settings.specific.target, vec![ALLOWED]);
}
#[tokio::test]
@@ -363,8 +402,11 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target.len(), 2);
assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64));
assert_eq!(all[0].settings.specific.target.len(), 2);
assert_eq!(
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
}
#[tokio::test]

View File

@@ -1,20 +1,5 @@
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::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
@@ -26,6 +11,25 @@ use crate::evm::{
},
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};
@@ -38,9 +42,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
pub(crate) token: &'static TokenInfo,
pub(crate) to: Address,
pub(crate) value: U256,
pub token: &'static TokenInfo,
pub to: Address,
pub value: U256,
}
impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -64,6 +68,20 @@ pub struct Settings {
pub target: Option<Address>,
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 {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
@@ -106,13 +124,20 @@ async fn check_volume_rate_limits(
) -> QueryResult<Vec<EvalViolation>> {
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);
};
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 prospective_cumulative_volume: U256 = past_transfers
.iter()
@@ -158,7 +183,7 @@ impl Policy for TokenTransfer {
return Ok(violations);
}
if let Some(allowed) = grant.settings.target
if let Some(allowed) = grant.settings.specific.target
&& allowed != 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 {
id: token_grant.id,
shared_grant_id: token_grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
common_settings_id: token_grant.basic_grant_id,
settings: CombinedSettings {
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 {
id: specific.id,
shared_grant_id: specific.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
common_settings_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?,
specific: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
},
})
})

View File

@@ -11,7 +11,10 @@ use crate::db::{
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
@@ -98,8 +101,6 @@ fn shared() -> SharedGrantSettings {
}
}
// ── analyze ─────────────────────────────────────────────────────────────
#[test]
fn analyze_known_token_valid_calldata() {
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());
}
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await;
@@ -134,9 +133,11 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(None, None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(None, None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata);
@@ -163,9 +164,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(None, None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(None, None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -183,9 +186,11 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(Some(RECIPIENT), None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(Some(RECIPIENT), None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -203,9 +208,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(Some(RECIPIENT), None),
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(Some(RECIPIENT), None),
},
};
let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -247,9 +254,11 @@ async fn evaluate_passes_volume_at_exact_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -290,9 +299,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
shared: shared(),
settings,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
specific: settings,
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata);
@@ -313,9 +324,11 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
shared: shared(),
settings: make_settings(None, None), // no volume limits
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
specific: make_settings(None, None), // no volume limits
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata);
@@ -349,10 +362,13 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some());
let g = found.unwrap();
assert_eq!(g.settings.token_contract, DAI);
assert_eq!(g.settings.target, Some(RECIPIENT));
assert_eq!(g.settings.volume_limits.len(), 1);
assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64));
assert_eq!(g.settings.specific.token_contract, DAI);
assert_eq!(g.settings.specific.target, Some(RECIPIENT));
assert_eq!(g.settings.specific.volume_limits.len(), 1);
assert_eq!(
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
}
#[tokio::test]
@@ -392,7 +408,39 @@ async fn try_find_grant_unknown_token_returns_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]
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();
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!(
all[0].settings.volume_limits[0].max_volume,
all[0].settings.specific.volume_limits[0].max_volume,
U256::from(9_999u64)
);
}

View File

@@ -68,6 +68,7 @@ impl<'a> AuthTransportAdapter<'a> {
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),

View File

@@ -8,7 +8,7 @@ use arbiter_proto::proto::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
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,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
},
@@ -79,6 +79,12 @@ impl Convert for EvalViolation {
EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(())
}
EvalViolation::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
})
}
};
ProtoEvalViolation { kind: Some(kind) }
@@ -108,12 +114,12 @@ impl Convert for VetError {
violations: violations.into_iter().map(Convert::convert).collect(),
})
}
PolicyError::Database(_) => {
PolicyError::Database(_) | PolicyError::Integrity(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
}
},
};
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) })
}
}

View File

@@ -26,8 +26,8 @@ use crate::{
actors::user_agent::{
UserAgentSession,
session::connection::{
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantList, HandleSignTransaction,
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantDelete, HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError,
},
},
@@ -114,13 +114,16 @@ async fn handle_grant_list(
grants: grants
.into_iter()
.map(|grant| GrantEntry {
id: grant.id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
id: grant.common_settings_id,
wallet_access_id: grant.settings.shared.wallet_access_id,
shared: Some(grant.settings.shared.convert()),
specific: Some(grant.settings.specific.convert()),
})
.collect(),
}),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantListResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to list EVM grants");
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
@@ -147,7 +150,10 @@ async fn handle_grant_create(
.try_convert()?;
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) => {
warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
@@ -171,6 +177,9 @@ async fn handle_grant_delete(
.await
{
Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())

View File

@@ -10,6 +10,7 @@ use tracing::info;
const PORT: u16 = 50051;
#[tokio::main]
#[mutants::skip]
async fn main() -> anyhow::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();

View File

@@ -1,9 +1,14 @@
use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{
actors::client::{ClientConnection, auth, connect_client},
db,
actors::{
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_async::RunQueryDsl;
@@ -21,7 +26,8 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
async fn insert_registered_client(
db: &db::DatabasePool,
pubkey: Vec<u8>,
actors: &GlobalActors,
pubkey: ed25519_dalek::VerifyingKey,
metadata: &ClientMetadata,
) {
use arbiter_server::db::schema::{client_metadata, program_client};
@@ -37,23 +43,64 @@ async fn insert_registered_client(
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
let client_id: i32 = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey),
program_client::public_key.eq(pubkey.to_bytes().to_vec()),
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)
.await
.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]
#[test_log::test]
pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await;
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 task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -78,20 +125,19 @@ pub async fn test_unregistered_pubkey_rejected() {
#[test_log::test]
pub async fn test_challenge_auth() {
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 pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
insert_registered_client(
&db,
pubkey_bytes.clone(),
&actors,
new_key.verifying_key(),
&metadata("client", Some("desc"), Some("1.0.0")),
)
.await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -147,34 +193,13 @@ pub async fn test_challenge_auth() {
#[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
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();
}
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
let props = ClientConnection::new(db.clone(), actors);
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -225,33 +250,18 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
#[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
{
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("client"),
client_metadata::description.eq(Some("old")),
client_metadata::version.eq(Some("1.0.0")),
))
.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();
}
insert_registered_client(
&db,
&actors,
new_key.verifying_key(),
&metadata("client", Some("old"), Some("1.0.0")),
)
.await;
let props = ClientConnection::new(db.clone(), actors);
let (server_transport, mut test_transport) = ChannelTransport::new();
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();
}

View File

@@ -4,8 +4,9 @@ use arbiter_server::{
GlobalActors,
bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, auth},
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
@@ -20,6 +21,13 @@ use super::common::ChannelTransport;
pub async fn test_bootstrap_token_auth() {
let db = db::create_test_pool().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();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
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() {
let db = db::create_test_pool().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();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.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();
@@ -187,7 +214,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])),
))
.execute(&mut conn)
.await
@@ -211,7 +237,7 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
assert!(matches!(
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() {
let db = db::create_test_pool().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();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.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();

View File

@@ -11,8 +11,6 @@ use arbiter_server::{
safe_cell::{SafeCell, SafeCellHandle as _},
};
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 x25519_dalek::{EphemeralSecret, PublicKey};
@@ -152,42 +150,3 @@ pub async fn test_unseal_retry_after_invalid_key() {
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))
);
}
}