28 Commits

Author SHA1 Message Date
Skipper
4a8e51ef32 docs: updated to new auth challenge format and removed stale TOCTOU race condition note
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-17 18:25:55 +02:00
Skipper
9ee86afc19 fix(useragent): now using new challenge format 2026-04-17 18:19:51 +02:00
Skipper
790026e93b fix(server::tests): api surface of auth challenge changed 2026-04-17 17:58:22 +02:00
Skipper
0e09afda5d refactor(server::{useragent::auth, client::auth}): use random based + timestamp nonce instead of monotonic counter in database 2026-04-17 17:44:42 +02:00
Skipper
51e6571d80 refactor(server): now keeps track of useragents, instead of 2026-04-17 00:00:43 +02:00
Skipper
3b828d5874 refactor(server::grpc::vault_gate): standard approach using / traits 2026-04-16 22:15:18 +02:00
Skipper
a6f94e3115 fix(server): sending fixed vault state when on stage 2026-04-16 19:36:41 +02:00
hdbg
f49e995c2f WIP: kameo::messages wiring for transport generalization
Some checks failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-16 17:18:46 +02:00
hdbg
87ee0fe87b feat(user-agent): add VaultGate for sealed vault authentication 2026-04-12 11:53:05 +02:00
hdbg
205227a3df fix(server::integrity): vault now differentias between expected/unexpected states for commands more granularly 2026-04-08 18:21:48 +02:00
hdbg
a4070e7df7 fix(useragent): unsafe, but working implementation of ml-dsa 2026-04-08 17:43:51 +02:00
hdbg
6b8da567dd fix(server::user_agent): useragents now self-sign themselves on bootstrap 2026-04-08 17:40:45 +02:00
hdbg
1585f90cae refactor(server): reorganized client/user_agent actors into separate module peers and added event MessageBus 2026-04-08 12:34:16 +02:00
f3cf6a9438 Merge pull request 'Post-quantum crypto and better useragent security' (#80) from push-xrxykvkuxpsv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #80
2026-04-07 19:26:54 +00:00
hdbg
a9f9fc2a9d housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 16:28:47 +02:00
hdbg
d22ab49e3d refactor(server): moved shared module crypto into arbiter-crypto 2026-04-07 16:24:51 +02:00
hdbg
a845181ef6 docs: ml-dsa scheme everywhere
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 15:02:32 +02:00
hdbg
0d424f3afc refactor(server): migrated auth to ml-dsa 2026-04-07 14:55:31 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
199 changed files with 15787 additions and 3109 deletions

View File

@@ -66,7 +66,7 @@ cargo insta review
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. - **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.

View File

@@ -66,7 +66,7 @@ cargo insta review
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. - **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.

View File

@@ -29,56 +29,37 @@ flowchart TD
A([Client connects]) --> B[Receive AuthChallengeRequest] A([Client connects]) --> B[Receive AuthChallengeRequest]
B --> C{pubkey in DB?} B --> C{pubkey in DB?}
C -- yes --> D[Read nonce\nIncrement nonce in DB] C -- yes --> G[Generate AuthChallenge]
D --> G
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest] C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
E --> F{First response} E --> F{First response}
F -- denied --> Z([Reject connection]) F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nUserAgent requests] F -- approved --> F2[Cancel remaining\nUserAgent requests]
F2 --> F3[INSERT client\nnonce = 1] F2 --> F3[INSERT client]
F3 --> G[Send AuthChallenge\nwith nonce] F3 --> G
G --> H[Receive AuthChallengeSolution] G --> H[Send AuthChallenge\ntimestamp + random bytes]
H --> I{Signature valid?} H --> I[Receive AuthChallengeSolution]
I -- no --> Z I --> K{Signature valid?}
I -- yes --> J([Session started]) K -- no --> Z
K -- yes --> J([Session started])
``` ```
### Known Issue: Concurrent Registration Race (TOCTOU) Auth challenges are generated from fresh random bytes plus a timestamp. They are signed as the canonical challenge payload and are not persisted in `program_client`.
Two connections presenting the same previously-unknown public key can race through the approval flow simultaneously:
1. Both check the DB → neither is registered.
2. Both request approval from user agents → both receive approval.
3. Both `INSERT` the client record → the second insert silently overwrites the first, resetting the nonce.
This means the first connection's nonce is invalidated by the second, causing its challenge verification to fail. A fix requires either serialising new-client registration (e.g. an in-memory lock keyed on pubkey) or replacing the separate check + insert with an `INSERT OR IGNORE` / upsert guarded by a unique constraint on `public_key`.
### Nonce Semantics
The `program_client.nonce` column stores the **next usable nonce** — i.e. it is always one ahead of the nonce last issued in a challenge.
- **New client:** inserted with `nonce = 1`; the first challenge is issued with `nonce = 0`.
- **Existing client:** the current DB value is read and used as the challenge nonce, then immediately incremented within the same exclusive transaction, preventing replay.
--- ---
## Cryptography ## Cryptography
### Authentication ### Authentication
- **Client protocol:** ed25519 - **Client protocol:** ML-DSA
### User-Agent Authentication ### User-Agent Authentication
User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware. User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
- **Supported schemes:** RSA, Ed25519, ECDSA (secp256k1) - **Supported schemes:** ML-DSA
- **Why:** the user agent authenticates with keys backed by platform facilities, and those facilities differ by platform - **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
- **Apple Silicon Secure Enclave / Secure Element:** ECDSA-only in practice
- **Windows Hello / TPM 2.0:** currently RSA-backed in our integration
This is why the user-agent auth protocol carries an explicit `KeyType`, while the SDK client protocol remains fixed to ed25519.
### Encryption at Rest ### Encryption at Rest
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305** - **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**

View File

@@ -48,6 +48,10 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3" version = "1.46.3"
backend = "cargo:cargo-insta" backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]] [[tools."cargo:cargo-nextest"]]
version = "0.9.126" version = "0.9.126"
backend = "cargo:cargo-nextest" backend = "cargo:cargo-nextest"
@@ -68,6 +72,10 @@ backend = "cargo:diesel_cli"
default-features = "false" default-features = "false"
features = "sqlite,sqlite-bundled" features = "sqlite,sqlite-bundled"
[[tools."cargo:flutter_rust_bridge_codegen"]]
version = "2.12.0"
backend = "cargo:flutter_rust_bridge_codegen"
[[tools.flutter]] [[tools.flutter]]
version = "3.38.9-stable" version = "3.38.9-stable"
backend = "asdf:flutter" backend = "asdf:flutter"
@@ -111,30 +119,37 @@ backend = "core:python"
[tools.python."platforms.linux-arm64"] [tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"] [tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"] [tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"] [tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"] [tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9" checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"] [tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0" checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"] [tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09" checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]] [[tools.rust]]
version = "1.93.0" version = "1.93.0"

View File

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

View File

@@ -10,8 +10,8 @@ message AuthChallengeRequest {
} }
message AuthChallenge { message AuthChallenge {
bytes pubkey = 1; uint64 timestamp_nanos = 1;
int32 nonce = 2; bytes random = 2;
} }
message AuthChallengeSolution { message AuthChallengeSolution {

View File

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

View File

@@ -2,21 +2,14 @@ syntax = "proto3";
package arbiter.user_agent.auth; package arbiter.user_agent.auth;
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;
optional string bootstrap_token = 2; optional string bootstrap_token = 2;
KeyType key_type = 3;
} }
message AuthChallenge { message AuthChallenge {
int32 nonce = 1; uint64 timestamp_nanos = 1;
bytes random = 2;
} }
message AuthChallengeSolution { message AuthChallengeSolution {

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/

1124
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,13 +20,12 @@ tokio = { version = "1.50.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0" rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] } rustls = { version = "0.23.37", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
smlang = "0.8.0" smlang = "0.8.0"
thiserror = "2.0.18" thiserror = "2.0.18"
async-trait = "0.1.89" async-trait = "0.1.89"
futures = "0.3.32" futures = "0.3.32"
tokio-stream = { version = "0.1.18", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] }
kameo = "0.19.2"
prost-types = { version = "0.14.3", features = ["chrono"] } prost-types = { version = "0.14.3", features = ["chrono"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] } x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1" rstest = "0.26.1"
@@ -44,3 +43,9 @@ sha2 = "0.10"
spki = "0.7" spki = "0.7"
prost = "0.14.3" prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] }
base64 = "0.22.1"
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}

View File

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

View File

@@ -1,5 +1,6 @@
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, format_challenge, ClientMetadata,
proto::{ proto::{
client::{ client::{
ClientRequest, ClientRequest,
@@ -14,7 +15,7 @@ use arbiter_proto::{
shared::ClientInfo as ProtoClientInfo, shared::ClientInfo as ProtoClientInfo,
}, },
}; };
use ed25519_dalek::Signer as _; use chrono::DateTime;
use crate::{ use crate::{
storage::StorageError, storage::StorageError,
@@ -23,6 +24,8 @@ use crate::{
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum AuthError { pub enum AuthError {
#[error("Server sent invalid auth challenge")]
InvalidChallenge,
#[error("Auth challenge was not returned by server")] #[error("Auth challenge was not returned by server")]
MissingAuthChallenge, MissingAuthChallenge,
@@ -54,14 +57,14 @@ fn map_auth_result(code: i32) -> AuthError {
async fn send_auth_challenge_request( async fn send_auth_challenge_request(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &ed25519_dalek::SigningKey, key: &SigningKey,
) -> std::result::Result<(), AuthError> { ) -> std::result::Result<(), AuthError> {
transport transport
.send(ClientRequest { .send(ClientRequest {
request_id: next_request_id(), request_id: next_request_id(),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request { payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest { payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
pubkey: key.verifying_key().to_bytes().to_vec(), pubkey: key.public_key().to_bytes(),
client_info: Some(ProtoClientInfo { client_info: Some(ProtoClientInfo {
name: metadata.name, name: metadata.name,
description: metadata.description, description: metadata.description,
@@ -95,11 +98,22 @@ async fn receive_auth_challenge(
async fn send_auth_challenge_solution( async fn send_auth_challenge_solution(
transport: &mut ClientTransport, transport: &mut ClientTransport,
key: &ed25519_dalek::SigningKey, key: &SigningKey,
challenge: AuthChallenge, challenge: AuthChallenge,
) -> std::result::Result<(), AuthError> { ) -> std::result::Result<(), AuthError> {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let signature = key.sign(&challenge_payload).to_bytes().to_vec(); let challenge = authn::AuthChallenge {
nonce: *challenge
.random
.as_array()
.ok_or(AuthError::InvalidChallenge)?,
timestamp,
};
let challenge_payload: Vec<u8> = challenge.format();
let signature = key
.sign_message(&challenge_payload, CLIENT_CONTEXT)
.map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes();
transport transport
.send(ClientRequest { .send(ClientRequest {
@@ -140,7 +154,7 @@ async fn receive_auth_confirmation(
pub(crate) async fn authenticate( pub(crate) async fn authenticate(
transport: &mut ClientTransport, transport: &mut ClientTransport,
metadata: ClientMetadata, metadata: ClientMetadata,
key: &ed25519_dalek::SigningKey, key: &SigningKey,
) -> std::result::Result<(), AuthError> { ) -> std::result::Result<(), AuthError> {
send_auth_challenge_request(transport, metadata, key).await?; send_auth_challenge_request(transport, metadata, key).await?;
let challenge = receive_auth_challenge(transport).await?; let challenge = receive_auth_challenge(transport).await?;

View File

@@ -1,3 +1,4 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl, ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
}; };
@@ -60,7 +61,7 @@ impl ArbiterClient {
pub async fn connect_with_key( pub async fn connect_with_key(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
key: ed25519_dalek::SigningKey, key: SigningKey,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor); let tls = ClientTlsConfig::new().trust_anchor(anchor);

View File

@@ -1,3 +1,4 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::home_path; use arbiter_proto::home_path;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -11,7 +12,7 @@ pub enum StorageError {
} }
pub trait SigningKeyStorage { pub trait SigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>; fn load_or_create(&self) -> std::result::Result<SigningKey, StorageError>;
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -20,7 +21,7 @@ pub struct FileSigningKeyStorage {
} }
impl FileSigningKeyStorage { impl FileSigningKeyStorage {
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key"; pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key";
pub fn new(path: impl Into<PathBuf>) -> Self { pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() } Self { path: path.into() }
@@ -30,7 +31,7 @@ impl FileSigningKeyStorage {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
} }
fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> { fn read_key(path: &Path) -> std::result::Result<SigningKey, StorageError> {
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
let raw: [u8; 32] = let raw: [u8; 32] =
bytes bytes
@@ -39,12 +40,12 @@ impl FileSigningKeyStorage {
expected: 32, expected: 32,
actual: v.len(), actual: v.len(),
})?; })?;
Ok(ed25519_dalek::SigningKey::from_bytes(&raw)) Ok(SigningKey::from_seed(raw))
} }
} }
impl SigningKeyStorage for FileSigningKeyStorage { impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> { fn load_or_create(&self) -> std::result::Result<SigningKey, StorageError> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -53,8 +54,8 @@ impl SigningKeyStorage for FileSigningKeyStorage {
return Self::read_key(&self.path); return Self::read_key(&self.path);
} }
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let key = SigningKey::generate();
let raw_key = key.to_bytes(); let raw_key = key.to_seed();
// Use create_new to prevent accidental overwrite if another process creates the key first. // Use create_new to prevent accidental overwrite if another process creates the key first.
match std::fs::OpenOptions::new() match std::fs::OpenOptions::new()
@@ -103,7 +104,7 @@ mod tests {
.load_or_create() .load_or_create()
.expect("second load_or_create should read same key"); .expect("second load_or_create should read same key");
assert_eq!(key_a.to_bytes(), key_b.to_bytes()); assert_eq!(key_a.to_seed(), key_b.to_seed());
assert!(path.exists()); assert!(path.exists());
std::fs::remove_file(path).expect("temp key file should be removable"); std::fs::remove_file(path).expect("temp key file should be removable");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ url = "2.5.8"
miette.workspace = true miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
base64 = "0.22.1" base64.workspace = true
prost-types.workspace = true prost-types.workspace = true
tracing.workspace = true tracing.workspace = true
async-trait.workspace = true async-trait.workspace = true

View File

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

View File

@@ -58,6 +58,7 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use async_trait::async_trait; use async_trait::async_trait;
use kameo::{error::Infallible, prelude::*};
/// Errors returned by transport adapters implementing [`Bi`]. /// Errors returned by transport adapters implementing [`Bi`].
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@@ -106,6 +107,36 @@ pub trait Receiver<Inbound>: Send + Sync {
/// any built-in correlation mechanism between inbound and outbound items. /// any built-in correlation mechanism between inbound and outbound items.
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {} pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
#[async_trait]
impl<T, Outbound> Sender<Outbound> for &mut T
where
T: Sender<Outbound> + ?Sized,
Outbound: Send + 'static,
{
async fn send(&mut self, item: Outbound) -> Result<(), Error> {
(**self).send(item).await
}
}
#[async_trait]
impl<T, Inbound> Receiver<Inbound> for &mut T
where
T: Receiver<Inbound> + ?Sized,
Inbound: Send + 'static,
{
async fn recv(&mut self) -> Option<Inbound> {
(**self).recv().await
}
}
impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
where
T: Bi<Inbound, Outbound> + ?Sized,
Inbound: Send + 'static,
Outbound: Send + 'static,
{
}
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> { pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
type Sender: Sender<Outbound>; type Sender: Sender<Outbound>;
type Receiver: Receiver<Inbound>; type Receiver: Receiver<Inbound>;
@@ -161,3 +192,29 @@ where
} }
pub mod grpc; pub mod grpc;
#[derive(thiserror::Error, Debug)]
pub enum ForwardError<I> {
#[error("Transport error: {0}")]
Transport(#[from] Error),
#[error("Actor delivery error: {0}")]
Actor(SendError<I>),
}
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
transport: &mut Transport,
actor: &ActorRef<Handler>,
) -> Result<(), ForwardError<Inbound>>
where
Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
Handler: Actor + Message<Inbound, Reply = Outbound>,
Inbound: Send + 'static,
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
{
while let Some(request) = transport.recv().await {
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
transport.send(resp).await?
}
Err(Error::ChannelClosed.into())
}

View File

@@ -16,9 +16,8 @@ diesel-async = { version = "0.8.0", features = [
"sqlite", "sqlite",
"tokio", "tokio",
] } ] }
ed25519-dalek.workspace = true
ed25519-dalek.features = ["serde"]
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto"
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tonic.workspace = true tonic.workspace = true
@@ -37,19 +36,13 @@ dashmap = "6.1.0"
rand.workspace = true rand.workspace = true
rcgen.workspace = true rcgen.workspace = true
chrono.workspace = true chrono.workspace = true
memsafe = "0.4.0"
zeroize = { version = "1.8.2", features = ["std", "simd"] } zeroize = { version = "1.8.2", features = ["std", "simd"] }
kameo.workspace = true kameo.workspace = true
x25519-dalek.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] } argon2 = { version = "0.5.3", features = ["zeroize"] }
restructed = "0.2.2" restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"
k256.workspace = true
k256.features = ["serde"]
rsa.workspace = true
rsa.features = ["serde"]
sha2.workspace = true sha2.workspace = true
hmac = "0.12" hmac = "0.12"
spki.workspace = true spki.workspace = true
@@ -58,10 +51,17 @@ prost-types.workspace = true
prost.workspace = true prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102" anyhow = "1.0.102"
postcard = { version = "1.1.3", features = ["use-std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_with = "3.18.0" serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
ml-dsa.workspace = true
ed25519-dalek.workspace = true
x25519-dalek.workspace = true
k256.workspace = true
kameo_actors.workspace = true
[dev-dependencies] [dev-dependencies]
insta = "1.46.3" insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] } test-log = { version = "0.2", default-features = false, features = ["trace"] }

View File

@@ -45,13 +45,11 @@ insert into arbiter_settings (id) values (1) on conflict do nothing;
create table if not exists useragent_client ( create table if not exists useragent_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key, key_type); create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key);
create table if not exists client_metadata ( create table if not exists client_metadata (
id integer not null primary key, id integer not null primary key,
@@ -73,7 +71,6 @@ create unique index if not exists uniq_metadata_binding_client on client_metadat
create table if not exists program_client ( create table if not exists program_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
metadata_id integer not null references client_metadata (id) on delete cascade, metadata_id integer not null references client_metadata (id) on delete cascade,
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),

View File

@@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
@@ -44,14 +45,14 @@ pub struct Bootstrapper {
impl Bootstrapper { impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> { pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let mut conn = db.get().await?; let row_count: i64 = {
let mut conn = db.get().await?;
let row_count: i64 = schema::useragent_client::table schema::useragent_client::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?
};
drop(conn);
let token = if row_count == 0 { let token = if row_count == 0 {
let token = generate_token().await?; let token = generate_token().await?;
@@ -69,7 +70,13 @@ impl Bootstrapper {
#[message] #[message]
pub fn is_correct_token(&self, token: String) -> bool { pub fn is_correct_token(&self, token: String) -> bool {
match &self.token { match &self.token {
Some(expected) => *expected == token, Some(expected) => {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}
None => false, None => false,
} }
} }

View File

@@ -7,21 +7,22 @@ use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{ use crate::{
actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState}, actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity, crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp}, models::{self},
schema, schema,
}, },
evm::{ evm::{
self, ListError, RunKind, policies::{ self, ListError, RunKind,
policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
} },
}, },
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
pub use crate::evm::safe_signer; pub use crate::evm::safe_signer;
@@ -33,11 +34,11 @@ pub enum SignTransactionError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Keyholder error: {0}")] #[error("Vault error: {0}")]
Keyholder(#[from] crate::actors::keyholder::Error), Vault(#[from] crate::actors::vault::Error),
#[error("Keyholder mailbox error")] #[error("Vault mailbox error")]
KeyholderSend, VaultSend,
#[error("Signing error: {0}")] #[error("Signing error: {0}")]
Signing(#[from] alloy::signers::Error), Signing(#[from] alloy::signers::Error),
@@ -48,11 +49,11 @@ pub enum SignTransactionError {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Keyholder error: {0}")] #[error("Vault error: {0}")]
Keyholder(#[from] crate::actors::keyholder::Error), Vault(#[from] crate::actors::vault::Error),
#[error("Keyholder mailbox error")] #[error("Vault mailbox error")]
KeyholderSend, VaultSend,
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
@@ -63,20 +64,20 @@ pub enum Error {
#[derive(Actor)] #[derive(Actor)]
pub struct EvmActor { pub struct EvmActor {
pub keyholder: ActorRef<KeyHolder>, pub vault: ActorRef<Vault>,
pub db: DatabasePool, pub db: DatabasePool,
pub rng: StdRng, pub rng: StdRng,
pub engine: evm::Engine, pub engine: evm::Engine,
} }
impl EvmActor { impl EvmActor {
pub fn new(keyholder: ActorRef<KeyHolder>, db: DatabasePool) -> Self { pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self {
// is it safe to seed rng from system once? // is it safe to seed rng from system once?
// todo: audit // todo: audit
let rng = StdRng::from_rng(&mut rng()); let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone(), keyholder.clone()); let engine = evm::Engine::new(db.clone(), vault.clone());
Self { Self {
keyholder, vault,
db, db,
rng, rng,
engine, engine,
@@ -93,10 +94,10 @@ impl EvmActor {
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
let aead_id: i32 = self let aead_id: i32 = self
.keyholder .vault
.ask(CreateNew { plaintext }) .ask(CreateNew { plaintext })
.await .await
.map_err(|_| Error::KeyholderSend)?; .map_err(|_| Error::VaultSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table) let wallet_id = insert_into(schema::evm_wallet::table)
@@ -157,9 +158,9 @@ impl EvmActor {
} }
#[message] #[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?; // let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let keyholder = self.keyholder.clone(); // let vault = self.vault.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| { // diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move { // Box::pin(async move {
@@ -253,12 +254,12 @@ impl EvmActor {
drop(conn); drop(conn);
let raw_key: SafeCell<Vec<u8>> = self let raw_key: SafeCell<Vec<u8>> = self
.keyholder .vault
.ask(Decrypt { .ask(Decrypt {
aead_id: wallet.aead_encrypted_id, aead_id: wallet.aead_encrypted_id,
}) })
.await .await
.map_err(|_| SignTransactionError::KeyholderSend)?; .map_err(|_| SignTransactionError::VaultSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?; let signer = safe_signer::SafeSigner::from_cell(raw_key)?;

View File

@@ -6,10 +6,12 @@ use kameo::{
reply::ReplySender, reply::ReplySender,
}; };
use crate::actors::{ use crate::{
client::ClientProfile, actors::flow_coordinator::ApprovalError,
flow_coordinator::ApprovalError, peers::{
user_agent::{UserAgentSession, session::BeginNewClientApproval}, client::ClientProfile,
user_agent::{UserAgentSession, session::BeginNewClientApproval},
},
}; };
pub struct Args { pub struct Args {

View File

@@ -9,18 +9,28 @@ use kameo::{
}; };
use tracing::info; use tracing::info;
use crate::actors::{ use crate::{
client::{ClientProfile, session::ClientSession}, actors::{
flow_coordinator::client_connect_approval::ClientApprovalController, flow_coordinator::client_connect_approval::ClientApprovalController,
user_agent::session::UserAgentSession, useragent_registry::{GetConnected, UserAgentRegistry},
},
peers::client::{ClientProfile, session::ClientSession},
}; };
pub mod client_connect_approval; pub mod client_connect_approval;
#[derive(Default)]
pub struct FlowCoordinator { pub struct FlowCoordinator {
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub clients: HashMap<ActorId, ActorRef<ClientSession>>, pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
useragent_registry: ActorRef<UserAgentRegistry>,
}
impl FlowCoordinator {
pub fn new(useragent_registry: ActorRef<UserAgentRegistry>) -> Self {
Self {
clients: HashMap::default(),
useragent_registry,
}
}
} }
impl Actor for FlowCoordinator { impl Actor for FlowCoordinator {
@@ -38,13 +48,7 @@ impl Actor for FlowCoordinator {
id: ActorId, id: ActorId,
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.user_agents.remove(&id).is_some() { if self.clients.remove(&id).is_some() {
info!(
?id,
actor = "FlowCoordinator",
event = "useragent.disconnected"
);
} else if self.clients.remove(&id).is_some() {
info!( info!(
?id, ?id,
actor = "FlowCoordinator", actor = "FlowCoordinator",
@@ -69,17 +73,6 @@ pub enum ApprovalError {
#[messages] #[messages]
impl FlowCoordinator { impl FlowCoordinator {
#[message(ctx)]
pub async fn register_user_agent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.user_agents.insert(actor.id(), actor);
}
#[message(ctx)] #[message(ctx)]
pub async fn register_client( pub async fn register_client(
&mut self, &mut self,
@@ -101,7 +94,14 @@ impl FlowCoordinator {
unreachable!("Expected `request_client_approval` to have callback channel"); unreachable!("Expected `request_client_approval` to have callback channel");
}; };
let refs: Vec<_> = self.user_agents.values().cloned().collect(); let refs = match self.useragent_registry.ask(GetConnected).await {
Ok(refs) => refs,
Err(_) => {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply;
}
};
if refs.is_empty() { if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected)); reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply; return reply;

View File

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

View File

@@ -1,344 +0,0 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
keyholder::KeyHolder,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
},
crypto::integrity::{self, AttestationStatus},
db::{DatabasePool, schema::useragent_client},
};
pub struct ChallengeRequest {
pub pubkey: AuthPublicKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: AuthPublicKey,
pub token: String,
}
pub struct ChallengeContext {
pub challenge_nonce: i32,
pub key: AuthPublicKey,
}
pub struct ChallengeSolution {
pub solution: Vec<u8>,
}
smlang::statemachine!(
name: Auth,
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey),
}
);
/// 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!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
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")
})?
.ok_or_else(|| {
error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn verify_integrity(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let result = integrity::verify_entity(
&mut db_conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::internal("Integrity verification failed")
})?;
Ok(())
}
async fn create_nonce(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let new_nonce = db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_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!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity signature update failed");
Error::internal("Database error")
})?;
Result::<_, Error>::Ok(new_nonce)
})
})
.await?;
Ok(new_nonce)
}
async fn register_key(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> 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")
})?;
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!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
let entity = UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
};
integrity::sign_entity(conn, &keyholder, &entity, id)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new user-agent key");
Error::internal("Failed to register public key")
})?;
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
pub struct AuthContext<'a, T> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: T,
}
impl<'a, T> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
type Error = Error;
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge_nonce: nonce,
key: pubkey,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
&mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthPublicKey, Self::Error> {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
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)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
challenge_nonce,
key,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthPublicKey, Self::Error> {
let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes());
let valid = match key {
AuthPublicKey::Ed25519(vk) => {
let sig = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid Ed25519 signature length");
Error::InvalidChallengeSolution
})?;
vk.verify_strict(&formatted, &sig).is_ok()
}
AuthPublicKey::EcdsaSecp256k1(vk) => {
use k256::ecdsa::signature::Verifier as _;
let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid ECDSA signature bytes");
Error::InvalidChallengeSolution
})?;
vk.verify(&formatted, &sig).is_ok()
}
AuthPublicKey::Rsa(pk) => {
use rsa::signature::Verifier as _;
let verifying_key = rsa::pss::VerifyingKey::<sha2::Sha256>::new(pk.clone());
let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid RSA signature bytes");
Error::InvalidChallengeSolution
})?;
verifying_key.verify(&formatted, &sig).is_ok()
}
};
match valid {
true => {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
false => {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
}
}
}
}

View File

@@ -1,142 +0,0 @@
use crate::{
actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType}
};
fn serialize_ecdsa<S>(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Serialize as hex string for easier debugging (33 bytes compressed SEC1 format)
let key = key.to_encoded_point(true);
let bytes = key.as_bytes();
serializer.serialize_bytes(bytes)
}
fn deserialize_ecdsa<'de, D>(deserializer: D) -> Result<k256::ecdsa::VerifyingKey, D::Error>
where
D: serde::Deserializer<'de>,
{
struct EcdsaVisitor;
impl<'de> serde::de::Visitor<'de> for EcdsaVisitor {
type Value = k256::ecdsa::VerifyingKey;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a compressed SEC1-encoded ECDSA public key")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let point = k256::EncodedPoint::from_bytes(v)
.map_err(|_| E::custom("invalid compressed SEC1 format"))?;
k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_| E::custom("invalid ECDSA public key"))
}
}
deserializer.deserialize_bytes(EcdsaVisitor)
}
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug, Serialize)]
pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
#[serde(serialize_with = "serialize_ecdsa", deserialize_with = "deserialize_ecdsa")]
EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey),
}
#[derive(Debug, Serialize)]
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.
pub fn to_stored_bytes(&self) -> Vec<u8> {
match self {
AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(),
// SEC1 compressed (33 bytes) is the natural compact format for secp256k1
AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(),
AuthPublicKey::Rsa(k) => {
use rsa::pkcs8::EncodePublicKey as _;
#[allow(clippy::expect_used)]
k.to_public_key_der()
.expect("rsa SPKI encoding is infallible")
.to_vec()
}
}
}
pub fn key_type(&self) -> KeyType {
match self {
AuthPublicKey::Ed25519(_) => KeyType::Ed25519,
AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1,
AuthPublicKey::Rsa(_) => KeyType::Rsa,
}
}
}
impl TryFrom<(KeyType, Vec<u8>)> for AuthPublicKey {
type Error = &'static str;
fn try_from(value: (KeyType, Vec<u8>)) -> Result<Self, Self::Error> {
let (key_type, bytes) = value;
match key_type {
KeyType::Ed25519 => {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| "invalid Ed25519 key length")?;
let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
.map_err(|_e| "invalid Ed25519 key")?;
Ok(AuthPublicKey::Ed25519(key))
}
KeyType::EcdsaSecp256k1 => {
let point =
k256::EncodedPoint::from_bytes(&bytes).map_err(|_e| "invalid ECDSA key")?;
let key = k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_e| "invalid ECDSA key")?;
Ok(AuthPublicKey::EcdsaSecp256k1(key))
}
KeyType::Rsa => {
use rsa::pkcs8::DecodePublicKey as _;
let key = rsa::RsaPublicKey::from_public_key_der(&bytes)
.map_err(|_e| "invalid RSA key")?;
Ok(AuthPublicKey::Rsa(key))
}
}
}
}
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile },
ClientConnectionCancel { pubkey: ed25519_dalek::VerifyingKey },
}
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
pub mod auth;
pub mod session;
pub use auth::authenticate;
use serde::Serialize;
pub use session::UserAgentSession;

View File

@@ -1,522 +0,0 @@
use std::sync::Mutex;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::messages;
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::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
},
safe_cell::SafeCellHandle as _,
};
impl UserAgentSession {
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");
return Err(Error::internal("Invalid state for unseal encrypted key"));
};
let ephemeral_secret = {
#[allow(
clippy::unwrap_used,
reason = "Mutex poison is unrecoverable and should panic"
)]
let mut secret_lock = unseal_context.secret.lock().unwrap();
let secret = secret_lock.take();
match secret {
Some(secret) => secret,
None => {
drop(secret_lock);
error!("Ephemeral secret already taken");
return Err(Error::internal("Ephemeral secret already taken"));
}
}
};
Ok((ephemeral_secret, unseal_context.client_public_key))
}
fn decrypt_client_key_material(
ephemeral_secret: EphemeralSecret,
client_public_key: PublicKey,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key);
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
pub struct UnsealStartResponse {
pub server_pubkey: PublicKey,
}
#[derive(Debug, Error)]
pub enum UnsealError {
#[error("Invalid key provided for unsealing")]
InvalidKey,
#[error("Internal error during unsealing process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("Invalid key provided for bootstrapping")]
InvalidKey,
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Internal error during bootstrapping process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn handle_unseal_request(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<UnsealStartResponse, Error> {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key: client_pubkey,
}))?;
Ok(UnsealStartResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), UnsealError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
Err(_err) => {
return Err(Error::internal("Failed to take unseal secret").into());
}
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub(crate) async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), BootstrapError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
Err(err) => return Err(err.into()),
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to bootstrap vault");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::General(Error::internal(
"Vault actor error",
)))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "keyholder.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
match self
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.await
{
Ok(()) => Ok(()),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: ed25519_dalek::VerifyingKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -1,27 +0,0 @@
use std::sync::Mutex;
use x25519_dalek::{EphemeralSecret, PublicKey};
pub struct UnsealContext {
pub client_public_key: PublicKey,
pub secret: Mutex<Option<EphemeralSecret>>,
}
smlang::statemachine!(
name: UserAgent,
custom_error: false,
transitions: {
*Idle + UnsealRequest(UnsealContext) / generate_temp_keypair = WaitingForUnsealKey(UnsealContext),
WaitingForUnsealKey(UnsealContext) + ReceivedValidKey = Unsealed,
WaitingForUnsealKey(UnsealContext) + ReceivedInvalidKey = Idle,
}
);
pub struct DummyContext;
impl UserAgentStateMachineContext for DummyContext {
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> {
Ok(event_data)
}
}

View File

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

View File

@@ -5,47 +5,43 @@ use diesel::{
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _; use hmac::Mac as _;
use kameo::{Actor, Reply, messages}; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::crypto::{
crypto::{ KeyCell, derive_key,
KeyCell, derive_key, encryption::v1::{self, Nonce},
encryption::v1::{self, Nonce}, integrity::v1::HmacSha256,
integrity::v1::HmacSha256,
},
safe_cell::SafeCell,
}; };
use crate::{ use crate::db::{
db::{ self,
self, models::{self, RootKeyHistory},
models::{self, RootKeyHistory}, schema::{self},
schema::{self},
},
safe_cell::SafeCellHandle as _,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[derive(Default, EnumDiscriminants)] pub mod events {
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
enum State { #[derive(Clone, Copy)]
#[default] pub struct Bootstrapped;
Unbootstrapped,
Sealed { #[derive(Clone, Copy)]
root_key_history_id: i32, pub struct Unsealed;
},
Unsealed { #[derive(Clone, Copy)]
root_key_history_id: i32, pub struct VaultResealed;
root_key: KeyCell,
},
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Keyholder is already bootstrapped")] #[error("Vault is already bootstrapped")]
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")] #[error("Vault is not bootstrapped")]
NotBootstrapped, NotBootstrapped,
#[error("Vault is sealed")]
Sealed,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
@@ -65,18 +61,35 @@ pub enum Error {
BrokenDatabase, BrokenDatabase,
} }
struct Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
}
#[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(VaultState))]
enum State {
#[default]
Unbootstrapped,
Sealed {
root_key_history_id: i32,
},
Unsealed(Unsealed),
}
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor. /// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)] #[derive(Actor)]
pub struct KeyHolder { pub struct Vault {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>,
} }
#[messages] #[messages]
impl KeyHolder { impl Vault {
pub async fn new(db: db::DatabasePool) -> Result<Self, Error> { pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = { let state = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
@@ -94,10 +107,10 @@ impl KeyHolder {
} }
}; };
Ok(Self { db, state }) Ok(Self { db, state, events })
} }
// Exclusive transaction to avoid race condtions if multiple keyholders write // Exclusive transaction to avoid race condtions if multiple vaults write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> { async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
let mut conn = pool.get().await?; let mut conn = pool.get().await?;
@@ -134,6 +147,14 @@ impl KeyHolder {
Ok(nonce) Ok(nonce)
} }
fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
}
}
#[message] #[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
@@ -186,12 +207,13 @@ impl KeyHolder {
}) })
.await?; .await?;
self.state = State::Unsealed { self.state = State::Unsealed(Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
}; });
info!("Keyholder bootstrapped successfully"); info!("Vault bootstrapped successfully");
self.events.tell(Publish(events::Bootstrapped)).await;
Ok(()) Ok(())
} }
@@ -238,24 +260,23 @@ impl KeyHolder {
Error::InvalidKey Error::InvalidKey
})?; })?;
self.state = State::Unsealed { self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| { root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size"); error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase Error::BrokenDatabase
})?, })?,
}; });
info!("Keyholder unsealed successfully"); info!("Vault unsealed successfully");
self.events.tell(Publish(events::Unsealed)).await;
Ok(()) Ok(())
} }
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else { let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
return Err(Error::NotBootstrapped);
};
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
@@ -283,14 +304,10 @@ impl KeyHolder {
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let State::Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
.. } = Self::expect_unsealed(&mut self.state)?;
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire // Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
// Borrow checker note: &mut borrow a few lines above is disjoint from this field // Borrow checker note: &mut borrow a few lines above is disjoint from this field
@@ -320,19 +337,16 @@ impl KeyHolder {
} }
#[message] #[message]
pub fn get_state(&self) -> KeyHolderState { pub fn get_state(&self) -> VaultState {
self.state.discriminant() self.state.discriminant()
} }
#[message] #[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> { pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
let State::Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
} = &mut self.state } = Self::expect_unsealed(&mut self.state)?;
else {
return Err(Error::NotBootstrapped);
};
let mut hmac = root_key let mut hmac = root_key
.0 .0
@@ -354,13 +368,10 @@ impl KeyHolder {
expected_mac: Vec<u8>, expected_mac: Vec<u8>,
key_version: i32, key_version: i32,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let State::Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
} = &mut self.state } = Self::expect_unsealed(&mut self.state)?;
else {
return Err(Error::NotBootstrapped);
};
if *root_key_history_id != key_version { if *root_key_history_id != key_version {
return Ok(false); return Ok(false);
@@ -379,17 +390,16 @@ impl KeyHolder {
} }
#[message] #[message]
pub fn seal(&mut self) -> Result<(), Error> { pub async fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed { let Unsealed {
root_key_history_id, root_key_history_id,
.. ..
} = &self.state } = Self::expect_unsealed(&mut self.state)?;
else {
return Err(Error::NotBootstrapped);
};
self.state = State::Sealed { self.state = State::Sealed {
root_key_history_id: *root_key_history_id, root_key_history_id: *root_key_history_id,
}; };
self.events.tell(Publish(events::VaultResealed)).await;
Ok(()) Ok(())
} }
} }
@@ -401,14 +411,17 @@ mod tests {
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use crate::{ use crate::{
actors::GlobalActors,
db::{self}, db::{self},
safe_cell::SafeCell,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::*; use super::*;
async fn bootstrapped_actor(db: &db::DatabasePool) -> KeyHolder { async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
let mut actor = KeyHolder::new(db.clone()).await.unwrap(); let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
actor actor
@@ -420,17 +433,17 @@ mod tests {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state { let root_key_history_id = match actor.state {
State::Unsealed { State::Unsealed(Unsealed {
root_key_history_id, root_key_history_id,
.. ..
} => root_key_history_id, }) => root_key_history_id,
_ => panic!("expected unsealed state"), _ => panic!("expected unsealed state"),
}; };
let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id) let n1 = Vault::get_new_nonce(&db, root_key_history_id)
.await .await
.unwrap(); .unwrap();
let n2 = KeyHolder::get_new_nonce(&db, root_key_history_id) let n2 = Vault::get_new_nonce(&db, root_key_history_id)
.await .await
.unwrap(); .unwrap();
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");

View File

@@ -59,10 +59,8 @@ mod tests {
use std::ops::Deref as _; use std::ops::Deref as _;
use super::*; use super::*;
use crate::{ use crate::crypto::derive_key;
crypto::derive_key, use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
safe_cell::{SafeCell, SafeCellHandle as _},
};
#[test] #[test]
pub fn derive_seal_key_deterministic() { pub fn derive_seal_key_deterministic() {

View File

@@ -1,7 +1,8 @@
use crate::{actors::keyholder, crypto::KeyCell,safe_cell::SafeCellHandle as _}; use crate::{
use chacha20poly1305::Key; actors::vault::{self, GetState},
use hmac::{Hmac, Mac as _}; crypto::integrity::hashing::Hashable,
use serde::Serialize; };
use hmac::Hmac;
use sha2::Sha256; use sha2::Sha256;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite}; use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
@@ -9,8 +10,10 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError}; use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _; use sha2::Digest as _;
pub mod hashing;
use crate::{ use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity}, actors::vault::{SignIntegrity, Vault, VerifyIntegrity},
db::{ db::{
self, self,
models::{IntegrityEnvelope, NewIntegrityEnvelope}, models::{IntegrityEnvelope, NewIntegrityEnvelope},
@@ -23,11 +26,11 @@ pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] db::DatabaseError), Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")] #[error("Vault error: {0}")]
Keyholder(#[from] keyholder::Error), Vault(#[from] vault::Error),
#[error("KeyHolder mailbox error")] #[error("Vault mailbox error")]
KeyholderSend, VaultSend,
#[error("Integrity envelope is missing for entity {entity_kind}")] #[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str }, MissingEnvelope { entity_kind: &'static str },
@@ -43,9 +46,6 @@ pub enum Error {
#[error("Integrity MAC mismatch for entity {entity_kind}")] #[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str }, MacMismatch { entity_kind: &'static str },
#[error("Payload serialization error: {0}")]
PayloadSerialization(#[from] postcard::Error),
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -59,13 +59,15 @@ pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>; pub type HmacSha256 = Hmac<Sha256>;
pub trait Integrable: Serialize { pub trait Integrable: Hashable {
const KIND: &'static str; const KIND: &'static str;
const VERSION: i32 = 1; const VERSION: i32 = 1;
} }
fn payload_hash(payload: &[u8]) -> [u8; 32] { fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
Sha256::digest(payload).into() let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
} }
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) { fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
@@ -105,30 +107,30 @@ impl IntoId for &'_ [u8] {
pub async fn sign_entity<E: Integrable>( pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>, vault: &ActorRef<Vault>,
entity: &E, entity: &E,
entity_id: impl IntoId, entity_id: impl IntoId,
) -> Result<(), Error> { ) -> Result<(), Error> {
let payload = postcard::to_stdvec(entity)?; let payload_hash = payload_hash(&entity);
let payload_hash = payload_hash(&payload);
let entity_id = entity_id.into_id(); let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash); let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) = keyholder let (key_version, mac) =
.ask(SignIntegrity { mac_input }) vault
.await .ask(SignIntegrity { mac_input })
.map_err(|err| match err { .await
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner), .map_err(|err| match err {
_ => Error::KeyholderSend, kameo::error::SendError::HandlerError(inner) => Error::Vault(inner),
})?; _ => Error::VaultSend,
})?;
insert_into(integrity_envelope::table) insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope { .values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(), entity_kind: E::KIND.to_owned(),
entity_id: entity_id, entity_id,
payload_version: E::VERSION , payload_version: E::VERSION,
key_version, key_version,
mac: mac.to_vec(), mac: mac.to_vec(),
}) })
@@ -151,7 +153,7 @@ pub async fn sign_entity<E: Integrable>(
pub async fn verify_entity<E: Integrable>( pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>, vault: &ActorRef<Vault>,
entity: &E, entity: &E,
entity_id: impl IntoId, entity_id: impl IntoId,
) -> Result<AttestationStatus, Error> { ) -> Result<AttestationStatus, Error> {
@@ -162,7 +164,9 @@ pub async fn verify_entity<E: Integrable>(
.first(conn) .first(conn)
.await .await
.map_err(|err| match err { .map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind: E::KIND }, diesel::result::Error::NotFound => Error::MissingEnvelope {
entity_kind: E::KIND,
},
other => Error::Database(db::DatabaseError::from(other)), other => Error::Database(db::DatabaseError::from(other)),
})?; })?;
@@ -174,58 +178,76 @@ pub async fn verify_entity<E: Integrable>(
}); });
} }
let payload = postcard::to_stdvec(entity)?; let payload_hash = payload_hash(&entity);
let payload_hash = payload_hash(&payload); let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let mac_input = build_mac_input(
E::KIND,
&entity_id,
envelope.payload_version,
&payload_hash,
);
let result = keyholder let result = vault
.ask(VerifyIntegrity { .ask(VerifyIntegrity {
mac_input, mac_input,
expected_mac: envelope.mac, expected_mac: envelope.mac,
key_version: envelope.key_version, key_version: envelope.key_version,
}) })
.await .await;
;
match result { match result {
Ok(true) => Ok(AttestationStatus::Attested), Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch { entity_kind: E::KIND }), Ok(false) => Err(Error::MacMismatch {
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(AttestationStatus::Unavailable), entity_kind: E::KIND,
Err(_) => Err(Error::KeyholderSend), }),
Err(SendError::HandlerError(vault::Error::Sealed)) => {
Ok(AttestationStatus::Unavailable)
}
Err(_) => Err(Error::VaultSend),
} }
} }
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
Ok(matches!(state, vault::VaultState::Unsealed))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use diesel::{ExpressionMethods as _, QueryDsl}; use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn}; use kameo::{actor::ActorRef, prelude::Spawn};
use crate::{ use sha2::Digest;
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use crate::{
actors::{
GlobalActors,
vault::{Bootstrap, Vault},
},
db::{self, schema},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::hashing::Hashable;
use super::{Error, Integrable, sign_entity, verify_entity}; use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, serde::Serialize)] #[derive(Clone)]
struct DummyEntity { struct DummyEntity {
payload_version: i32, payload_version: i32,
payload: Vec<u8>, payload: Vec<u8>,
} }
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 { impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity"; const KIND: &'static str = "dummy_entity";
} }
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> { async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap()); let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor actor
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()), seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
@@ -238,7 +260,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn sign_writes_envelope_and_verify_passes() { async fn sign_writes_envelope_and_verify_passes() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await; let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7"; const ENTITY_ID: &[u8] = b"entity-id-7";
@@ -248,7 +270,9 @@ mod tests {
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
}; };
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap(); sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
let count: i64 = schema::integrity_envelope::table let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) .filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
@@ -259,13 +283,15 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once"); assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap(); verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
} }
#[tokio::test] #[tokio::test]
async fn tampered_mac_fails_verification() { async fn tampered_mac_fails_verification() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await; let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11"; const ENTITY_ID: &[u8] = b"entity-id-11";
@@ -275,7 +301,9 @@ mod tests {
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
}; };
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap(); sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
diesel::update(schema::integrity_envelope::table) diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity")) .filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
@@ -285,7 +313,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let err = verify_entity(&mut conn, &keyholder, &entity, ENTITY_ID) let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. })); assert!(matches!(err, Error::MacMismatch { .. }));
@@ -294,7 +322,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn changed_payload_fails_verification() { async fn changed_payload_fails_verification() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await; let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21"; const ENTITY_ID: &[u8] = b"entity-id-21";
@@ -304,14 +332,16 @@ mod tests {
payload: b"payload-v1".to_vec(), payload: b"payload-v1".to_vec(),
}; };
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID).await.unwrap(); sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
let tampered = DummyEntity { let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(), payload: b"payload-v1-but-tampered".to_vec(),
..entity ..entity
}; };
let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID) let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. })); assert!(matches!(err, Error::MacMismatch { .. }));

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

@@ -10,7 +10,7 @@ use rand::{
rngs::{StdRng, SysRng}, rngs::{StdRng, SysRng},
}; };
use crate::safe_cell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
pub mod encryption; pub mod encryption;
pub mod integrity; pub mod integrity;
@@ -102,11 +102,21 @@ impl KeyCell {
} }
} }
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell { pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{
argon2::Params::new(8, 1, 1, None).unwrap()
}
#[cfg(not(debug_assertions))]
{
argon2::Params::new(262_144, 3, 4, None).unwrap()
}
};
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params); let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default()); let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| { password.read_inline(|password_source| {
@@ -131,7 +141,7 @@ mod tests {
derive_key, derive_key,
encryption::v1::{Nonce, generate_salt}, encryption::v1::{Nonce, generate_salt},
}; };
use crate::safe_cell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[test] #[test]
pub fn encrypt_decrypt() { pub fn encrypt_decrypt() {

View File

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

View File

@@ -72,40 +72,6 @@ pub mod types {
Ok(SqliteTimestamp(datetime)) Ok(SqliteTimestamp(datetime))
} }
} }
/// Key algorithm stored in the `useragent_client.key_type` column.
/// Values must stay stable — they are persisted in the database.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, strum::FromRepr)]
#[diesel(sql_type = Integer)]
#[repr(i32)]
pub enum KeyType {
Ed25519 = 1,
EcdsaSecp256k1 = 2,
Rsa = 3,
}
impl ToSql<Integer, Sqlite> for KeyType {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
out.set_value(*self as i32);
Ok(IsNull::No)
}
}
impl FromSql<Integer, Sqlite> for KeyType {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err("Expected Integer for KeyType".into());
};
let discriminant = bytes.read_long();
KeyType::from_repr(discriminant as i32)
.ok_or_else(|| format!("Unknown KeyType discriminant: {discriminant}").into())
}
}
} }
pub use types::*; pub use types::*;
@@ -229,7 +195,6 @@ pub struct ProgramClientMetadataHistory {
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient { pub struct ProgramClient {
pub id: i32, pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub metadata_id: i32, pub metadata_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -240,11 +205,9 @@ pub struct ProgramClient {
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient { pub struct UseragentClient {
pub id: i32, pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]

View File

@@ -155,7 +155,6 @@ diesel::table! {
diesel::table! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
nonce -> Integer,
public_key -> Binary, public_key -> Binary,
metadata_id -> Integer, metadata_id -> Integer,
created_at -> Integer, created_at -> Integer,
@@ -189,9 +188,7 @@ diesel::table! {
diesel::table! { diesel::table! {
useragent_client (id) { useragent_client (id) {
id -> Integer, id -> Integer,
nonce -> Integer,
public_key -> Binary, public_key -> Binary,
key_type -> Integer,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,
} }

View File

@@ -11,7 +11,7 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef; use kameo::actor::ActorRef;
use crate::{ use crate::{
actors::keyholder::KeyHolder, actors::vault::Vault,
crypto::integrity, crypto::integrity,
db::{ db::{
self, DatabaseError, self, DatabaseError,
@@ -21,8 +21,8 @@ use crate::{
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings, CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
}; };
@@ -90,6 +90,14 @@ async fn check_shared_constraints(
let mut violations = Vec::new(); let mut violations = Vec::new();
let now = Utc::now(); let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window // Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) { if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
violations.push(EvalViolation::InvalidTime); violations.push(EvalViolation::InvalidTime);
@@ -130,7 +138,7 @@ async fn check_shared_constraints(
// Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine { pub struct Engine {
db: db::DatabasePool, db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>, vault: ActorRef<Vault>,
} }
impl Engine { impl Engine {
@@ -150,7 +158,7 @@ impl Engine {
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?; .ok_or(PolicyError::NoMatchingGrant)?;
integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?; integrity::verify_entity(&mut conn, &self.vault, &grant.settings, grant.id).await?;
let mut violations = check_shared_constraints( let mut violations = check_shared_constraints(
&context, &context,
@@ -199,8 +207,8 @@ impl Engine {
} }
impl Engine { impl Engine {
pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self { pub fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self { db, keyholder } Self { db, vault }
} }
pub async fn create_grant<P: Policy>( pub async fn create_grant<P: Policy>(
@@ -211,7 +219,7 @@ impl Engine {
P::Settings: Clone, P::Settings: Clone,
{ {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone(); let vault = self.vault.clone();
let id = conn let id = conn
.transaction(|conn| { .transaction(|conn| {
@@ -250,14 +258,9 @@ impl Engine {
P::create_grant(&basic_grant, &full_grant.specific, conn).await?; P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
integrity::sign_entity( integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
conn, .await
&keyholder, .map_err(|_| diesel::result::Error::RollbackTransaction)?;
&full_grant,
basic_grant.id,
)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id) QueryResult::Ok(basic_grant.id)
}) })
@@ -280,7 +283,7 @@ impl Engine {
// Verify integrity of all grants before returning any results // Verify integrity of all grants before returning any results
for grant in &all_grants { for grant in &all_grants {
integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?; integrity::verify_entity(conn, &self.vault, &grant.settings, grant.id).await?;
} }
Ok(all_grants.into_iter().map(|g| Grant { Ok(all_grants.into_iter().map(|g| Grant {
@@ -342,3 +345,255 @@ impl Engine {
Err(VetError::UnsupportedTransactionType) Err(VetError::UnsupportedTransactionType)
} }
} }
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
fn context() -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ZERO,
calldata: Bytes::new(),
max_fee_per_gas: 100,
max_priority_fee_per_gas: 10,
}
}
fn shared_settings() -> SharedGrantSettings {
SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
}
}
async fn insert_basic_grant(
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
rate_limit_window_secs: shared
.rate_limit
.as_ref()
.map(|limit| limit.window.num_seconds() as i32),
revoked_at: None,
})
.returning(EvmBasicGrant::as_select())
.get_result(conn)
.await
.unwrap()
}
#[rstest]
#[case::matching_chain(CHAIN_ID, false)]
#[case::mismatching_chain(CHAIN_ID + 1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_chain_id(
#[case] context_chain: u64,
#[case] expect_mismatch: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
chain: context_chain,
..context()
};
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
expect_mismatch
);
if expect_mismatch {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
#[tokio::test]
async fn check_shared_constraints_enforces_validity_window(
#[case] valid_from: Option<chrono::DateTime<Utc>>,
#[case] valid_until: Option<chrono::DateTime<Utc>>,
#[case] expect_invalid_time: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
valid_from,
valid_until,
..shared_settings()
};
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
expect_invalid_time
);
if expect_invalid_time {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_gas_fee_caps(
#[case] max_gas_fee_per_gas: Option<U256>,
#[case] max_priority_fee_per_gas: Option<U256>,
#[case] actual_max_fee_per_gas: u128,
#[case] actual_max_priority_fee_per_gas: u128,
#[case] expect_gas_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
max_fee_per_gas: actual_max_fee_per_gas,
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
..context()
};
let shared = SharedGrantSettings {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
..shared_settings()
};
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
expect_gas_limit_violation
);
if expect_gas_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::under_rate_limit(2, false)]
#[case::at_rate_limit(1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_rate_limit(
#[case] rate_limit_count: u32,
#[case] expect_rate_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
rate_limit: Some(TransactionRateLimit {
count: rate_limit_count,
window: Duration::hours(1),
}),
..shared_settings()
};
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
.await
.unwrap();
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
expect_rate_limit_violation
);
if expect_rate_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
}

View File

@@ -7,11 +7,12 @@ use diesel::{
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
}; };
pub mod ether_transfer; pub mod ether_transfer;
@@ -55,11 +56,14 @@ pub enum EvalViolation {
#[error("Transaction type is not allowed by this grant")] #[error("Transaction type is not allowed by this grant")]
InvalidTransactionType, InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
} }
pub type DatabaseID = i32; pub type DatabaseID = i32;
#[derive(Debug, Serialize)] #[derive(Debug)]
pub struct Grant<PolicySettings> { pub struct Grant<PolicySettings> {
pub id: DatabaseID, pub id: DatabaseID,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
@@ -123,19 +127,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning), TokenTransfer(token_transfers::Meaning),
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TransactionRateLimit { pub struct TransactionRateLimit {
pub count: u32, pub count: u32,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VolumeRateLimit { pub struct VolumeRateLimit {
pub max_volume: U256, pub max_volume: U256,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SharedGrantSettings { pub struct SharedGrantSettings {
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain: ChainId, pub chain: ChainId,
@@ -196,7 +200,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings), TokenTransfer(token_transfers::Settings),
} }
#[derive(Debug, Serialize)] #[derive(Debug)]
pub struct CombinedSettings<PolicyGrant> { pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings, pub shared: SharedGrantSettings,
pub specific: PolicyGrant, pub specific: PolicyGrant,
@@ -216,3 +220,37 @@ impl<P: Integrable> Integrable for CombinedSettings<P> {
const VERSION: i32 = P::VERSION; 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

@@ -52,7 +52,7 @@ impl From<Meaning> for SpecificMeaning {
} }
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits // A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone)]
pub struct Settings { pub struct Settings {
pub target: Vec<Address>, pub target: Vec<Address>,
pub limit: VolumeRateLimit, pub limit: VolumeRateLimit,
@@ -61,6 +61,15 @@ impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer"; const KIND: &'static str = "EtherTransfer";
} }
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.target.hash(hasher);
self.limit.hash(hasher);
}
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> SpecificGrant {
SpecificGrant::EtherTransfer(val) SpecificGrant::EtherTransfer(val)

View File

@@ -84,8 +84,6 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_matches_empty_calldata() { fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap(); let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap();
@@ -102,8 +100,6 @@ fn analyze_rejects_nonempty_calldata() {
assert!(EtherTransfer::analyze(&context).is_none()); assert!(EtherTransfer::analyze(&context).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_passes_for_allowed_target() { async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -276,8 +272,6 @@ async fn evaluate_passes_at_exactly_volume_limit() {
); );
} }
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn try_find_grant_roundtrip() { async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -336,7 +330,36 @@ async fn try_find_grant_wrong_target_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
// ── find_all_grants ────────────────────────────────────────────────────── proptest::proptest! {
#[test]
fn target_order_does_not_affect_hash(
raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8),
seed in proptest::prelude::any::<u64>(),
max_volume in proptest::prelude::any::<u64>(),
window_secs in 1i64..=86400,
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let limit = VolumeRateLimit {
max_volume: U256::from(max_volume),
window: Duration::seconds(window_secs),
};
let mut h1 = sha2::Sha256::new();
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { target: shuffled, limit }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {

View File

@@ -1,17 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use serde::Serialize;
use crate::db::schema::{ use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit, evm_token_transfer_volume_limit,
@@ -32,6 +20,16 @@ use crate::{
}, },
evm::policies::CombinedSettings, evm::policies::CombinedSettings,
}; };
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use super::{DatabaseID, EvalContext, EvalViolation}; use super::{DatabaseID, EvalContext, EvalViolation};
@@ -64,7 +62,7 @@ impl From<Meaning> for SpecificMeaning {
} }
// A grant for token transfers, which can be scoped to specific target addresses and volume limits // A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone)]
pub struct Settings { pub struct Settings {
pub token_contract: Address, pub token_contract: Address,
pub target: Option<Address>, pub target: Option<Address>,
@@ -73,6 +71,17 @@ pub struct Settings {
impl Integrable for Settings { impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer"; const KIND: &'static str = "TokenTransfer";
} }
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.token_contract.hash(hasher);
self.target.hash(hasher);
self.volume_limits.hash(hasher);
}
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val) SpecificGrant::TokenTransfer(val)

View File

@@ -101,8 +101,6 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_known_token_valid_calldata() { fn analyze_known_token_valid_calldata() {
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
@@ -128,8 +126,6 @@ fn analyze_empty_calldata_returns_none() {
assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none()); assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() { async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -412,7 +408,39 @@ async fn try_find_grant_unknown_token_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
// ── find_all_grants ────────────────────────────────────────────────────── proptest::proptest! {
#[test]
fn volume_limits_order_does_not_affect_hash(
raw_limits in proptest::collection::vec(
(proptest::prelude::any::<u64>(), 1i64..=86400),
0..8,
),
seed in proptest::prelude::any::<u64>(),
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
let limits: Vec<VolumeRateLimit> = raw_limits
.iter()
.map(|(max_vol, window_secs)| VolumeRateLimit {
max_volume: U256::from(*max_vol),
window: Duration::seconds(*window_secs),
})
.collect();
let mut shuffled = limits.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let mut h1 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {

View File

@@ -1,12 +1,12 @@
use std::sync::Mutex; use std::sync::Mutex;
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
use alloy::{ use alloy::{
consensus::SignableTransaction, consensus::SignableTransaction,
network::{TxSigner, TxSignerSync}, network::{TxSigner, TxSignerSync},
primitives::{Address, B256, ChainId, Signature}, primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address}, signers::{Error, Result, Signer, SignerSync, utils::secret_key_to_address},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use async_trait::async_trait; use async_trait::async_trait;
use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner}; use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner};

View File

@@ -10,8 +10,8 @@ use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
actors::client::{ClientConnection, session::ClientSession},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::client::{ClientConnection, session::ClientSession},
}; };
mod auth; mod auth;

View File

@@ -1,3 +1,4 @@
use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata,
proto::{ proto::{
@@ -21,8 +22,8 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::client::{self, ClientConnection, auth},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::client::{self, ClientConnection, auth},
}; };
pub struct AuthTransportAdapter<'a> { pub struct AuthTransportAdapter<'a> {
@@ -43,10 +44,14 @@ impl<'a> AuthTransportAdapter<'a> {
fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload { fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload {
match response { match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => { auth::Outbound::AuthChallenge { challenge } => {
AuthResponsePayload::Challenge(ProtoAuthChallenge { AuthResponsePayload::Challenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes().to_vec(), timestamp_nanos: challenge
nonce, .timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
random: challenge.nonce.to_vec(),
}) })
} }
auth::Outbound::AuthSuccess => { auth::Outbound::AuthSuccess => {
@@ -68,6 +73,7 @@ impl<'a> AuthTransportAdapter<'a> {
auth::Error::ApproveError(auth::ApproveError::Internal) auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable | auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed | auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal, | auth::Error::Transport => ProtoAuthResult::Internal,
} }
.into(), .into(),
@@ -159,11 +165,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
.await; .await;
return None; return None;
}; };
let Ok(pubkey) = <[u8; 32]>::try_from(pubkey) else { let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
};
let Ok(pubkey) = ed25519_dalek::VerifyingKey::from_bytes(&pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await; let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None; return None;
}; };
@@ -173,7 +175,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else { let Ok(signature) = authn::Signature::try_from(signature.as_slice()) else {
let _ = self let _ = self
.send_auth_result(ProtoAuthResult::InvalidSignature) .send_auth_result(ProtoAuthResult::InvalidSignature)
.await; .await;

View File

@@ -16,11 +16,11 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::client::session::{ClientSession, HandleSignTransaction, SignTransactionRpcError},
grpc::{ grpc::{
Convert, TryConvert, Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction}, common::inbound::{RawEvmAddress, RawEvmTransaction},
}, },
peers::client::session::{ClientSession, HandleSignTransaction, SignTransactionRpcError},
}; };
fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload { fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload {

View File

@@ -12,9 +12,9 @@ use kameo::{actor::ActorRef, error::SendError};
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::actors::{ use crate::{
client::session::{ClientSession, Error, HandleQueryVaultState}, actors::vault::VaultState,
keyholder::KeyHolderState, peers::client::session::{ClientSession, Error, HandleQueryVaultState},
}; };
pub(super) async fn dispatch( pub(super) async fn dispatch(
@@ -30,9 +30,9 @@ pub(super) async fn dispatch(
match payload { match payload {
VaultRequestPayload::QueryState(_) => { VaultRequestPayload::QueryState(_) => {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error, Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to query vault state"); warn!(error = ?err, "Failed to query vault state");

View File

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

View File

@@ -10,8 +10,7 @@ use tonic::{Request, Response, Status, async_trait};
use tracing::info; use tracing::info;
use crate::{ use crate::{
actors::{client::ClientConnection, user_agent::UserAgentConnection}, peers::{client::ClientConnection, user_agent::UserAgentConnection},
grpc::user_agent::start,
}; };
mod request_tracker; mod request_tracker;
@@ -63,7 +62,7 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Ser
let (bi, rx) = GrpcBi::from_bi_stream(req_stream); let (bi, rx) = GrpcBi::from_bi_stream(req_stream);
tokio::spawn(start( tokio::spawn(user_agent::start(
UserAgentConnection { UserAgentConnection {
db: self.context.db.clone(), db: self.context.db.clone(),
actors: self.context.actors.clone(), actors: self.context.actors.clone(),

View File

@@ -9,13 +9,13 @@ use arbiter_proto::{
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use kameo::actor::{ActorRef, Spawn as _}; use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
}; };
mod auth; mod auth;
@@ -24,6 +24,7 @@ mod inbound;
mod outbound; mod outbound;
mod sdk_client; mod sdk_client;
mod vault; mod vault;
mod vault_gate;
pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>); pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>);
@@ -124,21 +125,23 @@ pub async fn start(
) { ) {
let mut request_tracker = RequestTracker::default(); let mut request_tracker = RequestTracker::default();
let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
Ok(pubkey) => pubkey,
Err(e) => {
warn!(error = ?e, "Authentication failed");
return;
}
};
let (oob_sender, oob_receiver) = mpsc::channel(16); let (oob_sender, oob_receiver) = mpsc::channel(16);
let oob_adapter = OutOfBandAdapter(oob_sender); let oob_adapter = OutOfBandAdapter(oob_sender);
let actor = UserAgentSession::spawn(UserAgentSession::new(conn, Box::new(oob_adapter))); let actor = {
let actor_for_cleanup = actor.clone(); let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
match crate::peers::user_agent::start(&mut conn, transport, Box::new(oob_adapter)).await {
Ok(actor) => actor,
Err(e) => {
warn!(error = ?e, "User agent connection failed");
return;
}
}
};
info!(?pubkey, "User authenticated successfully"); info!("User agent session established");
dispatch_loop(bi, actor, oob_receiver, request_tracker).await;
actor_for_cleanup.kill(); dispatch_loop(bi, actor.clone(), oob_receiver, request_tracker).await;
actor.kill();
} }

View File

@@ -1,3 +1,4 @@
use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
proto::user_agent::{ proto::user_agent::{
UserAgentRequest, UserAgentResponse, UserAgentRequest, UserAgentResponse,
@@ -5,8 +6,7 @@ use arbiter_proto::{
self as proto_auth, AuthChallenge as ProtoAuthChallenge, self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
KeyType as ProtoKeyType, request::Payload as AuthRequestPayload, request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
response::Payload as AuthResponsePayload,
}, },
user_agent_request::Payload as UserAgentRequestPayload, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
@@ -18,14 +18,13 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::user_agent::{AuthPublicKey, UserAgentConnection, auth},
db::models::KeyType,
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::user_agent::{Credentials, UserAgentConnection, auth},
}; };
pub struct AuthTransportAdapter<'a> { pub struct AuthTransportAdapter<'a> {
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>, pub(super) bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &'a mut RequestTracker, pub(super) request_tracker: &'a mut RequestTracker,
} }
impl<'a> AuthTransportAdapter<'a> { impl<'a> AuthTransportAdapter<'a> {
@@ -39,19 +38,35 @@ impl<'a> AuthTransportAdapter<'a> {
} }
} }
async fn send_user_agent_response( pub(super) fn bi_mut(&mut self) -> &mut GrpcBi<UserAgentRequest, UserAgentResponse> {
self.bi
}
pub(super) fn tracker_mut(&mut self) -> &mut RequestTracker {
self.request_tracker
}
pub(super) async fn send_response_payload(
&mut self, &mut self,
payload: AuthResponsePayload, payload: UserAgentResponsePayload,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
self.bi self.bi
.send(Ok(UserAgentResponse { .send(Ok(UserAgentResponse {
id: Some(self.request_tracker.current_request_id()), id: Some(self.request_tracker.current_request_id()),
payload: Some(UserAgentResponsePayload::Auth(proto_auth::Response { payload: Some(payload),
payload: Some(payload),
})),
})) }))
.await .await
} }
async fn send_user_agent_response(
&mut self,
payload: AuthResponsePayload,
) -> Result<(), TransportError> {
self.send_response_payload(UserAgentResponsePayload::Auth(proto_auth::Response {
payload: Some(payload),
}))
.await
}
} }
#[async_trait] #[async_trait]
@@ -62,8 +77,15 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
use auth::{Error, Outbound}; use auth::{Error, Outbound};
let payload = match item { let payload = match item {
Ok(Outbound::AuthChallenge { nonce }) => { Ok(Outbound::AuthChallenge { challenge }) => {
AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce }) AuthResponsePayload::Challenge(ProtoAuthChallenge {
timestamp_nanos: challenge
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
random: challenge.nonce.to_vec(),
})
} }
Ok(Outbound::AuthSuccess) => { Ok(Outbound::AuthSuccess) => {
AuthResponsePayload::Result(ProtoAuthResult::Success.into()) AuthResponsePayload::Result(ProtoAuthResult::Success.into())
@@ -141,28 +163,8 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest { AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
pubkey, pubkey,
bootstrap_token, bootstrap_token,
key_type,
}) => { }) => {
let Ok(key_type) = ProtoKeyType::try_from(key_type) else { let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else {
warn!(
event = "received request with invalid key type",
"grpc.useragent.auth_adapter"
);
return None;
};
let key_type = match key_type {
ProtoKeyType::Ed25519 => KeyType::Ed25519,
ProtoKeyType::EcdsaSecp256k1 => KeyType::EcdsaSecp256k1,
ProtoKeyType::Rsa => KeyType::Rsa,
ProtoKeyType::Unspecified => {
warn!(
event = "received request with unspecified key type",
"grpc.useragent.auth_adapter"
);
return None;
}
};
let Ok(pubkey) = AuthPublicKey::try_from((key_type, pubkey)) else {
warn!( warn!(
event = "received request with invalid public key", event = "received request with invalid public key",
"grpc.useragent.auth_adapter" "grpc.useragent.auth_adapter"
@@ -188,7 +190,7 @@ pub async fn start(
conn: &mut UserAgentConnection, conn: &mut UserAgentConnection,
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>, bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &mut RequestTracker, request_tracker: &mut RequestTracker,
) -> Result<AuthPublicKey, auth::Error> { ) -> Result<Credentials, auth::Error> {
let transport = AuthTransportAdapter::new(bi, request_tracker); let mut transport = AuthTransportAdapter::new(bi, request_tracker);
auth::authenticate(conn, transport).await auth::authenticate(conn, &mut transport).await
} }

View File

@@ -23,18 +23,18 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
actors::user_agent::{ grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
},
peers::user_agent::{
UserAgentSession, UserAgentSession,
session::connection::{ session::handlers::{
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantDelete, HandleGrantList, HandleSignTransaction, HandleGrantDelete, HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError, SignTransactionError as SessionSignTransactionError,
}, },
}, },
grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
},
}; };
fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload { fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload {

View File

@@ -1,3 +1,4 @@
use arbiter_crypto::authn;
use arbiter_proto::proto::{ use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata, shared::ClientInfo as ProtoClientMetadata,
user_agent::{ user_agent::{
@@ -20,15 +21,15 @@ use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
actors::user_agent::{ db::models::NewEvmWalletAccess,
grpc::Convert,
peers::user_agent::{
OutOfBand, UserAgentSession, OutOfBand, UserAgentSession,
session::connection::{ session::handlers::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove, HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleRevokeEvmWalletAccess, HandleSdkClientList,
}, },
}, },
db::models::NewEvmWalletAccess,
grpc::Convert,
}; };
fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload { fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
@@ -41,7 +42,7 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
match oob { match oob {
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response( OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest { SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
pubkey: profile.pubkey.to_bytes().to_vec(), pubkey: profile.pubkey.to_bytes(),
info: Some(ProtoClientMetadata { info: Some(ProtoClientMetadata {
name: profile.metadata.name, name: profile.metadata.name,
description: profile.metadata.description, description: profile.metadata.description,
@@ -51,7 +52,7 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
), ),
OutOfBand::ClientConnectionCancel { pubkey } => wrap_sdk_client_response( OutOfBand::ClientConnectionCancel { pubkey } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionCancel(ProtoSdkClientConnectionCancel { SdkClientResponsePayload::ConnectionCancel(ProtoSdkClientConnectionCancel {
pubkey: pubkey.to_bytes().to_vec(), pubkey: pubkey.to_bytes(),
}), }),
), ),
} }
@@ -89,10 +90,8 @@ async fn handle_connection_response(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
resp: ProtoSdkClientConnectionResponse, resp: ProtoSdkClientConnectionResponse,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey) let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice())
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?; .map_err(|_| Status::invalid_argument("Invalid ML-DSA public key"))?;
let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?;
actor actor
.ask(HandleNewClientApprove { .ask(HandleNewClientApprove {
@@ -117,7 +116,7 @@ async fn handle_list(
.into_iter() .into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry { .map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id, id: client.id,
pubkey: client.public_key, pubkey: client.public_key.to_vec(),
info: Some(ProtoClientMetadata { info: Some(ProtoClientMetadata {
name: metadata.name, name: metadata.name,
description: metadata.description, description: metadata.description,

View File

@@ -1,34 +1,15 @@
use arbiter_proto::proto::shared::VaultState as ProtoVaultState; use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use arbiter_proto::proto::user_agent::{ use arbiter_proto::proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
vault::{ vault::{self as proto_vault, request::Payload as VaultRequestPayload, response::Payload as VaultResponsePayload},
self as proto_vault,
bootstrap::{
self as proto_bootstrap, BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
BootstrapResult as ProtoBootstrapResult,
},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart,
request::Payload as UnsealRequestPayload, response::Payload as UnsealResponsePayload,
},
},
}; };
use kameo::{actor::ActorRef, error::SendError}; use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
use crate::actors::{ use crate::{
keyholder::KeyHolderState, actors::vault::VaultState,
user_agent::{ peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState},
UserAgentSession,
session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleQueryVaultState,
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
}; };
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload { fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
@@ -37,18 +18,6 @@ fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayloa
}) })
} }
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
pub(super) async fn dispatch( pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
req: proto_vault::Request, req: proto_vault::Request,
@@ -59,116 +28,21 @@ pub(super) async fn dispatch(
match payload { match payload {
VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await, VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(req) => dispatch_unseal_request(actor, req).await, VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
VaultRequestPayload::Bootstrap(req) => handle_bootstrap_request(actor, req).await, Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
))
}
} }
} }
async fn dispatch_unseal_request(
actor: &ActorRef<UserAgentSession>,
req: proto_unseal::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing unseal request payload"));
};
match payload {
UnsealRequestPayload::Start(req) => handle_unseal_start(actor, req).await,
UnsealRequestPayload::EncryptedKey(req) => handle_unseal_encrypted_key(actor, req).await,
}
}
async fn handle_unseal_start(
actor: &ActorRef<UserAgentSession>,
req: UnsealStart,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let client_pubkey = <[u8; 32]>::try_from(req.client_pubkey)
.map(x25519_dalek::PublicKey::from)
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?;
let response = actor
.ask(HandleUnsealRequest { client_pubkey })
.await
.map_err(|err| {
warn!(error = ?err, "Failed to handle unseal start request");
Status::internal("Failed to start unseal flow")
})?;
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
))))
}
async fn handle_unseal_encrypted_key(
actor: &ActorRef<UserAgentSession>,
req: ProtoUnsealEncryptedKey,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleUnsealEncryptedKey {
nonce: req.nonce,
ciphertext: req.ciphertext,
associated_data: req.associated_data,
})
.await
{
Ok(()) => ProtoUnsealResult::Success,
Err(SendError::HandlerError(UnsealError::InvalidKey)) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(error = ?err, "Failed to handle unseal request");
return Err(Status::internal("Failed to unseal vault"));
}
};
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Result(
result.into(),
))))
}
async fn handle_bootstrap_request(
actor: &ActorRef<UserAgentSession>,
req: proto_bootstrap::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let encrypted_key = req
.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?;
handle_bootstrap_encrypted_key(actor, encrypted_key).await
}
async fn handle_bootstrap_encrypted_key(
actor: &ActorRef<UserAgentSession>,
req: ProtoBootstrapEncryptedKey,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleBootstrapEncryptedKey {
nonce: req.nonce,
ciphertext: req.ciphertext,
associated_data: req.associated_data,
})
.await
{
Ok(()) => ProtoBootstrapResult::Success,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
ProtoBootstrapResult::InvalidKey
}
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(error = ?err, "Failed to handle bootstrap request");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
Ok(Some(wrap_bootstrap_response(result)))
}
async fn handle_query_vault_state( async fn handle_query_vault_state(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<UserAgentResponsePayload>, Status> {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed, Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to query vault state"); warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error ProtoVaultState::Error

View File

@@ -0,0 +1,81 @@
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
use async_trait::async_trait;
use tonic::Status;
use tracing::warn;
use super::auth::AuthTransportAdapter;
use crate::{
grpc::TryConvert,
peers::user_agent::vault_gate::{self as vault_gate},
};
mod inbound;
mod outbound;
#[async_trait]
impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
loop {
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(
?error,
"Failed to receive user agent request during vault gate"
);
return None;
}
};
if let Err(err) = self.tracker_mut().request(request.id) {
let _ = self.bi_mut().send(Err(err)).await;
return None;
}
let Some(payload) = request.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing request payload")))
.await;
return None;
};
match payload.try_convert() {
Ok(inbound) => return Some(inbound),
Err(status) => {
let _ = self.bi_mut().send(Err(status)).await;
return None;
}
}
}
}
}
#[async_trait]
impl Sender<Result<vault_gate::Outbound, vault_gate::Error>> for AuthTransportAdapter<'_> {
async fn send(
&mut self,
item: Result<vault_gate::Outbound, vault_gate::Error>,
) -> Result<(), TransportError> {
let outbound = match item {
Ok(outbound) => outbound,
Err(err) => {
warn!(?err, "vault gate produced transport-level error");
return self
.bi_mut()
.send(Err(Status::internal(err.to_string())))
.await;
}
};
match outbound.try_convert() {
Ok(payload) => self.send_response_payload(payload).await,
Err(status) => self.bi_mut().send(Err(status)).await,
}
}
}
impl Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>>
for AuthTransportAdapter<'_>
{
}

View File

@@ -0,0 +1,129 @@
use arbiter_proto::proto::user_agent::{
user_agent_request::Payload as UserAgentRequestPayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap},
request::Payload as VaultRequestPayload,
unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload},
},
};
use tonic::Status;
use crate::{
grpc::{Convert, TryConvert},
peers::user_agent::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
},
};
impl TryConvert for UserAgentRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
UserAgentRequestPayload::Vault(req) => req.try_convert(),
_ => Err(Status::permission_denied(
"Only vault operations are permitted before unsealing",
)),
}
}
}
impl TryConvert for proto_vault::Request {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.payload
.ok_or_else(|| Status::invalid_argument("Missing vault request payload"))?
.try_convert()
}
}
impl TryConvert for VaultRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
VaultRequestPayload::QueryState(_) => Ok(vault_gate::Inbound::HandleVaultState),
VaultRequestPayload::Unseal(req) => req.try_convert(),
VaultRequestPayload::Bootstrap(req) => req.try_convert(),
}
}
}
impl TryConvert for proto_unseal::Request {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.payload
.ok_or_else(|| Status::invalid_argument("Missing unseal request payload"))?
.try_convert()
}
}
impl TryConvert for UnsealRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
UnsealRequestPayload::Start(start) => start.try_convert(),
UnsealRequestPayload::EncryptedKey(key) => Ok(key.convert()),
}
}
}
impl TryConvert for proto_unseal::UnsealStart {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
let bytes = <[u8; 32]>::try_from(self.client_pubkey)
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?;
Ok(vault_gate::Inbound::HandleHandshake(HandleHandshake {
client_pubkey: x25519_dalek::PublicKey::from(bytes),
}))
}
}
impl Convert for proto_unseal::UnsealEncryptedKey {
type Output = vault_gate::Inbound;
fn convert(self) -> vault_gate::Inbound {
vault_gate::Inbound::HandleUnsealEncryptedKey(HandleUnsealEncryptedKey {
nonce: self.nonce,
ciphertext: self.ciphertext,
associated_data: self.associated_data,
})
}
}
impl TryConvert for proto_bootstrap::Request {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?
.try_convert()
}
}
impl TryConvert for proto_bootstrap::BootstrapEncryptedKey {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
Ok(vault_gate::Inbound::HandleBootstrapEncryptedKey(
HandleBootstrapEncryptedKey {
nonce: self.nonce,
ciphertext: self.ciphertext,
associated_data: self.associated_data,
},
))
}
}

View File

@@ -0,0 +1,115 @@
use arbiter_proto::proto::{
shared::VaultState as ProtoVaultState,
user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealResult as ProtoUnsealResult,
response::Payload as UnsealResponsePayload,
},
},
},
};
use tonic::Status;
use tracing::warn;
use crate::{
actors::vault::VaultState,
grpc::{Convert, TryConvert},
peers::user_agent::vault_gate::{self as vault_gate},
};
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
impl Convert for VaultState {
type Output = UserAgentResponsePayload;
fn convert(self) -> UserAgentResponsePayload {
let proto_state = match self {
VaultState::Unbootstrapped => ProtoVaultState::Unbootstrapped,
VaultState::Sealed => ProtoVaultState::Sealed,
VaultState::Unsealed => ProtoVaultState::Unsealed,
};
wrap_vault_response(VaultResponsePayload::State(proto_state.into()))
}
}
impl Convert for vault_gate::HandshakeResponse {
type Output = UserAgentResponsePayload;
fn convert(self) -> UserAgentResponsePayload {
wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: self.server_pubkey.as_bytes().to_vec(),
},
))
}
}
impl TryConvert for vault_gate::Outbound {
type Output = UserAgentResponsePayload;
type Error = Status;
fn try_convert(self) -> Result<UserAgentResponsePayload, Status> {
match self {
vault_gate::Outbound::HandleVaultState(result) => result
.map_err(|err| {
warn!(?err, "vault state query failed");
Status::internal("Failed to query vault state")
})
.map(VaultState::convert),
vault_gate::Outbound::HandleHandshake(result) => result
.map_err(|err| {
warn!(?err, "handshake failed");
Status::internal("Failed to start unseal flow")
})
.map(vault_gate::HandshakeResponse::convert),
vault_gate::Outbound::HandleUnsealEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(?err, "unseal failed");
return Err(Status::internal("Failed to unseal vault"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
vault_gate::Outbound::HandleBootstrapEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoBootstrapResult::InvalidKey,
Err(vault_gate::Error::AlreadyBootstrapped) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(?err, "bootstrap failed");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
}
}
}

View File

@@ -7,7 +7,7 @@ pub mod crypto;
pub mod db; pub mod db;
pub mod evm; pub mod evm;
pub mod grpc; pub mod grpc;
pub mod safe_cell; pub mod peers;
pub mod utils; pub mod utils;
pub struct Server { pub struct Server {

View File

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

View File

@@ -1,5 +1,6 @@
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, format_challenge, ClientMetadata,
transport::{Bi, expect_message}, transport::{Bi, expect_message},
}; };
use chrono::Utc; use chrono::Utc;
@@ -8,15 +9,16 @@ use diesel::{
dsl::insert_into, update, dsl::insert_into, update,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey}; use kameo::{actor::ActorRef, error::SendError};
use kameo::error::SendError;
use tracing::error; use tracing::error;
use crate::{ use crate::{
actors::{ actors::{
client::{ClientConnection, ClientProfile}, GlobalActors,
flow_coordinator::{self, RequestClientApproval}, flow_coordinator::{self, RequestClientApproval},
vault::Vault,
}, },
crypto::integrity::{self, AttestationStatus},
db::{ db::{
self, self,
models::{ProgramClientMetadata, SqliteTimestamp}, models::{ProgramClientMetadata, SqliteTimestamp},
@@ -24,12 +26,16 @@ use crate::{
}, },
}; };
use super::{ClientConnection, ClientCredentials, ClientProfile};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
#[error("Database pool unavailable")] #[error("Database pool unavailable")]
DatabasePoolUnavailable, DatabasePoolUnavailable,
#[error("Database operation failed")] #[error("Database operation failed")]
DatabaseOperationFailed, DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")] #[error("Invalid challenge solution")]
InvalidChallengeSolution, InvalidChallengeSolution,
#[error("Client approval request failed")] #[error("Client approval request failed")]
@@ -38,6 +44,13 @@ pub enum Error {
Transport, Transport,
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::DatabaseOperationFailed
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError { pub enum ApproveError {
#[error("Internal error")] #[error("Internal error")]
@@ -51,73 +64,79 @@ pub enum ApproveError {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: VerifyingKey, pubkey: authn::PublicKey,
metadata: ClientMetadata, metadata: ClientMetadata,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: Signature, signature: authn::Signature,
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { pubkey: VerifyingKey, nonce: i32 }, AuthChallenge { challenge: AuthChallenge },
AuthSuccess, AuthSuccess,
} }
pub struct ClientInfo { async fn get_client_id(
pub id: i32,
pub current_nonce: i32,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: &VerifyingKey, pubkey: &authn::PublicKey,
) -> Result<Option<ClientInfo>, Error> { ) -> Result<Option<i32>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec(); let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
program_client::table
conn.exclusive_transaction(|conn| { .filter(program_client::public_key.eq(&pubkey_bytes))
Box::pin(async move { .select(program_client::id)
let Some((client_id, current_nonce)) = program_client::table .first::<i32>(&mut conn)
.filter(program_client::public_key.eq(&pubkey_bytes)) .await
.select((program_client::id, program_client::nonce)) .optional()
.first::<(i32, i32)>(conn) .map_err(|e| {
.await error!(error = ?e, "Database error");
.optional()? Error::DatabaseOperationFailed
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
}) })
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
} }
async fn approve_new_client( async fn verify_integrity(
actors: &crate::actors::GlobalActors, db: &db::DatabasePool,
profile: ClientProfile, vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
let id = get_client_id(db, pubkey).await?.ok_or_else(|| {
error!("Client not found during integrity verification");
Error::DatabaseOperationFailed
})?;
let attestation = integrity::verify_entity(
&mut db_conn,
vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?;
if attestation != AttestationStatus::Attested {
error!("Integrity attestation unavailable for client {id}");
return Err(Error::IntegrityCheckFailed);
}
Ok(())
}
async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Result<(), Error> {
let result = actors let result = actors
.flow_coordinator .flow_coordinator
.ask(RequestClientApproval { client: profile }) .ask(RequestClientApproval { client: profile })
@@ -139,45 +158,61 @@ async fn approve_new_client(
async fn insert_client( async fn insert_client(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: &VerifyingKey, vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<i32, Error> { ) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client}; use crate::db::schema::{client_metadata, program_client};
let pubkey = pubkey.clone();
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
let metadata_id = insert_into(client_metadata::table) conn.exclusive_transaction(|conn| {
.values(( let vault = vault.clone();
client_metadata::name.eq(&metadata.name), let pubkey = pubkey.clone();
client_metadata::description.eq(&metadata.description), Box::pin(async move {
client_metadata::version.eq(&metadata.version), let metadata_id = insert_into(client_metadata::table)
)) .values((
.returning(client_metadata::id) client_metadata::name.eq(&metadata.name),
.get_result::<i32>(&mut conn) client_metadata::description.eq(&metadata.description),
.await client_metadata::version.eq(&metadata.version),
.map_err(|e| { ))
error!(error = ?e, "Failed to insert client metadata"); .returning(client_metadata::id)
Error::DatabaseOperationFailed .get_result::<i32>(conn)
})?; .await?;
let client_id = insert_into(program_client::table) let client_id = insert_into(program_client::table)
.values(( .values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()), program_client::public_key.eq(pubkey.to_bytes()),
program_client::metadata_id.eq(metadata_id), program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0 ))
)) .on_conflict_do_nothing()
.on_conflict_do_nothing() .returning(program_client::id)
.returning(program_client::id) .get_result::<i32>(conn)
.get_result::<i32>(&mut conn) .await?;
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
Ok(client_id) integrity::sign_entity(
conn,
&vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?;
Ok(client_id)
})
})
.await
} }
async fn sync_client_metadata( async fn sync_client_metadata(
@@ -253,14 +288,16 @@ async fn sync_client_metadata(
async fn challenge_client<T>( async fn challenge_client<T>(
transport: &mut T, transport: &mut T,
pubkey: VerifyingKey, pubkey: authn::PublicKey,
nonce: i32, challenge: AuthChallenge,
) -> Result<(), Error> ) -> Result<(), Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized, T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
{ {
transport transport
.send(Ok(Outbound::AuthChallenge { pubkey, nonce })) .send(Ok(Outbound::AuthChallenge {
challenge: challenge.clone(),
}))
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to send auth challenge"); error!(error = ?e, "Failed to send auth challenge");
@@ -277,12 +314,10 @@ where
Error::Transport Error::Transport
})?; })?;
let formatted = format_challenge(nonce, pubkey.as_bytes()); if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) {
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
error!("Challenge solution verification failed"); error!("Challenge solution verification failed");
Error::InvalidChallengeSolution return Err(Error::InvalidChallengeSolution);
})?; }
Ok(()) Ok(())
} }
@@ -295,27 +330,28 @@ where
return Err(Error::Transport); return Err(Error::Transport);
}; };
let info = match get_client_and_nonce(&props.db, &pubkey).await? { let client_id = match get_client_id(&props.db, &pubkey).await? {
Some(nonce) => nonce, Some(id) => {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
id
}
None => { None => {
approve_new_client( approve_new_client(
&props.actors, &props.actors,
ClientProfile { ClientProfile {
pubkey, pubkey: pubkey.clone(),
metadata: metadata.clone(), metadata: metadata.clone(),
}, },
) )
.await?; .await?;
let client_id = insert_client(&props.db, &pubkey, &metadata).await?; insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
ClientInfo {
id: client_id,
current_nonce: 0,
}
} }
}; };
sync_client_metadata(&props.db, info.id, &metadata).await?; sync_client_metadata(&props.db, client_id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
let challenge = AuthChallenge::generate(&mut rand::rng());
challenge_client(transport, pubkey, challenge).await?;
transport transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
@@ -325,5 +361,5 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(info.id) Ok(client_id)
} }

View File

@@ -1,18 +1,35 @@
use arbiter_crypto::authn;
use arbiter_proto::{ClientMetadata, transport::Bi}; use arbiter_proto::{ClientMetadata, transport::Bi};
use kameo::actor::Spawn; use kameo::actor::Spawn;
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
actors::{GlobalActors, client::session::ClientSession}, actors::GlobalActors,
crypto::integrity::{Integrable, hashing::Hashable},
db, db,
peers::client::session::ClientSession,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClientProfile { pub struct ClientProfile {
pub pubkey: ed25519_dalek::VerifyingKey, pub pubkey: authn::PublicKey,
pub metadata: ClientMetadata, pub metadata: ClientMetadata,
} }
pub struct ClientCredentials {
pub pubkey: authn::PublicKey,
}
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.to_bytes());
}
}
pub struct ClientConnection { pub struct ClientConnection {
pub(crate) db: db::DatabasePool, pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors, pub(crate) actors: GlobalActors,
@@ -31,7 +48,9 @@ pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
where where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized, T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{ {
match auth::authenticate(&mut props, transport).await { let fut = auth::authenticate(&mut props, transport);
println!("authenticate future size: {}", std::mem::size_of_val(&fut));
match fut.await {
Ok(client_id) => { Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id)); ClientSession::spawn(ClientSession::new(props, client_id));
info!("Client authenticated, session started"); info!("Client authenticated, session started");

View File

@@ -6,15 +6,16 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{ use crate::{
actors::{ actors::{
GlobalActors, GlobalActors,
client::ClientConnection,
evm::{ClientSignTransaction, SignTransactionError}, evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient, flow_coordinator::RegisterClient,
keyholder::KeyHolderState, vault::VaultState,
}, },
db, db,
evm::VetError, evm::VetError,
}; };
use super::ClientConnection;
pub struct ClientSession { pub struct ClientSession {
props: ClientConnection, props: ClientConnection,
client_id: i32, client_id: i32,
@@ -29,13 +30,13 @@ impl ClientSession {
#[messages] #[messages]
impl ClientSession { impl ClientSession {
#[message] #[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> { pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::keyholder::GetState; use crate::actors::vault::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await { let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state, Ok(state) => state,
Err(err) => { Err(err) => {
error!(?err, actor = "client", "keyholder.query.failed"); error!(?err, actor = "client", "vault.query.failed");
return Err(Error::Internal); return Err(Error::Internal);
} }
}; };

View File

@@ -0,0 +1,2 @@
pub mod client;
pub mod user_agent;

View File

@@ -1,18 +1,17 @@
use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use tracing::error; use tracing::error;
use crate::actors::user_agent::{
AuthPublicKey, UserAgentConnection,
auth::state::{AuthContext, AuthStateMachine},
};
mod state; mod state;
use state::*; use state::*;
use super::Credentials;
use super::UserAgentConnection;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: AuthPublicKey, pubkey: authn::PublicKey,
bootstrap_token: Option<String>, bootstrap_token: Option<String>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
@@ -46,7 +45,7 @@ impl From<diesel::result::Error> for Error {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { nonce: i32 }, AuthChallenge { challenge: AuthChallenge },
AuthSuccess, AuthSuccess,
} }
@@ -54,12 +53,11 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
match payload { match payload {
Inbound::AuthChallengeRequest { Inbound::AuthChallengeRequest {
pubkey, pubkey,
bootstrap_token: None, bootstrap_token,
} => AuthEvents::AuthRequest(ChallengeRequest { pubkey }), } => AuthEvents::AuthRequest(ChallengeRequest {
Inbound::AuthChallengeRequest {
pubkey, pubkey,
bootstrap_token: Some(token), bootstrap_token,
} => AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { pubkey, token }), }),
Inbound::AuthChallengeSolution { signature } => { Inbound::AuthChallengeSolution { signature } => {
AuthEvents::ReceivedSolution(ChallengeSolution { AuthEvents::ReceivedSolution(ChallengeSolution {
solution: signature, solution: signature,
@@ -70,21 +68,20 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
pub async fn authenticate<T>( pub async fn authenticate<T>(
props: &mut UserAgentConnection, props: &mut UserAgentConnection,
transport: T, transport: &mut T,
) -> Result<AuthPublicKey, Error> ) -> Result<Credentials, Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + Send, T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{ {
let mut state = AuthStateMachine::new(AuthContext::new(props, transport)); let mut state = AuthStateMachine::new(AuthContext::new(props, transport));
loop { loop {
// `state` holds a mutable reference to `props` so we can't access it directly here
let Some(payload) = state.context_mut().transport.recv().await else { let Some(payload) = state.context_mut().transport.recv().await else {
return Err(Error::Transport); return Err(Error::Transport);
}; };
match state.process_event(parse_auth_event(payload)).await { match state.process_event(parse_auth_event(payload)).await {
Ok(AuthStates::AuthOk(key)) => return Ok(key.clone()), Ok(AuthStates::AuthOk(result)) => return Ok(result.clone()),
Err(AuthError::ActionFailed(err)) => { Err(AuthError::ActionFailed(err)) => {
error!(?err, "State machine action failed"); error!(?err, "State machine action failed");
return Err(err); return Err(err);

View File

@@ -0,0 +1,196 @@
use super::super::{Credentials, UserAgentConnection};
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{
actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::useragent_client},
peers::user_agent::auth::Outbound,
};
pub struct ChallengeRequest {
pub pubkey: authn::PublicKey,
pub bootstrap_token: Option<String>,
}
pub struct ChallengeContext {
pub challenge: AuthChallenge,
pub pubkey: authn::PublicKey,
pub bootstrap_token: Option<String>,
}
pub struct ChallengeSolution {
pub solution: Vec<u8>,
}
smlang::statemachine!(
name: Auth,
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials),
}
);
async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<Option<i32>, Error> {
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
useragent_client::table
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.select(useragent_client::id)
.first::<i32>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})
}
async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i32, Error> {
let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((useragent_client::public_key.eq(pubkey_bytes),))
.returning(useragent_client::id)
.get_result(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
Ok(id)
}
pub struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: &'a mut T,
}
impl<'a, T: ?Sized> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send + ?Sized,
{
type Error = Error;
async fn prepare_challenge(
&mut self,
ChallengeRequest {
pubkey,
bootstrap_token,
}: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
// Verify pubkey is registered (unless bootstrapping)
if bootstrap_token.is_none() {
let id = get_client_id(&self.conn.db, &pubkey).await?;
if id.is_none() {
return Err(Error::UnregisteredPublicKey);
}
}
let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport
.send(Ok(Outbound::AuthChallenge {
challenge: challenge.clone(),
}))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge,
pubkey,
bootstrap_token,
})
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
challenge,
pubkey,
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
})?;
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
if !valid {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
return Err(Error::InvalidChallengeSolution);
}
// Resolve client id: bootstrap (consume token + register) or lookup
let id = match bootstrap_token {
Some(token) => {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
if !token_ok {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
return Err(Error::InvalidBootstrapToken);
}
register_key(&self.conn.db, pubkey).await?
}
None => get_client_id(&self.conn.db, pubkey)
.await?
.ok_or(Error::UnregisteredPublicKey)?,
};
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(Credentials {
id,
pubkey: pubkey.clone(),
})
}
}

View File

@@ -0,0 +1,199 @@
use crate::{
actors::{
GlobalActors,
vault::{GetState, Vault},
},
crypto::integrity::{self, AttestationStatus, Integrable},
db::{self, DatabaseError, DatabasePool},
peers::client::ClientProfile,
};
use arbiter_crypto::authn;
use arbiter_proto::transport::{Bi, Sender};
pub use auth::authenticate;
use kameo::actor::{ActorRef, Spawn as _};
pub use session::UserAgentSession;
use tokio::sync::oneshot;
use tracing::{error, warn};
use vault_gate::VaultGate;
use crate::crypto::integrity::hashing::Hashable;
pub mod auth;
pub mod session;
pub mod vault_gate;
#[derive(Debug, Clone)]
pub struct Credentials {
pub id: i32,
pub pubkey: authn::PublicKey,
}
impl Hashable for authn::PublicKey {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.to_bytes());
}
}
impl Hashable for Credentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.id.hash(hasher);
self.pubkey.hash(hasher);
}
}
impl Integrable for Credentials {
const KIND: &'static str = "useragent_credentials";
}
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile },
ClientConnectionCancel { pubkey: authn::PublicKey },
}
#[derive(Clone)]
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("authentication failed: {0:?}")]
Auth(auth::Error),
#[error("vault gate failed: {0}")]
VaultGate(#[from] vault_gate::Error),
#[error("transport closed unexpectedly")]
Transport,
#[error("database error: {0}")]
Database(DatabaseError),
#[error("internal: {0}")]
Internal(String),
}
impl From<auth::Error> for Error {
fn from(err: auth::Error) -> Self {
Self::Auth(err)
}
}
async fn verify_integrity(
db: &DatabasePool,
vault: &ActorRef<Vault>,
credentials: &Credentials,
) -> Result<(), Error> {
let mut conn = db
.get()
.await
.map_err(|_| Error::Internal("DB unavailable".into()))?;
match integrity::verify_entity(&mut conn, &vault, credentials, credentials.id).await {
Ok(AttestationStatus::Attested) => Ok(()),
Ok(AttestationStatus::Unavailable) => {
Err(Error::Internal("Vault sealed during promotion".into()))
}
Err(e) => {
error!(?e, "Integrity verification failed during unseal promotion");
Err(Error::Internal("Integrity check failed".into()))
}
}
}
async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let vault_state = vault
.ask(GetState {})
.await
.map_err(|_| Error::Internal("Failed to contact the vault".into()))?;
Ok(!matches!(
vault_state,
crate::actors::vault::VaultState::Unsealed
))
}
async fn run_vault_gate<T>(
props: &UserAgentConnection,
transport: &mut T,
auth_creds: Credentials,
) -> Result<(), Error>
where
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send + ?Sized,
{
let (promotion_tx, mut promotion_rx) = oneshot::channel();
let gate = VaultGate::spawn(VaultGate::new(
auth_creds,
props.actors.clone(),
props.db.clone(),
promotion_tx,
));
let result = loop {
tokio::select! {
promotion = &mut promotion_rx => {
break match promotion {
Ok(Ok(creds)) => Ok(creds),
Ok(Err(err)) => Err(Error::VaultGate(err)),
Err(_) => Err(Error::Internal(
"vault gate promotion channel closed".into(),
)),
};
}
inbound = transport.recv() => {
let Some(inbound) = inbound else {
break Err(Error::Transport);
};
match gate.ask(inbound).await {
Ok(outbound) => {
if transport.send(Ok(outbound)).await.is_err() {
break Err(Error::Transport);
}
}
Err(err) => {
warn!(?err, "VaultGate failed to handle message");
break Err(Error::Internal(format!(
"vault gate ask failed: {err:?}"
)));
}
}
}
}
};
gate.kill();
result
}
pub async fn start<T>(
props: &mut UserAgentConnection,
mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<UserAgentSession>, Error>
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
{
let creds = authenticate(props, &mut transport).await?;
// should run vault gate only if sealed / unbootstrapped
if should_run_gate(&props.actors.vault).await? {
run_vault_gate(props, &mut transport, creds.clone()).await?;
}
// checking the integrity
verify_integrity(&props.db, &props.actors.vault, &creds).await?;
Ok(UserAgentSession::spawn(UserAgentSession::new(
props.clone(),
creds,
oob_sender,
)))
}

View File

@@ -0,0 +1,294 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use arbiter_crypto::{
authn,
safecell::SafeCellHandle as _,
};
use chacha20poly1305::aead::KeyInit;
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context;
use tracing::error;
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
};
use crate::db::models::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::{
actors::vault::VaultState,
};
use super::{Error, UserAgentSession};
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(UseragentDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey.to_bytes()) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -1,20 +1,21 @@
use arbiter_crypto::authn;
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use arbiter_proto::transport::Sender; use arbiter_proto::transport::Sender;
use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
use crate::actors::{ use crate::{
client::ClientProfile, actors::{
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController}, flow_coordinator::client_connect_approval::ClientApprovalController,
user_agent::{OutOfBand, UserAgentConnection}, useragent_registry::ConnectUseragent,
},
peers::{client::ClientProfile, user_agent::Credentials},
}; };
mod state; use super::{OutOfBand, UserAgentConnection};
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
@@ -47,52 +48,33 @@ impl Error {
} }
pub struct PendingClientApproval { pub struct PendingClientApproval {
pubkey: authn::PublicKey,
controller: ActorRef<ClientApprovalController>, controller: ActorRef<ClientApprovalController>,
} }
pub struct UserAgentSession { pub struct UserAgentSession {
creds: Credentials,
props: UserAgentConnection, props: UserAgentConnection,
state: UserAgentStateMachine<DummyContext>,
sender: Box<dyn Sender<OutOfBand>>, sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<VerifyingKey, PendingClientApproval>, pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
} }
pub mod connection; pub mod handlers;
impl UserAgentSession { impl UserAgentSession {
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self { pub(crate) fn new(
props: UserAgentConnection,
creds: Credentials,
sender: Box<dyn Sender<OutOfBand>>,
) -> Self {
Self { Self {
creds,
props, props,
state: UserAgentStateMachine::new(DummyContext),
sender, sender,
pending_client_approvals: Default::default(), pending_client_approvals: Default::default(),
} }
} }
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
struct DummySender;
#[async_trait]
impl Sender<OutOfBand> for DummySender {
async fn send(
&mut self,
_item: OutOfBand,
) -> Result<(), arbiter_proto::transport::Error> {
Ok(())
}
}
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
Error::State
})?;
Ok(())
}
} }
#[messages] #[messages]
@@ -118,8 +100,13 @@ impl UserAgentSession {
return; return;
} }
self.pending_client_approvals self.pending_client_approvals.insert(
.insert(client.pubkey, PendingClientApproval { controller }); client.pubkey.to_bytes(),
PendingClientApproval {
pubkey: client.pubkey,
controller,
},
);
} }
} }
@@ -134,17 +121,17 @@ impl Actor for UserAgentSession {
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
.flow_coordinator .useragent_registry
.ask(RegisterUserAgent { .ask(ConnectUseragent {
actor: this.clone(), actor: this.clone(),
}) })
.await .await
.map_err(|err| { .map_err(|err| {
error!( error!(
?err, ?err,
"Failed to register user agent connection with flow coordinator" "Failed to register user agent connection with user agent registry"
); );
Error::internal("Failed to register user agent connection with flow coordinator") Error::internal("Failed to register user agent connection with user agent registry")
})?; })?;
Ok(args) Ok(args)
} }
@@ -158,14 +145,18 @@ impl Actor for UserAgentSession {
let cancelled_pubkey = self let cancelled_pubkey = self
.pending_client_approvals .pending_client_approvals
.iter() .iter()
.find_map(|(k, v)| (v.controller.id() == id).then_some(*k)); .find_map(|(k, v)| (v.controller.id() == id).then_some(k.clone()));
if let Some(pubkey) = cancelled_pubkey { if let Some(pubkey_bytes) = cancelled_pubkey {
self.pending_client_approvals.remove(&pubkey); let Some(approval) = self.pending_client_approvals.remove(&pubkey_bytes) else {
return Ok(std::ops::ControlFlow::Continue(()));
};
if let Err(e) = self if let Err(e) = self
.sender .sender
.send(OutOfBand::ClientConnectionCancel { pubkey }) .send(OutOfBand::ClientConnectionCancel {
pubkey: approval.pubkey,
})
.await .await
{ {
error!( error!(

View File

@@ -0,0 +1,296 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::transport::Bi;
use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
use kameo::{Actor, error::SendError, messages, prelude::Message};
use kameo_actors::message_bus::Register;
use tokio::sync::oneshot;
use tracing::{error, info, warn};
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub mod state;
use state::*;
use super::Credentials;
use crate::{
actors::{
GlobalActors,
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
},
crypto::integrity::{self},
db::DatabasePool,
peers::user_agent::UserAgentConnection,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Invalid key provided")]
InvalidKey,
#[error("State transition failed")]
State,
#[error("Internal error: {0}")]
Internal(String),
}
impl Error {
fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
}
pub struct HandshakeResponse {
pub server_pubkey: PublicKey,
}
pub struct VaultGate {
pub auth_creds: Credentials,
pub promotion_tx: Option<oneshot::Sender<Result<(), Error>>>,
pub state: State,
pub actors: GlobalActors,
pub db: DatabasePool,
}
impl VaultGate {
pub fn new(
auth_creds: Credentials,
actors: GlobalActors,
db: DatabasePool,
promotion_tx: oneshot::Sender<Result<(), Error>>,
) -> Self {
Self {
auth_creds,
state: State::default(),
actors,
db,
promotion_tx: Some(promotion_tx),
}
}
}
impl Actor for VaultGate {
type Args = Self;
type Error = ();
async fn on_start(
args: Self::Args,
actor_ref: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
let _ = args
.actors
.events
.tell(Register(
actor_ref.clone().recipient::<events::Bootstrapped>(),
))
.await;
let _ = args
.actors
.events
.tell(Register(actor_ref.recipient::<events::Unsealed>()))
.await;
Ok(args)
}
}
impl VaultGate {
fn decrypt_key(
secret: &SharedSecret,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
#[messages(messages = Inbound, replies = Outbound)]
impl VaultGate {
#[message]
pub async fn handle_handshake(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret);
let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange {
server_key: public_key.clone(),
secret,
};
Ok(HandshakeResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), Error> {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
{
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey);
}
};
match self
.actors
.vault
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
Ok(())
}
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to vault");
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), Error> {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key_buffer = match Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
{
Ok(buffer) => buffer,
Err(()) => {
return Err(Error::InvalidKey);
}
};
match self
.actors
.vault
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
Ok(())
}
Err(SendError::HandlerError(vault::Error::AlreadyBootstrapped)) => {
Err(Error::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to bootstrap vault");
Err(Error::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to vault");
Err(Error::internal("Vault error"))
}
}
}
#[message]
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
let answer = self
.actors
.vault
.ask(GetState {})
.await
.map_err(|_| Error::internal("failed to query vault"))?;
Ok(answer)
}
}
impl Message<events::Bootstrapped> for VaultGate {
type Reply = ();
async fn handle(
&mut self,
_: events::Bootstrapped,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = async {
let mut conn = self
.db
.get()
.await
.map_err(|_| Error::internal("DB unavailable"))?;
integrity::sign_entity(
&mut conn,
&self.actors.vault,
&self.auth_creds,
self.auth_creds.id,
)
.await
.map_err(|e| {
error!(?e, "Failed to sign integrity envelope on bootstrap");
Error::internal("Integrity sign failed")
})?;
Ok(())
}
.await;
if let Some(tx) = self.promotion_tx.take() {
let _ = tx.send(result);
}
ctx.stop();
}
}
impl Message<events::Unsealed> for VaultGate {
type Reply = ();
async fn handle(
&mut self,
_: events::Unsealed,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
if let Some(tx) = self.promotion_tx.take() {
let _ = tx.send(Ok(()));
}
ctx.stop();
}
}

View File

@@ -0,0 +1,16 @@
use x25519_dalek::{PublicKey, SharedSecret};
pub struct Handshake {
client_pubkey: PublicKey,
}
#[derive(Default)]
pub enum State {
#[default]
Idle,
ReadyForExchange {
server_key: PublicKey,
secret: SharedSecret,
},
}

View File

@@ -1,13 +1,18 @@
use arbiter_crypto::{
authn::{self, AuthChallenge, CLIENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::ClientMetadata; use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender}; use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{ use arbiter_server::{
actors::client::{ClientConnection, auth, connect_client}, actors::{GlobalActors, vault::Bootstrap},
db, crypto::integrity,
db::{self, schema},
peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
}; };
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into}; use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use ed25519_dalek::Signer as _; use ml_dsa::{KeyGen, MlDsa87, SigningKey, VerifyingKey, signature::Keypair as _};
use super::common::ChannelTransport; use super::common::ChannelTransport;
@@ -21,7 +26,8 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
async fn insert_registered_client( async fn insert_registered_client(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: Vec<u8>, actors: &GlobalActors,
pubkey: VerifyingKey<MlDsa87>,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) { ) {
use arbiter_server::db::schema::{client_metadata, program_client}; use arbiter_server::db::schema::{client_metadata, program_client};
@@ -37,34 +43,82 @@ async fn insert_registered_client(
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();
insert_into(program_client::table) let client_id: i32 = insert_into(program_client::table)
.values(( .values((
program_client::public_key.eq(pubkey), program_client::public_key.eq(pubkey.encode().to_vec()),
program_client::metadata_id.eq(metadata_id), program_client::metadata_id.eq(metadata_id),
)) ))
.returning(program_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.vault,
&ClientCredentials {
pubkey: pubkey.into(),
},
client_id,
)
.await
.unwrap();
}
fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -> authn::Signature {
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, CLIENT_CONTEXT)
.unwrap()
.into()
}
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = MlDsa87::key_gen(&mut rand::rng())
.verifying_key()
.encode()
.to_vec();
insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(sentinel_key),))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
} }
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_useragent(db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
actors
}
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn test_unregistered_pubkey_rejected() { pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = spawn_test_actors(&db).await;
let props = ClientConnection::new(db.clone(), actors); let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut server_transport = server_transport; let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await; connect_client(props, &mut server_transport).await;
}); });
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(), pubkey: new_key.verifying_key().into(),
metadata: metadata("client", Some("desc"), Some("1.0.0")), metadata: metadata("client", Some("desc"), Some("1.0.0")),
}) })
.await .await
@@ -78,20 +132,19 @@ pub async fn test_unregistered_pubkey_rejected() {
#[test_log::test] #[test_log::test]
pub async fn test_challenge_auth() { pub async fn test_challenge_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
insert_registered_client( insert_registered_client(
&db, &db,
pubkey_bytes.clone(), &actors,
new_key.verifying_key(),
&metadata("client", Some("desc"), Some("1.0.0")), &metadata("client", Some("desc"), Some("1.0.0")),
) )
.await; .await;
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors); let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut server_transport = server_transport; let mut server_transport = server_transport;
@@ -101,7 +154,7 @@ pub async fn test_challenge_auth() {
// Send challenge request // Send challenge request
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(), pubkey: new_key.verifying_key().into(),
metadata: metadata("client", Some("desc"), Some("1.0.0")), metadata: metadata("client", Some("desc"), Some("1.0.0")),
}) })
.await .await
@@ -114,15 +167,14 @@ pub async fn test_challenge_auth() {
.expect("should receive challenge"); .expect("should receive challenge");
let challenge = match response { let challenge = match response {
Ok(resp) => match resp { Ok(resp) => match resp {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}, },
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
// Sign the challenge and send solution // Sign the challenge and send solution
let formatted_challenge = arbiter_proto::format_challenge(challenge.1, challenge.0.as_bytes()); let signature = sign_client_challenge(&new_key, &challenge);
let signature = new_key.sign(&formatted_challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { signature }) .send(auth::Inbound::AuthChallengeSolution { signature })
@@ -147,34 +199,13 @@ pub async fn test_challenge_auth() {
#[test_log::test] #[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() { pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = spawn_test_actors(&db).await;
let props = ClientConnection::new(db.clone(), actors); let new_key = MlDsa87::key_gen(&mut rand::rng());
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0")); let requested = metadata("client", Some("desc"), Some("1.0.0"));
{ insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap(); let props = ClientConnection::new(db.clone(), actors);
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
@@ -184,18 +215,18 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(), pubkey: new_key.verifying_key().into(),
metadata: requested, metadata: requested,
}) })
.await .await
.unwrap(); .unwrap();
let response = test_transport.recv().await.unwrap().unwrap(); let response = test_transport.recv().await.unwrap().unwrap();
let (pubkey, nonce) = match response { let challenge = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}; };
let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes())); let signature = sign_client_challenge(&new_key, &challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { signature }) .send(auth::Inbound::AuthChallengeSolution { signature })
.await .await
@@ -225,34 +256,19 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
#[test_log::test] #[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() { pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng());
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 props = ClientConnection::new(db.clone(), actors);
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();
}
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut server_transport = server_transport; let mut server_transport = server_transport;
@@ -261,18 +277,18 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(), pubkey: new_key.verifying_key().into(),
metadata: metadata("client", Some("new"), Some("2.0.0")), metadata: metadata("client", Some("new"), Some("2.0.0")),
}) })
.await .await
.unwrap(); .unwrap();
let response = test_transport.recv().await.unwrap().unwrap(); let response = test_transport.recv().await.unwrap().unwrap();
let (pubkey, nonce) = match response { let challenge = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}; };
let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes())); let signature = sign_client_challenge(&new_key, &challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { signature }) .send(auth::Inbound::AuthChallengeSolution { signature })
.await .await
@@ -322,3 +338,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 = MlDsa87::key_gen(&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().encode().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().into(),
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

@@ -1,17 +1,20 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::transport::{Bi, Error, Receiver, Sender}; use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::keyholder::KeyHolder, actors::{GlobalActors, vault::Vault},
db::{self, schema}, db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[allow(dead_code)] #[allow(dead_code)]
pub async fn bootstrapped_keyholder(db: &db::DatabasePool) -> KeyHolder { pub async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
let mut actor = KeyHolder::new(db.clone()).await.unwrap(); let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
actor actor
.bootstrap(SafeCell::new(b"test-seal-key".to_vec())) .bootstrap(SafeCell::new(b"test-seal-key".to_vec()))
.await .await

View File

@@ -1,8 +0,0 @@
mod common;
#[path = "keyholder/concurrency.rs"]
mod concurrency;
#[path = "keyholder/lifecycle.rs"]
mod lifecycle;
#[path = "keyholder/storage.rs"]
mod storage;

View File

@@ -1,28 +1,152 @@
use arbiter_proto::transport::{Receiver, Sender}; use arbiter_crypto::{
authn::{self, AuthChallenge, USERAGENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
GlobalActors,
bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
},
crypto::integrity, crypto::integrity,
db::{self, schema}, db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _}, peers::user_agent::{self, Credentials, UserAgentConnection, auth, vault_gate},
}; };
use async_trait::async_trait;
use diesel::{ExpressionMethods as _, QueryDsl, insert_into}; use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use ed25519_dalek::Signer as _; use ml_dsa::{KeyGen, MlDsa87, SigningKey, signature::Keypair as _};
use tokio::sync::mpsc;
use super::common::ChannelTransport; use super::common::ChannelTransport;
fn sign_useragent_challenge(
key: &SigningKey<MlDsa87>,
challenge: &AuthChallenge,
) -> authn::Signature {
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, USERAGENT_CONTEXT)
.unwrap()
.into()
}
fn tamper_challenge(challenge: &AuthChallenge) -> AuthChallenge {
let mut challenge = challenge.clone();
challenge.nonce[0] ^= 1;
challenge
}
struct NullOobSender;
#[async_trait]
impl Sender<user_agent::OutOfBand> for NullOobSender {
async fn send(&mut self, _item: user_agent::OutOfBand) -> Result<(), TransportError> {
Ok(())
}
}
struct StartServerTransport {
auth_rx: mpsc::Receiver<auth::Inbound>,
auth_tx: mpsc::Sender<Result<auth::Outbound, auth::Error>>,
vault_rx: mpsc::Receiver<vault_gate::Inbound>,
vault_tx: mpsc::Sender<Result<vault_gate::Outbound, vault_gate::Error>>,
}
struct StartTestTransport {
auth_rx: mpsc::Receiver<Result<auth::Outbound, auth::Error>>,
auth_tx: mpsc::Sender<auth::Inbound>,
}
fn start_transport_pair() -> (StartServerTransport, StartTestTransport) {
let (auth_in_tx, auth_in_rx) = mpsc::channel(10);
let (auth_out_tx, auth_out_rx) = mpsc::channel(10);
let (_vault_in_tx, vault_in_rx) = mpsc::channel(10);
let (vault_out_tx, _vault_out_rx) = mpsc::channel(10);
(
StartServerTransport {
auth_rx: auth_in_rx,
auth_tx: auth_out_tx,
vault_rx: vault_in_rx,
vault_tx: vault_out_tx,
},
StartTestTransport {
auth_rx: auth_out_rx,
auth_tx: auth_in_tx,
},
)
}
#[async_trait]
impl Receiver<auth::Inbound> for StartServerTransport {
async fn recv(&mut self) -> Option<auth::Inbound> {
self.auth_rx.recv().await
}
}
#[async_trait]
impl Sender<Result<auth::Outbound, auth::Error>> for StartServerTransport {
async fn send(&mut self, item: Result<auth::Outbound, auth::Error>) -> Result<(), TransportError> {
self.auth_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
impl arbiter_proto::transport::Bi<auth::Inbound, Result<auth::Outbound, auth::Error>>
for StartServerTransport
{
}
#[async_trait]
impl Receiver<vault_gate::Inbound> for StartServerTransport {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
self.vault_rx.recv().await
}
}
#[async_trait]
impl Sender<Result<vault_gate::Outbound, vault_gate::Error>> for StartServerTransport {
async fn send(
&mut self,
item: Result<vault_gate::Outbound, vault_gate::Error>,
) -> Result<(), TransportError> {
self.vault_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
impl arbiter_proto::transport::Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>>
for StartServerTransport
{
}
#[async_trait]
impl Receiver<Result<auth::Outbound, auth::Error>> for StartTestTransport {
async fn recv(&mut self) -> Option<Result<auth::Outbound, auth::Error>> {
self.auth_rx.recv().await
}
}
#[async_trait]
impl Sender<auth::Inbound> for StartTestTransport {
async fn send(&mut self, item: auth::Inbound) -> Result<(), TransportError> {
self.auth_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn test_bootstrap_token_auth() { pub async fn test_bootstrap_token_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors actors
.key_holder .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
@@ -30,30 +154,45 @@ pub async fn test_bootstrap_token_auth() {
.unwrap(); .unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), pubkey: new_key.verifying_key().into(),
bootstrap_token: Some(token), bootstrap_token: Some(token),
}) })
.await .await
.unwrap(); .unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport let response = test_transport
.recv() .recv()
.await .await
.expect("should receive auth result"); .expect("should receive auth result");
match response { assert!(matches!(response, Ok(auth::Outbound::AuthSuccess)));
Ok(auth::Outbound::AuthSuccess) => {}
other => panic!("Expected AuthSuccess, got {other:?}"),
}
task.await.unwrap().unwrap(); task.await.unwrap().unwrap();
@@ -63,7 +202,7 @@ pub async fn test_bootstrap_token_auth() {
.first::<Vec<u8>>(&mut conn) .first::<Vec<u8>>(&mut conn)
.await .await
.unwrap(); .unwrap();
assert_eq!(stored_pubkey, new_key.verifying_key().to_bytes().to_vec()); assert_eq!(stored_pubkey, new_key.verifying_key().encode().to_vec());
} }
#[tokio::test] #[tokio::test]
@@ -72,22 +211,39 @@ pub async fn test_bootstrap_invalid_token_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), pubkey: new_key.verifying_key().into(),
bootstrap_token: Some("invalid_token".to_string()), bootstrap_token: Some("invalid_token".to_string()),
}) })
.await .await
.unwrap(); .unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
assert!(matches!( assert!(matches!(
task.await.unwrap(), task.await.unwrap(),
Err(auth::Error::InvalidBootstrapToken) Err(auth::Error::InvalidBootstrapToken)
@@ -108,33 +264,30 @@ pub async fn test_challenge_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors actors
.key_holder .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().encode().to_vec();
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table) let id: i32 = insert_into(schema::useragent_client::table)
.values(( .values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.returning(schema::useragent_client::id) .returning(schema::useragent_client::id)
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();
integrity::sign_entity( integrity::sign_entity(
&mut conn, &mut conn,
&actors.key_holder, &actors.vault,
&UserAgentCredentials { &Credentials {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), id,
nonce: 1, pubkey: new_key.verifying_key().into(),
}, },
id, id,
) )
@@ -142,16 +295,16 @@ pub async fn test_challenge_auth() {
.unwrap(); .unwrap();
} }
let (server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), pubkey: new_key.verifying_key().into(),
bootstrap_token: None, bootstrap_token: None,
}) })
.await .await
@@ -163,18 +316,17 @@ pub async fn test_challenge_auth() {
.expect("should receive challenge"); .expect("should receive challenge");
let challenge = match response { let challenge = match response {
Ok(resp) => match resp { Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce, auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}, },
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
let formatted_challenge = arbiter_proto::format_challenge(challenge, &pubkey_bytes); let signature = sign_useragent_challenge(&new_key, &challenge);
let signature = new_key.sign(&formatted_challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes().to_vec(), signature: signature.to_bytes(),
}) })
.await .await
.unwrap(); .unwrap();
@@ -198,99 +350,35 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors actors
.key_holder .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().encode().to_vec();
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table) insert_into(schema::useragent_client::table)
.values(( .values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
} }
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = start_transport_pair();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await user_agent::start(&mut props, server_transport, Box::new(NullOobSender)).await
}); });
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()), pubkey: new_key.verifying_key().into(),
bootstrap_token: None,
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::Internal { .. })
));
}
#[tokio::test]
#[test_log::test]
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();
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),
))
.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();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None, bootstrap_token: None,
}) })
.await .await
@@ -302,18 +390,102 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
.expect("should receive challenge"); .expect("should receive challenge");
let challenge = match response { let challenge = match response {
Ok(resp) => match resp { Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce, auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}, },
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
let wrong_challenge = arbiter_proto::format_challenge(challenge + 1, &pubkey_bytes); let signature = sign_useragent_challenge(&new_key, &challenge);
let signature = new_key.sign(&wrong_challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes().to_vec(), signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
assert!(matches!(response, Ok(auth::Outbound::AuthSuccess)));
assert!(matches!(
task.await.unwrap(),
Err(user_agent::Error::Internal(_))
));
}
#[tokio::test]
#[test_log::test]
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
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = MlDsa87::key_gen(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().encode().to_vec();
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.vault,
&Credentials {
id,
pubkey: new_key.verifying_key().into(),
},
id,
)
.await
.unwrap();
}
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key().into(),
bootstrap_token: None,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, &tamper_challenge(&challenge));
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
}) })
.await .await
.unwrap(); .unwrap();

View File

@@ -1,50 +1,62 @@
use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
keyholder::{Bootstrap, Seal}, vault::{Bootstrap, Seal},
user_agent::{
UserAgentSession,
session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
}, },
db, db,
safe_cell::{SafeCell, SafeCellHandle as _}, peers::user_agent::{
Credentials,
vault_gate::{
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,
},
},
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _; use kameo::actor::Spawn as _;
use tokio::sync::oneshot;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
async fn setup_sealed_user_agent( async fn setup_sealed_gate(
seal_key: &[u8], seal_key: &[u8],
) -> (db::DatabasePool, kameo::actor::ActorRef<UserAgentSession>) { ) -> (
db::DatabasePool,
kameo::actor::ActorRef<VaultGate>,
oneshot::Receiver<Result<(), VaultGateError>>,
) {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors actors
.key_holder .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(seal_key.to_vec()), seal_key_raw: SafeCell::new(seal_key.to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
actors.key_holder.ask(Seal).await.unwrap(); actors.vault.ask(Seal).await.unwrap();
let session = UserAgentSession::spawn(UserAgentSession::new_test(db.clone(), actors)); let (promotion_tx, promotion_rx) = oneshot::channel();
let pubkey = authn::SigningKey::generate().public_key();
let auth_creds = Credentials { id: 1, pubkey };
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
(db, session) (db, gate, promotion_rx)
} }
async fn client_dh_encrypt( async fn client_dh_encrypt(
user_agent: &kameo::actor::ActorRef<UserAgentSession>, gate: &kameo::actor::ActorRef<VaultGate>,
key_to_send: &[u8], key_to_send: &[u8],
) -> HandleUnsealEncryptedKey { ) -> HandleUnsealEncryptedKey {
let client_secret = EphemeralSecret::random(); let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret); let client_public = PublicKey::from(&client_secret);
let response = user_agent let response = gate
.ask(HandleUnsealRequest { .ask(HandleHandshake {
client_pubkey: client_public, client_pubkey: client_public,
}) })
.await .await
@@ -72,26 +84,26 @@ async fn client_dh_encrypt(
#[test_log::test] #[test_log::test]
pub async fn test_unseal_success() { pub async fn test_unseal_success() {
let seal_key = b"test-seal-key"; let seal_key = b"test-seal-key";
let (_db, user_agent) = setup_sealed_user_agent(seal_key).await; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await; let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
let response = user_agent.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!(response, Ok(()))); assert!(matches!(response, Ok(())));
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn test_unseal_wrong_seal_key() { pub async fn test_unseal_wrong_seal_key() {
let (_db, user_agent) = setup_sealed_user_agent(b"correct-key").await; let (_db, gate, _promotion_rx) = setup_sealed_gate(b"correct-key").await;
let encrypted_key = client_dh_encrypt(&user_agent, b"wrong-key").await; let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
let response = user_agent.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!( assert!(matches!(
response, response,
Err(kameo::error::SendError::HandlerError( Err(kameo::error::SendError::HandlerError(
UnsealError::InvalidKey VaultGateError::InvalidKey
)) ))
)); ));
} }
@@ -99,19 +111,18 @@ pub async fn test_unseal_wrong_seal_key() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn test_unseal_corrupted_ciphertext() { pub async fn test_unseal_corrupted_ciphertext() {
let (_db, user_agent) = setup_sealed_user_agent(b"test-key").await; let (_db, gate, _promotion_rx) = setup_sealed_gate(b"test-key").await;
let client_secret = EphemeralSecret::random(); let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret); let client_public = PublicKey::from(&client_secret);
user_agent gate.ask(HandleHandshake {
.ask(HandleUnsealRequest { client_pubkey: client_public,
client_pubkey: client_public, })
}) .await
.await .unwrap();
.unwrap();
let response = user_agent let response = gate
.ask(HandleUnsealEncryptedKey { .ask(HandleUnsealEncryptedKey {
nonce: vec![0u8; 24], nonce: vec![0u8; 24],
ciphertext: vec![0u8; 32], ciphertext: vec![0u8; 32],
@@ -122,7 +133,7 @@ pub async fn test_unseal_corrupted_ciphertext() {
assert!(matches!( assert!(matches!(
response, response,
Err(kameo::error::SendError::HandlerError( Err(kameo::error::SendError::HandlerError(
UnsealError::InvalidKey VaultGateError::InvalidKey
)) ))
)); ));
} }
@@ -131,24 +142,24 @@ pub async fn test_unseal_corrupted_ciphertext() {
#[test_log::test] #[test_log::test]
pub async fn test_unseal_retry_after_invalid_key() { pub async fn test_unseal_retry_after_invalid_key() {
let seal_key = b"real-seal-key"; let seal_key = b"real-seal-key";
let (_db, user_agent) = setup_sealed_user_agent(seal_key).await; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
{ {
let encrypted_key = client_dh_encrypt(&user_agent, b"wrong-key").await; let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
let response = user_agent.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!( assert!(matches!(
response, response,
Err(kameo::error::SendError::HandlerError( Err(kameo::error::SendError::HandlerError(
UnsealError::InvalidKey VaultGateError::InvalidKey
)) ))
)); ));
} }
{ {
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await; let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
let response = user_agent.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!(response, Ok(()))); assert!(matches!(response, Ok(())));
} }
} }

View File

@@ -0,0 +1,8 @@
mod common;
#[path = "vault/concurrency.rs"]
mod concurrency;
#[path = "vault/lifecycle.rs"]
mod lifecycle;
#[path = "vault/storage.rs"]
mod storage;

View File

@@ -1,10 +1,14 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{ use arbiter_server::{
actors::keyholder::{CreateNew, Error, KeyHolder}, actors::{
GlobalActors,
vault::{CreateNew, Error, Vault},
},
db::{self, models, schema}, db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::actor::{ActorRef, Spawn as _}; use kameo::actor::{ActorRef, Spawn as _};
@@ -13,7 +17,7 @@ use tokio::task::JoinSet;
use crate::common; use crate::common;
async fn write_concurrently( async fn write_concurrently(
actor: ActorRef<KeyHolder>, actor: ActorRef<Vault>,
prefix: &'static str, prefix: &'static str,
count: usize, count: usize,
) -> Vec<(i32, Vec<u8>)> { ) -> Vec<(i32, Vec<u8>)> {
@@ -43,7 +47,7 @@ async fn write_concurrently(
#[test_log::test] #[test_log::test]
async fn concurrent_create_new_no_duplicate_nonces_() { async fn concurrent_create_new_no_duplicate_nonces_() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = KeyHolder::spawn(common::bootstrapped_keyholder(&db).await); let actor = Vault::spawn(common::bootstrapped_vault(&db).await);
let writes = write_concurrently(actor, "nonce-unique", 32).await; let writes = write_concurrently(actor, "nonce-unique", 32).await;
assert_eq!(writes.len(), 32); assert_eq!(writes.len(), 32);
@@ -65,7 +69,7 @@ async fn concurrent_create_new_no_duplicate_nonces_() {
#[test_log::test] #[test_log::test]
async fn concurrent_create_new_root_nonce_never_moves_backward() { async fn concurrent_create_new_root_nonce_never_moves_backward() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = KeyHolder::spawn(common::bootstrapped_keyholder(&db).await); let actor = Vault::spawn(common::bootstrapped_vault(&db).await);
write_concurrently(actor, "root-max", 24).await; write_concurrently(actor, "root-max", 24).await;
@@ -93,7 +97,7 @@ async fn concurrent_create_new_root_nonce_never_moves_backward() {
#[test_log::test] #[test_log::test]
async fn insert_failure_does_not_create_partial_row() { async fn insert_failure_does_not_create_partial_row() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let root_key_history_id = common::root_key_history_id(&db).await; let root_key_history_id = common::root_key_history_id(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -155,12 +159,14 @@ async fn insert_failure_does_not_create_partial_row() {
#[test_log::test] #[test_log::test]
async fn decrypt_roundtrip_after_high_concurrency() { async fn decrypt_roundtrip_after_high_concurrency() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = KeyHolder::spawn(common::bootstrapped_keyholder(&db).await); let actor = Vault::spawn(common::bootstrapped_vault(&db).await);
let writes = write_concurrently(actor, "roundtrip", 40).await; let writes = write_concurrently(actor, "roundtrip", 40).await;
let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect(); let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect();
let mut decryptor = KeyHolder::new(db.clone()).await.unwrap(); let mut decryptor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
decryptor decryptor
.try_unseal(SafeCell::new(b"test-seal-key".to_vec())) .try_unseal(SafeCell::new(b"test-seal-key".to_vec()))
.await .await

View File

@@ -1,9 +1,13 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{ use arbiter_server::{
actors::keyholder::{Error, KeyHolder}, actors::{
GlobalActors,
vault::{Error, Vault},
},
crypto::encryption::v1::{Nonce, ROOT_KEY_TAG}, crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
db::{self, models, schema}, db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{QueryDsl, SelectableHelper}; use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -13,7 +17,9 @@ use crate::common;
#[test_log::test] #[test_log::test]
async fn test_bootstrap() { async fn test_bootstrap() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = KeyHolder::new(db.clone()).await.unwrap(); let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
@@ -36,7 +42,7 @@ async fn test_bootstrap() {
#[test_log::test] #[test_log::test]
async fn test_bootstrap_rejects_double() { async fn test_bootstrap_rejects_double() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let seal_key2 = SafeCell::new(b"test-seal-key".to_vec()); let seal_key2 = SafeCell::new(b"test-seal-key".to_vec());
let err = actor.bootstrap(seal_key2).await.unwrap_err(); let err = actor.bootstrap(seal_key2).await.unwrap_err();
@@ -47,7 +53,9 @@ async fn test_bootstrap_rejects_double() {
#[test_log::test] #[test_log::test]
async fn test_create_new_before_bootstrap_fails() { async fn test_create_new_before_bootstrap_fails() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = KeyHolder::new(db).await.unwrap(); let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let err = actor let err = actor
.create_new(SafeCell::new(b"data".to_vec())) .create_new(SafeCell::new(b"data".to_vec()))
@@ -60,7 +68,9 @@ async fn test_create_new_before_bootstrap_fails() {
#[test_log::test] #[test_log::test]
async fn test_decrypt_before_bootstrap_fails() { async fn test_decrypt_before_bootstrap_fails() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = KeyHolder::new(db).await.unwrap(); let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let err = actor.decrypt(1).await.unwrap_err(); let err = actor.decrypt(1).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped)); assert!(matches!(err, Error::NotBootstrapped));
@@ -70,19 +80,21 @@ async fn test_decrypt_before_bootstrap_fails() {
#[test_log::test] #[test_log::test]
async fn test_new_restores_sealed_state() { async fn test_new_restores_sealed_state() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = common::bootstrapped_keyholder(&db).await; let actor = common::bootstrapped_vault(&db).await;
drop(actor); drop(actor);
let mut actor2 = KeyHolder::new(db).await.unwrap(); let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let err = actor2.decrypt(1).await.unwrap_err(); let err = actor2.decrypt(1).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped)); assert!(matches!(err, Error::Sealed));
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_unseal_correct_password() { async fn test_unseal_correct_password() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"survive a restart"; let plaintext = b"survive a restart";
let aead_id = actor let aead_id = actor
@@ -91,7 +103,9 @@ async fn test_unseal_correct_password() {
.unwrap(); .unwrap();
drop(actor); drop(actor);
let mut actor = KeyHolder::new(db.clone()).await.unwrap(); let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(seal_key).await.unwrap(); actor.try_unseal(seal_key).await.unwrap();
@@ -103,7 +117,7 @@ async fn test_unseal_correct_password() {
#[test_log::test] #[test_log::test]
async fn test_unseal_wrong_then_correct_password() { async fn test_unseal_wrong_then_correct_password() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"important data"; let plaintext = b"important data";
let aead_id = actor let aead_id = actor
@@ -112,7 +126,9 @@ async fn test_unseal_wrong_then_correct_password() {
.unwrap(); .unwrap();
drop(actor); drop(actor);
let mut actor = KeyHolder::new(db.clone()).await.unwrap(); let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let bad_key = SafeCell::new(b"wrong-password".to_vec()); let bad_key = SafeCell::new(b"wrong-password".to_vec());
let err = actor.try_unseal(bad_key).await.unwrap_err(); let err = actor.try_unseal(bad_key).await.unwrap_err();

View File

@@ -1,11 +1,12 @@
use std::collections::HashSet; use std::collections::HashSet;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{ use arbiter_server::{
actors::keyholder::Error, actors::vault::Error,
crypto::encryption::v1::Nonce, crypto::encryption::v1::Nonce,
db::{self, models, schema}, db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::update}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::update};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -15,7 +16,7 @@ use crate::common;
#[test_log::test] #[test_log::test]
async fn test_create_decrypt_roundtrip() { async fn test_create_decrypt_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"hello arbiter"; let plaintext = b"hello arbiter";
let aead_id = actor let aead_id = actor
@@ -31,7 +32,7 @@ async fn test_create_decrypt_roundtrip() {
#[test_log::test] #[test_log::test]
async fn test_decrypt_nonexistent_returns_not_found() { async fn test_decrypt_nonexistent_returns_not_found() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let err = actor.decrypt(9999).await.unwrap_err(); let err = actor.decrypt(9999).await.unwrap_err();
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
@@ -41,7 +42,7 @@ async fn test_decrypt_nonexistent_returns_not_found() {
#[test_log::test] #[test_log::test]
async fn test_ciphertext_differs_across_entries() { async fn test_ciphertext_differs_across_entries() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"same content"; let plaintext = b"same content";
let id1 = actor let id1 = actor
@@ -79,7 +80,7 @@ async fn test_ciphertext_differs_across_entries() {
#[test_log::test] #[test_log::test]
async fn test_nonce_never_reused() { async fn test_nonce_never_reused() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let n = 5; let n = 5;
for i in 0..n { for i in 0..n {
@@ -123,7 +124,7 @@ async fn test_nonce_never_reused() {
#[test_log::test] #[test_log::test]
async fn broken_db_nonce_format_fails_closed() { async fn broken_db_nonce_format_fails_closed() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let root_key_history_id = common::root_key_history_id(&db).await; let root_key_history_id = common::root_key_history_id(&db).await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -144,7 +145,7 @@ async fn broken_db_nonce_format_fails_closed() {
assert!(matches!(err, Error::BrokenDatabase)); assert!(matches!(err, Error::BrokenDatabase));
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_keyholder(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let id = actor let id = actor
.create_new(SafeCell::new(b"decrypt target".to_vec())) .create_new(SafeCell::new(b"decrypt target".to_vec()))
.await .await

View File

@@ -0,0 +1,3 @@
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust

View File

@@ -18,7 +18,6 @@ sealed class CalloutEvent with _$CalloutEvent {
required CalloutData data, required CalloutData data,
}) = CalloutEventAdded; }) = CalloutEventAdded;
const factory CalloutEvent.cancelled({ const factory CalloutEvent.cancelled({required String id}) =
required String id, CalloutEventCancelled;
}) = CalloutEventCancelled;
} }

View File

@@ -14,11 +14,8 @@ Future<void> showCallout(BuildContext context, WidgetRef ref, String id) async {
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 320), transitionDuration: const Duration(milliseconds: 320),
pageBuilder: (_, animation, _) => _CalloutOverlay( pageBuilder: (_, animation, _) =>
id: id, _CalloutOverlay(id: id, data: data, animation: animation),
data: data,
animation: animation,
),
); );
} }
@@ -35,22 +32,25 @@ class _CalloutOverlay extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
ref.listen( ref.listen(calloutManagerProvider.select((map) => map.containsKey(id)), (
calloutManagerProvider.select((map) => map.containsKey(id)), wasPresent,
(wasPresent, isPresent) { isPresent,
if (wasPresent == true && !isPresent && context.mounted) { ) {
Navigator.of(context).pop(); if (wasPresent == true && !isPresent && context.mounted) {
} Navigator.of(context).pop();
}, }
); });
final content = switch (data) { final content = switch (data) {
ConnectApprovalData(:final pubkey, :final clientInfo) => SdkConnectCallout( ConnectApprovalData(:final pubkey, :final clientInfo) =>
pubkey: pubkey, SdkConnectCallout(
clientInfo: clientInfo, pubkey: pubkey,
onAccept: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, true), clientInfo: clientInfo,
onDecline: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, false), onAccept: () =>
), ref.read(calloutManagerProvider.notifier).sendDecision(id, true),
onDecline: () =>
ref.read(calloutManagerProvider.notifier).sendDecision(id, false),
),
}; };
final barrierAnim = CurvedAnimation( final barrierAnim = CurvedAnimation(

View File

@@ -14,7 +14,8 @@ Future<void> showCalloutList(BuildContext context, WidgetRef ref) async {
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 280), transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, animation, __) => _CalloutListOverlay(animation: animation), pageBuilder: (_, animation, __) =>
_CalloutListOverlay(animation: animation),
); );
if (selectedId != null && context.mounted) { if (selectedId != null && context.mounted) {
@@ -51,7 +52,9 @@ class _CalloutListOverlay extends ConsumerWidget {
child: AnimatedBuilder( child: AnimatedBuilder(
animation: barrierAnim, animation: barrierAnim,
builder: (_, __) => ColoredBox( builder: (_, __) => ColoredBox(
color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value), color: Colors.black.withValues(
alpha: 0.35 * barrierAnim.value,
),
), ),
), ),
), ),

View File

@@ -50,7 +50,9 @@ class ArbiterUrl {
try { try {
return base64Url.decode(base64Url.normalize(cert)); return base64Url.decode(base64Url.normalize(cert));
} on FormatException catch (error) { } on FormatException catch (error) {
throw FormatException("Invalid base64 in 'cert' query parameter: ${error.message}"); throw FormatException(
"Invalid base64 in 'cert' query parameter: ${error.message}",
);
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import 'package:arbiter/features/identity/pk_manager.dart';
import 'package:arbiter/proto/arbiter.pbgrpc.dart'; import 'package:arbiter/proto/arbiter.pbgrpc.dart';
import 'package:arbiter/proto/user_agent/auth.pb.dart' as ua_auth; import 'package:arbiter/proto/user_agent/auth.pb.dart' as ua_auth;
import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/src/rust/api.dart';
import 'package:grpc/grpc.dart'; import 'package:grpc/grpc.dart';
import 'package:mtcore/markettakers.dart'; import 'package:mtcore/markettakers.dart';
@@ -61,11 +62,6 @@ Future<Connection> connectAndAuthorize(
final req = ua_auth.AuthChallengeRequest( final req = ua_auth.AuthChallengeRequest(
pubkey: pubkey, pubkey: pubkey,
bootstrapToken: bootstrapToken, bootstrapToken: bootstrapToken,
keyType: switch (key.alg) {
KeyAlgorithm.rsa => ua_auth.KeyType.KEY_TYPE_RSA,
KeyAlgorithm.ecdsa => ua_auth.KeyType.KEY_TYPE_ECDSA_SECP256K1,
KeyAlgorithm.ed25519 => ua_auth.KeyType.KEY_TYPE_ED25519,
},
); );
final response = await connection.ask( final response = await connection.ask(
UserAgentRequest(auth: ua_auth.Request(challengeRequest: req)), UserAgentRequest(auth: ua_auth.Request(challengeRequest: req)),
@@ -97,7 +93,10 @@ Future<Connection> connectAndAuthorize(
); );
} }
final challenge = _formatChallenge(authResponse.challenge, pubkey); final challenge = await formatChallenge(
random: authResponse.challenge.random,
timestamp: authResponse.challenge.timestampNanos.toInt(),
);
talker.info( talker.info(
'Received auth challenge, signing with key ${base64Encode(pubkey)}', 'Received auth challenge, signing with key ${base64Encode(pubkey)}',
); );
@@ -106,7 +105,9 @@ Future<Connection> connectAndAuthorize(
final solutionResponse = await connection.ask( final solutionResponse = await connection.ask(
UserAgentRequest( UserAgentRequest(
auth: ua_auth.Request( auth: ua_auth.Request(
challengeSolution: ua_auth.AuthChallengeSolution(signature: signature), challengeSolution: ua_auth.AuthChallengeSolution(
signature: signature,
),
), ),
), ),
); );
@@ -167,9 +168,3 @@ Future<Connection> _connect(StoredServerInfo serverInfo) async {
return Connection(channel: channel, tx: tx, rx: rx); return Connection(channel: channel, tx: tx, rx: rx);
} }
List<int> _formatChallenge(ua_auth.AuthChallenge challenge, List<int> pubkey) {
final encodedPubkey = base64Encode(pubkey);
final payload = "${challenge.nonce}:$encodedPubkey";
return utf8.encode(payload);
}

View File

@@ -85,7 +85,9 @@ class Connection {
if (response.hasId()) { if (response.hasId()) {
final completer = _pendingRequests.remove(response.id); final completer = _pendingRequests.remove(response.id);
if (completer == null) { if (completer == null) {
talker.warning('Received response for unknown request id ${response.id}'); talker.warning(
'Received response for unknown request id ${response.id}',
);
return; return;
} }
completer.complete(response); completer.complete(response);

View File

@@ -9,9 +9,7 @@ Future<List<WalletEntry>> listEvmWallets(Connection connection) async {
UserAgentRequest(evm: ua_evm.Request(walletList: Empty())), UserAgentRequest(evm: ua_evm.Request(walletList: Empty())),
); );
if (!response.hasEvm()) { if (!response.hasEvm()) {
throw Exception( throw Exception('Expected EVM response, got ${response.whichPayload()}');
'Expected EVM response, got ${response.whichPayload()}',
);
} }
final evmResponse = response.evm; final evmResponse = response.evm;
@@ -37,9 +35,7 @@ Future<void> createEvmWallet(Connection connection) async {
UserAgentRequest(evm: ua_evm.Request(walletCreate: Empty())), UserAgentRequest(evm: ua_evm.Request(walletCreate: Empty())),
); );
if (!response.hasEvm()) { if (!response.hasEvm()) {
throw Exception( throw Exception('Expected EVM response, got ${response.whichPayload()}');
'Expected EVM response, got ${response.whichPayload()}',
);
} }
final evmResponse = response.evm; final evmResponse = response.evm;

View File

@@ -10,9 +10,7 @@ Future<List<GrantEntry>> listEvmGrants(Connection connection) async {
UserAgentRequest(evm: ua_evm.Request(grantList: request)), UserAgentRequest(evm: ua_evm.Request(grantList: request)),
); );
if (!response.hasEvm()) { if (!response.hasEvm()) {
throw Exception( throw Exception('Expected EVM response, got ${response.whichPayload()}');
'Expected EVM response, got ${response.whichPayload()}',
);
} }
final evmResponse = response.evm; final evmResponse = response.evm;
@@ -50,9 +48,7 @@ Future<int> createEvmGrant(
final resp = await connection.ask(request); final resp = await connection.ask(request);
if (!resp.hasEvm()) { if (!resp.hasEvm()) {
throw Exception( throw Exception('Expected EVM response, got ${resp.whichPayload()}');
'Expected EVM response, got ${resp.whichPayload()}',
);
} }
final evmResponse = resp.evm; final evmResponse = resp.evm;
@@ -70,15 +66,11 @@ Future<int> createEvmGrant(
Future<void> deleteEvmGrant(Connection connection, int grantId) async { Future<void> deleteEvmGrant(Connection connection, int grantId) async {
final response = await connection.ask( final response = await connection.ask(
UserAgentRequest( UserAgentRequest(
evm: ua_evm.Request( evm: ua_evm.Request(grantDelete: EvmGrantDeleteRequest(grantId: grantId)),
grantDelete: EvmGrantDeleteRequest(grantId: grantId),
),
), ),
); );
if (!response.hasEvm()) { if (!response.hasEvm()) {
throw Exception( throw Exception('Expected EVM response, got ${response.whichPayload()}');
'Expected EVM response, got ${response.whichPayload()}',
);
} }
final evmResponse = response.evm; final evmResponse = response.evm;

View File

@@ -8,9 +8,7 @@ Future<Set<int>> readClientWalletAccess(
required int clientId, required int clientId,
}) async { }) async {
final response = await connection.ask( final response = await connection.ask(
UserAgentRequest( UserAgentRequest(sdkClient: ua_sdk.Request(listWalletAccess: Empty())),
sdkClient: ua_sdk.Request(listWalletAccess: Empty()),
),
); );
if (!response.hasSdkClient()) { if (!response.hasSdkClient()) {
throw Exception( throw Exception(
@@ -33,9 +31,7 @@ Future<List<ua_sdk.WalletAccessEntry>> listAllWalletAccesses(
Connection connection, Connection connection,
) async { ) async {
final response = await connection.ask( final response = await connection.ask(
UserAgentRequest( UserAgentRequest(sdkClient: ua_sdk.Request(listWalletAccess: Empty())),
sdkClient: ua_sdk.Request(listWalletAccess: Empty()),
),
); );
if (!response.hasSdkClient()) { if (!response.hasSdkClient()) {
throw Exception( throw Exception(
@@ -81,9 +77,7 @@ Future<void> writeClientWalletAccess(
UserAgentRequest( UserAgentRequest(
sdkClient: ua_sdk.Request( sdkClient: ua_sdk.Request(
revokeWalletAccess: ua_sdk.RevokeWalletAccess( revokeWalletAccess: ua_sdk.RevokeWalletAccess(
accesses: [ accesses: [for (final walletId in toRevoke) walletId],
for (final walletId in toRevoke) walletId,
],
), ),
), ),
), ),

View File

@@ -17,9 +17,9 @@ class StoredServerInfo {
final int port; final int port;
final String caCertFingerprint; final String caCertFingerprint;
factory StoredServerInfo.fromJson(Map<String, dynamic> json) => _$StoredServerInfoFromJson(json); factory StoredServerInfo.fromJson(Map<String, dynamic> json) =>
_$StoredServerInfoFromJson(json);
Map<String, dynamic> toJson() => _$StoredServerInfoToJson(this); Map<String, dynamic> toJson() => _$StoredServerInfoToJson(this);
} }
abstract class ServerInfoStorage { abstract class ServerInfoStorage {

View File

@@ -1,5 +1,6 @@
import 'package:arbiter/features/connection/connection.dart'; import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/proto/user_agent/vault/bootstrap.pb.dart' as ua_bootstrap; import 'package:arbiter/proto/user_agent/vault/bootstrap.pb.dart'
as ua_bootstrap;
import 'package:arbiter/proto/user_agent/vault/unseal.pb.dart' as ua_unseal; import 'package:arbiter/proto/user_agent/vault/unseal.pb.dart' as ua_unseal;
import 'package:arbiter/proto/user_agent/vault/vault.pb.dart' as ua_vault; import 'package:arbiter/proto/user_agent/vault/vault.pb.dart' as ua_vault;
import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/proto/user_agent.pb.dart';
@@ -27,9 +28,7 @@ Future<ua_bootstrap.BootstrapResult> bootstrapVault(
), ),
); );
if (!response.hasVault()) { if (!response.hasVault()) {
throw Exception( throw Exception('Expected vault response, got ${response.whichPayload()}');
'Expected vault response, got ${response.whichPayload()}',
);
} }
final vaultResponse = response.vault; final vaultResponse = response.vault;

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