8 Commits

Author SHA1 Message Date
hdbg
f709650bb1 refactor(server::{user_agent, client}): move auth part to separate function to not to pollute actor session with one-time concerns
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-03-01 23:35:22 +01:00
hdbg
004b14a168 refactor(transport): convert Bi trait to use async_trait 2026-03-01 13:18:48 +01:00
hdbg
4169b2ba42 refactor: consolidate auth messages into client and user_agent packages
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-03-01 12:49:38 +01:00
hdbg
3cc63474a8 chore(server): update Cargo.lock dependencies
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-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-02-26 22:41:50 +01:00
hdbg
3478204b9f tests(user-agent): basic auth tests similar to server 2026-02-26 22:41:24 +01:00
hdbg
61c65ddbcb feat(useragent): initial connection impl 2026-02-26 22:22:44 +01:00
hdbg
3401205cbd refactor(transport): simplify converters 2026-02-26 21:42:24 +01:00
hdbg
1799aef6f8 refactor(transport): implemented Bi stream based abstraction for actor communication with next loop override 2026-02-26 19:22:33 +01:00
37 changed files with 2222 additions and 1695 deletions

View File

@@ -1,178 +0,0 @@
{
"configVersion": 2,
"packages": [
{
"name": "async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "boolean_selector",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "characters",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "clock",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "collection",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "cupertino_icons",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "fake_async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "flutter",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "flutter_lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "flutter_test",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "leak_tracker",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "leak_tracker_flutter_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "leak_tracker_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "matcher",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "material_color_utilities",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1",
"packageUri": "lib/",
"languageVersion": "2.17"
},
{
"name": "meta",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "path",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "sky_engine",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "source_span",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "stack_trace",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "stream_channel",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "string_scanner",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "term_glyph",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "test_api",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "vector_math",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "vm_service",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "app",
"rootUri": "../",
"packageUri": "lib/",
"languageVersion": "3.10"
}
],
"generator": "pub",
"generatorVersion": "3.10.8",
"flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable",
"flutterVersion": "3.38.9",
"pubCache": "file:///Users/kaska/.pub-cache"
}

View File

@@ -1,230 +0,0 @@
{
"roots": [
"app"
],
"packages": [
{
"name": "app",
"version": "1.0.0+1",
"dependencies": [
"cupertino_icons",
"flutter"
],
"devDependencies": [
"flutter_lints",
"flutter_test"
]
},
{
"name": "flutter_lints",
"version": "6.0.0",
"dependencies": [
"lints"
]
},
{
"name": "flutter_test",
"version": "0.0.0",
"dependencies": [
"clock",
"collection",
"fake_async",
"flutter",
"leak_tracker_flutter_testing",
"matcher",
"meta",
"path",
"stack_trace",
"stream_channel",
"test_api",
"vector_math"
]
},
{
"name": "cupertino_icons",
"version": "1.0.8",
"dependencies": []
},
{
"name": "flutter",
"version": "0.0.0",
"dependencies": [
"characters",
"collection",
"material_color_utilities",
"meta",
"sky_engine",
"vector_math"
]
},
{
"name": "lints",
"version": "6.1.0",
"dependencies": []
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "meta",
"version": "1.17.0",
"dependencies": []
},
{
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "leak_tracker_flutter_testing",
"version": "3.0.10",
"dependencies": [
"flutter",
"leak_tracker",
"leak_tracker_testing",
"matcher",
"meta"
]
},
{
"name": "vector_math",
"version": "2.2.0",
"dependencies": []
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "clock",
"version": "1.1.2",
"dependencies": []
},
{
"name": "fake_async",
"version": "1.3.3",
"dependencies": [
"clock",
"collection"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "matcher",
"version": "0.12.17",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "test_api",
"version": "0.7.7",
"dependencies": [
"async",
"boolean_selector",
"collection",
"meta",
"source_span",
"stack_trace",
"stream_channel",
"string_scanner",
"term_glyph"
]
},
{
"name": "sky_engine",
"version": "0.0.0",
"dependencies": []
},
{
"name": "material_color_utilities",
"version": "0.11.1",
"dependencies": [
"collection"
]
},
{
"name": "characters",
"version": "1.4.0",
"dependencies": []
},
{
"name": "async",
"version": "2.13.0",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "leak_tracker_testing",
"version": "3.0.2",
"dependencies": [
"leak_tracker",
"matcher",
"meta"
]
},
{
"name": "leak_tracker",
"version": "11.0.2",
"dependencies": [
"clock",
"collection",
"meta",
"path",
"vm_service"
]
},
{
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"source_span",
"string_scanner"
]
},
{
"name": "vm_service",
"version": "15.0.2",
"dependencies": []
}
],
"configVersion": 1
}

View File

@@ -1 +0,0 @@
3.38.9

View File

@@ -1,11 +0,0 @@
// This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable"
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

View File

@@ -2,7 +2,6 @@ syntax = "proto3";
package arbiter; package arbiter;
import "auth.proto";
import "client.proto"; import "client.proto";
import "user_agent.proto"; import "user_agent.proto";
@@ -12,6 +11,6 @@ message ServerInfo {
} }
service ArbiterService { service ArbiterService {
rpc Client(stream ClientRequest) returns (stream ClientResponse); rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
rpc UserAgent(stream UserAgentRequest) returns (stream UserAgentResponse); rpc UserAgent(stream arbiter.user_agent.UserAgentRequest) returns (stream arbiter.user_agent.UserAgentResponse);
} }

View File

@@ -1,35 +0,0 @@
syntax = "proto3";
package arbiter.auth;
import "google/protobuf/timestamp.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
message AuthOk {}
message ClientMessage {
oneof payload {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
}
}
message ServerMessage {
oneof payload {
AuthChallenge auth_challenge = 1;
AuthOk auth_ok = 2;
}
}

View File

@@ -1,17 +1,32 @@
syntax = "proto3"; syntax = "proto3";
package arbiter; package arbiter.client;
import "auth.proto"; message AuthChallengeRequest {
bytes pubkey = 1;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
message AuthOk {}
message ClientRequest { message ClientRequest {
oneof payload { oneof payload {
arbiter.auth.ClientMessage auth_message = 1; AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
} }
} }
message ClientResponse { message ClientResponse {
oneof payload { oneof payload {
arbiter.auth.ServerMessage auth_message = 1; AuthChallenge auth_challenge = 1;
AuthOk auth_ok = 2;
} }
} }

View File

@@ -1,10 +1,25 @@
syntax = "proto3"; syntax = "proto3";
package arbiter; package arbiter.user_agent;
import "auth.proto";
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
message AuthOk {}
message UnsealStart { message UnsealStart {
bytes client_pubkey = 1; bytes client_pubkey = 1;
} }
@@ -35,17 +50,19 @@ enum VaultState {
message UserAgentRequest { message UserAgentRequest {
oneof payload { oneof payload {
arbiter.auth.ClientMessage auth_message = 1; AuthChallengeRequest auth_challenge_request = 1;
UnsealStart unseal_start = 2; AuthChallengeSolution auth_challenge_solution = 2;
UnsealEncryptedKey unseal_encrypted_key = 3; UnsealStart unseal_start = 3;
google.protobuf.Empty query_vault_state = 4; UnsealEncryptedKey unseal_encrypted_key = 4;
google.protobuf.Empty query_vault_state = 5;
} }
} }
message UserAgentResponse { message UserAgentResponse {
oneof payload { oneof payload {
arbiter.auth.ServerMessage auth_message = 1; AuthChallenge auth_challenge = 1;
UnsealStartResponse unseal_start_response = 2; AuthOk auth_ok = 2;
UnsealResult unseal_result = 3; UnsealStartResponse unseal_start_response = 3;
VaultState vault_state = 4; UnsealResult unseal_result = 4;
VaultState vault_state = 5;
} }
} }

196
server/Cargo.lock generated
View File

@@ -47,9 +47,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.101" version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "arbiter-client" name = "arbiter-client"
@@ -59,6 +59,7 @@ version = "0.1.0"
name = "arbiter-proto" name = "arbiter-proto"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"base64", "base64",
"futures", "futures",
"kameo", "kameo",
@@ -120,10 +121,15 @@ name = "arbiter-useragent"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arbiter-proto", "arbiter-proto",
"async-trait",
"ed25519-dalek", "ed25519-dalek",
"http",
"kameo", "kameo",
"rustls-webpki",
"smlang", "smlang",
"thiserror",
"tokio", "tokio",
"tokio-stream",
"tonic", "tonic",
"tracing", "tracing",
"x25519-dalek", "x25519-dalek",
@@ -166,7 +172,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
"synstructure", "synstructure",
] ]
@@ -178,7 +184,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -189,7 +195,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -206,9 +212,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.15.4" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"untrusted 0.7.1", "untrusted 0.7.1",
@@ -344,18 +350,18 @@ dependencies = [
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.11.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]] [[package]]
name = "bytes" name = "bytes"
@@ -418,9 +424,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.43" version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@@ -514,9 +520,9 @@ dependencies = [
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211f05e03c7d03754740fd9e585de910a095d6b99f8bcfffdef8319fa02a8331" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [ dependencies = [
"hybrid-array", "hybrid-array",
] ]
@@ -545,7 +551,7 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.11.0", "digest 0.11.1",
"fiat-crypto 0.3.0", "fiat-crypto 0.3.0",
"rustc_version", "rustc_version",
"subtle", "subtle",
@@ -560,7 +566,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -584,7 +590,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -595,7 +601,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -634,9 +640,9 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.6" version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
] ]
@@ -682,7 +688,7 @@ dependencies = [
"dsl_auto_type", "dsl_auto_type",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -702,7 +708,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
dependencies = [ dependencies = [
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -718,12 +724,12 @@ dependencies = [
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.11.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bf3682cdec91817be507e4aa104314898b95b84d74f3d43882210101a545b6" checksum = "285743a676ccb6b3e116bc14cc69319b957867930ae9c4822f8e0f54509d7243"
dependencies = [ dependencies = [
"block-buffer 0.11.0", "block-buffer 0.12.0",
"crypto-common 0.2.0", "crypto-common 0.2.1",
] ]
[[package]] [[package]]
@@ -734,7 +740,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -754,7 +760,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -949,7 +955,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1409,9 +1415,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.85" version = "0.3.90"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -1441,7 +1447,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1474,9 +1480,9 @@ dependencies = [
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
@@ -1558,7 +1564,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1706,9 +1712,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "owo-colors" name = "owo-colors"
version = "4.2.3" version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
@@ -1788,7 +1794,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1848,7 +1854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1920,7 +1926,7 @@ dependencies = [
"pulldown-cmark", "pulldown-cmark",
"pulldown-cmark-to-cmark", "pulldown-cmark-to-cmark",
"regex", "regex",
"syn 2.0.115", "syn 2.0.117",
"tempfile", "tempfile",
] ]
@@ -1934,7 +1940,7 @@ dependencies = [
"itertools", "itertools",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1948,9 +1954,9 @@ dependencies = [
[[package]] [[package]]
name = "pulldown-cmark" name = "pulldown-cmark"
version = "0.13.0" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"memchr", "memchr",
@@ -2056,9 +2062,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.9" version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "relative-path" name = "relative-path"
@@ -2075,7 +2081,7 @@ dependencies = [
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2127,7 +2133,7 @@ dependencies = [
"regex", "regex",
"relative-path", "relative-path",
"rustc_version", "rustc_version",
"syn 2.0.115", "syn 2.0.117",
"unicode-ident", "unicode-ident",
] ]
@@ -2157,9 +2163,9 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
@@ -2170,9 +2176,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.36" version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
@@ -2267,7 +2273,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2300,7 +2306,7 @@ checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"digest 0.11.0", "digest 0.11.1",
] ]
[[package]] [[package]]
@@ -2437,7 +2443,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2480,9 +2486,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.115" version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2503,14 +2509,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.25.0" version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.1", "getrandom 0.4.1",
@@ -2547,7 +2553,7 @@ checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2577,7 +2583,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2656,7 +2662,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2730,18 +2736,18 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.0.8+spec-1.1.0" version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [ dependencies = [
"winnow", "winnow",
] ]
[[package]] [[package]]
name = "tonic" name = "tonic"
version = "0.14.4" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2771,21 +2777,21 @@ dependencies = [
[[package]] [[package]]
name = "tonic-build" name = "tonic-build"
version = "0.14.4" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6d8958ed3be404120ca43ffa0fb1e1fc7be214e96c8d33bd43a131b6eebc9e" checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734"
dependencies = [ dependencies = [
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
name = "tonic-prost" name = "tonic-prost"
version = "0.14.4" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f" checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
dependencies = [ dependencies = [
"bytes", "bytes",
"prost", "prost",
@@ -2794,16 +2800,16 @@ dependencies = [
[[package]] [[package]]
name = "tonic-prost-build" name = "tonic-prost-build"
version = "0.14.4" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65873ace111e90344b8973e94a1fc817c924473affff24629281f90daed1cd2e" checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a"
dependencies = [ dependencies = [
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
"prost-build", "prost-build",
"prost-types", "prost-types",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
"tempfile", "tempfile",
"tonic-build", "tonic-build",
] ]
@@ -2858,7 +2864,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -2920,9 +2926,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.23" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-linebreak" name = "unicode-linebreak"
@@ -3051,9 +3057,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.108" version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -3064,9 +3070,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.108" version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -3074,22 +3080,22 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.108" version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.108" version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -3171,7 +3177,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -3182,7 +3188,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -3413,7 +3419,7 @@ dependencies = [
"heck", "heck",
"indexmap", "indexmap",
"prettyplease", "prettyplease",
"syn 2.0.115", "syn 2.0.117",
"wasm-metadata", "wasm-metadata",
"wit-bindgen-core", "wit-bindgen-core",
"wit-component", "wit-component",
@@ -3429,7 +3435,7 @@ dependencies = [
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
"wit-bindgen-core", "wit-bindgen-core",
"wit-bindgen-rust", "wit-bindgen-rust",
] ]
@@ -3535,7 +3541,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
"synstructure", "synstructure",
] ]
@@ -3556,7 +3562,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
"synstructure", "synstructure",
] ]
@@ -3577,7 +3583,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -3610,7 +3616,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.115", "syn 2.0.117",
] ]
[[package]] [[package]]

View File

@@ -18,7 +18,7 @@ thiserror.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
base64 = "0.22.1" base64 = "0.22.1"
tracing.workspace = true tracing.workspace = true
async-trait.workspace = true
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14.3" tonic-prost-build = "0.14.3"

View File

@@ -11,7 +11,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.compile_protos( .compile_protos(
&[ &[
format!("{}/arbiter.proto", PROTOBUF_DIR), format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/auth.proto", PROTOBUF_DIR), format!("{}/user_agent.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR),
], ],
&[PROTOBUF_DIR.to_string()], &[PROTOBUF_DIR.to_string()],
) )

View File

@@ -3,13 +3,15 @@ pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD}; use base64::{Engine, prelude::BASE64_STANDARD};
use crate::proto::auth::AuthChallenge;
pub mod proto { pub mod proto {
tonic::include_proto!("arbiter"); tonic::include_proto!("arbiter");
pub mod auth { pub mod user_agent {
tonic::include_proto!("arbiter.auth"); tonic::include_proto!("arbiter.user_agent");
}
pub mod client {
tonic::include_proto!("arbiter.client");
} }
} }
@@ -28,7 +30,7 @@ pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
Ok(arbiter_home) Ok(arbiter_home)
} }
pub fn format_challenge(challenge: &AuthChallenge) -> Vec<u8> { pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
let concat_form = format!("{}:{}", challenge.nonce, BASE64_STANDARD.encode(&challenge.pubkey)); let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
concat_form.into_bytes().to_vec() concat_form.into_bytes()
} }

View File

@@ -1,371 +1,293 @@
//! Transport abstraction layer for bridging gRPC bidirectional streaming with kameo actors. //! Transport-facing abstractions for protocol/session code.
//! //!
//! This module provides a clean separation between the gRPC transport layer and business logic //! This module separates three concerns:
//! by modeling the connection as two linked kameo actors:
//! //!
//! - A **transport actor** ([`GrpcTransportActor`]) that owns the gRPC stream and channel, //! - protocol/session logic wants a small duplex interface ([`Bi`])
//! forwarding inbound messages to the business actor and outbound messages to the client. //! - transport adapters push concrete stream items to an underlying IO layer
//! - A **business logic actor** that receives inbound messages from the transport actor and //! - transport boundaries translate between protocol-facing and transport-facing
//! sends outbound messages back through the transport actor. //! item types via direction-specific converters
//! //!
//! The [`wire()`] function sets up bidirectional linking between the two actors, ensuring //! [`Bi`] is intentionally minimal and transport-agnostic:
//! that if either actor dies, the other is notified and can shut down gracefully. //! - [`Bi::recv`] yields inbound protocol messages
//! - [`Bi::send`] accepts outbound protocol/domain items
//! //!
//! # Terminology //! # Generic Ordering Rule
//! //!
//! - **InboundMessage**: a message received by the transport actor from the channel/socket //! This module uses a single convention consistently: when a type or trait is
//! and forwarded to the business actor. //! parameterized by protocol message directions, the generic parameters are
//! - **OutboundMessage**: a message produced by the business actor and sent to the transport //! declared as `Inbound` first, then `Outbound`.
//! actor to be forwarded to the channel/socket.
//! //!
//! # Architecture //! For [`Bi`], that means `Bi<Inbound, Outbound>`:
//! - `recv() -> Option<Inbound>`
//! - `send(Outbound)`
//!
//! For adapter types that are parameterized by direction-specific converters,
//! inbound-related converter parameters are declared before outbound-related
//! converter parameters.
//!
//! [`RecvConverter`] and [`SendConverter`] are infallible conversion traits used
//! by adapters to map between protocol-facing and transport-facing item types.
//! The traits themselves are not result-aware; adapters decide how transport
//! errors are handled before (or instead of) conversion.
//!
//! [`grpc::GrpcAdapter`] combines:
//! - a tonic inbound stream
//! - a Tokio sender for outbound transport items
//! - a [`RecvConverter`] for the receive path
//! - a [`SendConverter`] for the send path
//!
//! [`DummyTransport`] is a no-op implementation useful for tests and local actor
//! execution where no real network stream exists.
//!
//! # Component Interaction
//! //!
//! ```text //! ```text
//! gRPC Stream ──InboundMessage──▶ GrpcTransportActor ──tell(InboundMessage)──▶ BusinessActor //! inbound (network -> protocol)
//! ▲ │ //! ============================
//! └─tell(Result<OutboundMessage, _>)────┘ //!
//! //! tonic::Streaming<RecvTransport>
//! mpsc::Sender ──▶ Client //! -> grpc::GrpcAdapter::recv()
//! |
//! +--> on `Ok(item)`: RecvConverter::convert(RecvTransport) -> Inbound
//! +--> on `Err(status)`: log error and close stream (`None`)
//! -> Bi::recv()
//! -> protocol/session actor
//!
//! outbound (protocol -> network)
//! ==============================
//!
//! protocol/session actor
//! -> Bi::send(Outbound)
//! -> grpc::GrpcAdapter::send()
//! |
//! +--> SendConverter::convert(Outbound) -> SendTransport
//! -> Tokio mpsc::Sender<SendTransport>
//! -> tonic response stream
//! ``` //! ```
//! //!
//! # Example //! # Design Notes
//! //!
//! ```rust,ignore //! - `send()` returns [`Error`] only for transport delivery failures (for
//! let (tx, rx) = mpsc::channel(1000); //! example, when the outbound channel is closed).
//! let context = server_context.clone(); //! - [`grpc::GrpcAdapter`] logs tonic receive errors and treats them as stream
//! //! closure (`None`).
//! wire( //! - When protocol-facing and transport-facing types are identical, use
//! |transport_ref| MyBusinessActor::new(context, transport_ref), //! [`IdentityRecvConverter`] / [`IdentitySendConverter`].
//! |business_recipient, business_id| GrpcTransportActor {
//! sender: tx,
//! receiver: grpc_stream,
//! business_logic_actor: business_recipient,
//! business_logic_actor_id: business_id,
//! },
//! ).await;
//!
//! Ok(Response::new(ReceiverStream::new(rx)))
//! ```
use futures::{Stream, StreamExt}; use std::marker::PhantomData;
use kameo::{
Actor,
actor::{ActorRef, PreparedActor, Recipient, Spawn, WeakActorRef},
mailbox::Signal,
prelude::Message,
};
use tokio::{
select,
sync::mpsc::{self, error::SendError},
};
use tonic::{Status, Streaming};
use tracing::{debug, error};
/// A bidirectional stream abstraction for sans-io testing. use async_trait::async_trait;
///
/// Combines a [`Stream`] of incoming messages with the ability to [`send`](Bi::send) /// Errors returned by transport adapters implementing [`Bi`].
/// outgoing responses. This trait allows business logic to be tested without a real #[derive(thiserror::Error, Debug)]
/// gRPC connection by swapping in an in-memory implementation. pub enum Error {
/// #[error("Transport channel is closed")]
/// # Type Parameters ChannelClosed,
/// - `T`: `InboundMessage` received from the channel/socket (e.g., `UserAgentRequest`)
/// - `U`: `OutboundMessage` sent to the channel/socket (e.g., `UserAgentResponse`)
pub trait Bi<T, U>: Stream<Item = Result<T, Status>> + Send + Sync + 'static {
type Error;
fn send(
&mut self,
item: Result<U, Status>,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
} }
/// Concrete [`Bi`] implementation backed by a tonic gRPC [`Streaming`] and an [`mpsc::Sender`]. /// Minimal bidirectional transport abstraction used by protocol code.
/// ///
/// This is the production implementation used in gRPC service handlers. The `request_stream` /// `Bi<Inbound, Outbound>` models a duplex channel with:
/// receives messages from the client, and `response_sender` sends responses back. /// - inbound items of type `Inbound` read via [`Bi::recv`]
pub struct BiStream<T, U> { /// - outbound items of type `Outbound` written via [`Bi::send`]
pub request_stream: Streaming<T>, #[async_trait]
pub response_sender: mpsc::Sender<Result<U, Status>>, pub trait Bi<Inbound, Outbound>: Send + Sync + 'static {
async fn send(&mut self, item: Outbound) -> Result<(), Error>;
async fn recv(&mut self) -> Option<Inbound>;
} }
impl<T, U> Stream for BiStream<T, U> /// Converts transport-facing inbound items into protocol-facing inbound items.
where pub trait RecvConverter: Send + Sync + 'static {
T: Send + 'static, type Input;
U: Send + 'static, type Output;
{
type Item = Result<T, Status>;
fn poll_next( fn convert(&self, item: Self::Input) -> Self::Output;
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.request_stream.poll_next_unpin(cx)
}
} }
impl<T, U> Bi<T, U> for BiStream<T, U> /// Converts protocol/domain outbound items into transport-facing outbound items.
where pub trait SendConverter: Send + Sync + 'static {
T: Send + 'static, type Input;
U: Send + 'static, type Output;
{
type Error = SendError<Result<U, Status>>;
async fn send(&mut self, item: Result<U, Status>) -> Result<(), Self::Error> { fn convert(&self, item: Self::Input) -> Self::Output;
self.response_sender.send(item).await
}
} }
/// Marker trait for transport actors that can receive outbound messages of type `T`. /// A [`RecvConverter`] that forwards values unchanged.
/// pub struct IdentityRecvConverter<T> {
/// Implement this on your transport actor to indicate it can handle outbound messages _marker: PhantomData<T>,
/// produced by the business actor. Requires the actor to implement [`Message<Result<T, E>>`]
/// so business logic can forward responses via [`tell()`](ActorRef::tell).
///
/// # Example
///
/// ```rust,ignore
/// #[derive(Actor)]
/// struct MyTransportActor { /* ... */ }
///
/// impl Message<Result<MyResponse, MyError>> for MyTransportActor {
/// type Reply = ();
/// async fn handle(&mut self, msg: Result<MyResponse, MyError>, _ctx: &mut Context<Self, Self::Reply>) -> Self::Reply {
/// // forward outbound message to channel/socket
/// }
/// }
///
/// impl TransportActor<MyResponse, MyError> for MyTransportActor {}
/// ```
pub trait TransportActor<Outbound: Send + 'static, DomainError: Send + 'static>:
Actor + Send + Message<Result<Outbound, DomainError>>
{
} }
/// A kameo actor that bridges a gRPC bidirectional stream with a business logic actor. impl<T> IdentityRecvConverter<T> {
/// pub fn new() -> Self {
/// This actor owns the gRPC [`Streaming`] receiver and an [`mpsc::Sender`] for responses.
/// It multiplexes between its own mailbox (for outbound messages from the business actor)
/// and the gRPC stream (for inbound client messages) using [`tokio::select!`].
///
/// # Message Flow
///
/// - **Inbound**: Messages from the gRPC stream are forwarded to `business_logic_actor`
/// via [`tell()`](Recipient::tell).
/// - **Outbound**: The business actor sends `Result<Outbound, DomainError>` messages to this
/// actor, which forwards them through the `sender` channel to the gRPC response stream.
///
/// # Lifecycle
///
/// - If the business logic actor dies (detected via actor linking), this actor stops,
/// which closes the gRPC stream.
/// - If the gRPC stream closes or errors, this actor stops, which (via linking) notifies
/// the business actor.
/// - Error responses (`Err(DomainError)`) are forwarded to the client and then the actor stops,
/// closing the connection.
///
/// # Type Parameters
/// - `Outbound`: `OutboundMessage` sent to the client (e.g., `UserAgentResponse`)
/// - `Inbound`: `InboundMessage` received from the client (e.g., `UserAgentRequest`)
/// - `E`: The domain error type, must implement `Into<tonic::Status>` for gRPC conversion
pub struct GrpcTransportActor<Outbound, Inbound, DomainError>
where
Outbound: Send + 'static,
Inbound: Send + 'static,
DomainError: Into<tonic::Status> + Send + 'static,
{
sender: mpsc::Sender<Result<Outbound, tonic::Status>>,
receiver: tonic::Streaming<Inbound>,
business_logic_actor: Recipient<Inbound>,
_error: std::marker::PhantomData<DomainError>,
}
impl<Outbound, Inbound, DomainError> GrpcTransportActor<Outbound, Inbound, DomainError>
where
Outbound: Send + 'static,
Inbound: Send + 'static,
DomainError: Into<tonic::Status> + Send + 'static,
{
pub fn new(
sender: mpsc::Sender<Result<Outbound, tonic::Status>>,
receiver: tonic::Streaming<Inbound>,
business_logic_actor: Recipient<Inbound>,
) -> Self {
Self { Self {
sender, _marker: PhantomData,
receiver,
business_logic_actor,
_error: std::marker::PhantomData,
} }
} }
} }
impl<Outbound, Inbound, E> Actor for GrpcTransportActor<Outbound, Inbound, E> impl<T> Default for IdentityRecvConverter<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> RecvConverter for IdentityRecvConverter<T>
where where
Outbound: Send + 'static, T: Send + Sync + 'static,
Inbound: Send + 'static,
E: Into<tonic::Status> + Send + 'static,
{ {
type Args = Self; type Input = T;
type Output = T;
type Error = (); fn convert(&self, item: Self::Input) -> Self::Output {
item
}
}
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> { /// A [`SendConverter`] that forwards values unchanged.
Ok(args) pub struct IdentitySendConverter<T> {
_marker: PhantomData<T>,
}
impl<T> IdentitySendConverter<T> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<T> Default for IdentitySendConverter<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> SendConverter for IdentitySendConverter<T>
where
T: Send + Sync + 'static,
{
type Input = T;
type Output = T;
fn convert(&self, item: Self::Input) -> Self::Output {
item
}
}
/// gRPC-specific transport adapters and helpers.
pub mod grpc {
use async_trait::async_trait;
use futures::StreamExt;
use tokio::sync::mpsc;
use tonic::Streaming;
use super::{Bi, Error, RecvConverter, SendConverter};
/// [`Bi`] adapter backed by a tonic gRPC bidirectional stream.
///
/// Tonic receive errors are logged and treated as stream closure (`None`).
/// The receive converter is only invoked for successful inbound transport
/// items.
pub struct GrpcAdapter<InboundConverter, OutboundConverter>
where
InboundConverter: RecvConverter,
OutboundConverter: SendConverter,
{
sender: mpsc::Sender<OutboundConverter::Output>,
receiver: Streaming<InboundConverter::Input>,
inbound_converter: InboundConverter,
outbound_converter: OutboundConverter,
} }
fn on_link_died( impl<InboundTransport, Inbound, InboundConverter, OutboundConverter>
&mut self, GrpcAdapter<InboundConverter, OutboundConverter>
_: WeakActorRef<Self>, where
id: kameo::prelude::ActorId, InboundConverter: RecvConverter<Input = InboundTransport, Output = Inbound>,
_: kameo::prelude::ActorStopReason, OutboundConverter: SendConverter,
) -> impl Future< {
Output = Result<std::ops::ControlFlow<kameo::prelude::ActorStopReason>, Self::Error>, pub fn new(
> + Send { sender: mpsc::Sender<OutboundConverter::Output>,
async move { receiver: Streaming<InboundTransport>,
if id == self.business_logic_actor.id() { inbound_converter: InboundConverter,
error!("Business logic actor died, stopping GrpcTransportActor"); outbound_converter: OutboundConverter,
Ok(std::ops::ControlFlow::Break( ) -> Self {
kameo::prelude::ActorStopReason::Normal, Self {
)) sender,
} else { receiver,
debug!( inbound_converter,
"Linked actor {} died, but it's not the business logic actor, ignoring", outbound_converter,
id
);
Ok(std::ops::ControlFlow::Continue(()))
} }
} }
} }
async fn next( #[async_trait]
&mut self, impl<InboundConverter, OutboundConverter> Bi<InboundConverter::Output, OutboundConverter::Input>
_: WeakActorRef<Self>, for GrpcAdapter<InboundConverter, OutboundConverter>
mailbox_rx: &mut kameo::prelude::MailboxReceiver<Self>, where
) -> Option<kameo::mailbox::Signal<Self>> { InboundConverter: RecvConverter,
select! { OutboundConverter: SendConverter,
msg = mailbox_rx.recv() => { OutboundConverter::Input: Send + 'static,
msg OutboundConverter::Output: Send + 'static,
} {
recv_msg = self.receiver.next() => { #[tracing::instrument(level = "trace", skip(self, item))]
match recv_msg { async fn send(&mut self, item: OutboundConverter::Input) -> Result<(), Error> {
Some(Ok(msg)) => { let outbound = self.outbound_converter.convert(item);
match self.business_logic_actor.tell(msg).await { self.sender
Ok(_) => None, .send(outbound)
Err(e) => { .await
// TODO: this would probably require better error handling - or resending if backpressure is the issue .map_err(|_| Error::ChannelClosed)
error!("Failed to send message to business logic actor: {}", e); }
Some(Signal::Stop)
} #[tracing::instrument(level = "trace", skip(self))]
} async fn recv(&mut self) -> Option<InboundConverter::Output> {
} match self.receiver.next().await {
Some(Err(e)) => { Some(Ok(item)) => Some(self.inbound_converter.convert(item)),
error!("Received error from stream: {}, stopping GrpcTransportActor", e); Some(Err(error)) => {
Some(Signal::Stop) tracing::error!(error = ?error, "grpc transport recv failed; closing stream");
} None
None => {
error!("Receiver channel closed, stopping GrpcTransportActor");
Some(Signal::Stop)
}
} }
None => None,
} }
} }
} }
} }
impl<Outbound, Inbound, E> Message<Result<Outbound, E>> for GrpcTransportActor<Outbound, Inbound, E> /// No-op [`Bi`] transport for tests and manual actor usage.
where ///
Outbound: Send + 'static, /// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore
Inbound: Send + 'static, /// does not busy-wait or spuriously close the stream.
E: Into<tonic::Status> + Send + 'static, pub struct DummyTransport<Inbound, Outbound> {
{ _marker: PhantomData<(Inbound, Outbound)>,
type Reply = (); }
async fn handle( impl<Inbound, Outbound> DummyTransport<Inbound, Outbound> {
&mut self, pub fn new() -> Self {
msg: Result<Outbound, E>, Self {
ctx: &mut kameo::prelude::Context<Self, Self::Reply>, _marker: PhantomData,
) -> Self::Reply {
let is_err = msg.is_err();
let grpc_msg = msg.map_err(Into::into);
match self.sender.send(grpc_msg).await {
Ok(_) => {
if is_err {
ctx.stop();
}
}
Err(e) => {
error!("Failed to send message: {}", e);
ctx.stop();
}
} }
} }
} }
impl<Outbound, Inbound, E> TransportActor<Outbound, E> for GrpcTransportActor<Outbound, Inbound, E> impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> {
where fn default() -> Self {
Outbound: Send + 'static, Self::new()
Inbound: Send + 'static, }
E: Into<tonic::Status> + Send + 'static,
{
} }
/// Wires together a transport actor and a business logic actor with bidirectional linking. #[async_trait]
/// impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound>
/// This function handles the chicken-and-egg problem of two actors that need references
/// to each other at construction time. It uses kameo's [`PreparedActor`] to obtain
/// [`ActorRef`]s before spawning, then links both actors so that if either dies,
/// the other is notified via [`on_link_died`](Actor::on_link_died).
///
/// The business actor receives a type-erased [`Recipient<Result<Outbound, DomainError>>`] instead of an
/// `ActorRef<Transport>`, keeping it decoupled from the concrete transport implementation.
///
/// # Type Parameters
/// - `Transport`: The transport actor type (e.g., [`GrpcTransportActor`])
/// - `Inbound`: `InboundMessage` received by the business actor from the transport
/// - `Outbound`: `OutboundMessage` sent by the business actor back to the transport
/// - `Business`: The business logic actor
/// - `BusinessCtor`: Closure that receives a prepared business actor and transport recipient,
/// spawns the business actor, and returns its [`ActorRef`]
/// - `TransportCtor`: Closure that receives a prepared transport actor, a recipient for
/// inbound messages, and the business actor id, then spawns the transport actor
///
/// # Returns
/// A tuple of `(transport_ref, business_ref)` — actor references for both spawned actors.
pub async fn wire<
Transport,
Inbound,
Outbound,
DomainError,
Business,
BusinessCtor,
TransportCtor,
>(
business_ctor: BusinessCtor,
transport_ctor: TransportCtor,
) -> (ActorRef<Transport>, ActorRef<Business>)
where where
Transport: TransportActor<Outbound, DomainError>, Inbound: Send + Sync + 'static,
Inbound: Send + 'static, Outbound: Send + Sync + 'static,
Outbound: Send + 'static,
DomainError: Send + 'static,
Business: Actor + Message<Inbound> + Send + 'static,
BusinessCtor: FnOnce(PreparedActor<Business>, Recipient<Result<Outbound, DomainError>>),
TransportCtor:
FnOnce(PreparedActor<Transport>, Recipient<Inbound>),
{ {
let prepared_business: PreparedActor<Business> = Spawn::prepare(); async fn send(&mut self, _item: Outbound) -> Result<(), Error> {
let prepared_transport: PreparedActor<Transport> = Spawn::prepare(); Ok(())
}
let business_ref = prepared_business.actor_ref().clone(); async fn recv(&mut self) -> Option<Inbound> {
let transport_ref = prepared_transport.actor_ref().clone(); std::future::pending::<()>().await;
None
transport_ref.link(&business_ref).await; }
business_ref.link(&transport_ref).await;
let recipient = transport_ref.clone().recipient();
business_ctor(prepared_business, recipient);
let business_recipient = business_ref.clone().recipient();
transport_ctor(prepared_transport, business_recipient);
(transport_ref, business_ref)
} }

View File

@@ -1,12 +0,0 @@
use arbiter_proto::{
proto::{ClientRequest, ClientResponse},
transport::Bi,
};
use crate::ServerContext;
pub(crate) async fn handle_client(
_context: ServerContext,
_bistream: impl Bi<ClientRequest, ClientResponse>,
) {
}

View File

@@ -0,0 +1,101 @@
use arbiter_proto::proto::client::{
AuthChallengeRequest, AuthChallengeSolution, ClientRequest,
client_request::Payload as ClientRequestPayload,
};
use ed25519_dalek::VerifyingKey;
use tracing::error;
use crate::actors::client::{
ConnectionProps,
auth::state::{AuthContext, AuthStateMachine},
session::ClientSession,
};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error {
#[error("Unexpected message payload")]
UnexpectedMessagePayload,
#[error("Invalid client public key length")]
InvalidClientPubkeyLength,
#[error("Invalid client public key encoding")]
InvalidAuthPubkeyEncoding,
#[error("Database pool unavailable")]
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Public key not registered")]
PublicKeyNotRegistered,
#[error("Invalid signature length")]
InvalidSignatureLength,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Transport error")]
Transport,
}
mod state;
use state::*;
fn parse_auth_event(payload: ClientRequestPayload) -> Result<AuthEvents, Error> {
match payload {
ClientRequestPayload::AuthChallengeRequest(AuthChallengeRequest { pubkey }) => {
let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?;
let pubkey = VerifyingKey::from_bytes(pubkey_bytes)
.map_err(|_| Error::InvalidAuthPubkeyEncoding)?;
Ok(AuthEvents::AuthRequest(ChallengeRequest {
pubkey: pubkey.into(),
}))
}
ClientRequestPayload::AuthChallengeSolution(AuthChallengeSolution { signature }) => {
Ok(AuthEvents::ReceivedSolution(ChallengeSolution {
solution: signature,
}))
}
}
}
pub async fn authenticate(props: &mut ConnectionProps) -> Result<VerifyingKey, Error> {
let mut state = AuthStateMachine::new(AuthContext::new(props));
loop {
let transport = state.context_mut().conn.transport.as_mut();
let Some(ClientRequest {
payload: Some(payload),
}) = transport.recv().await
else {
return Err(Error::Transport);
};
let event = parse_auth_event(payload)?;
match state.process_event(event).await {
Ok(AuthStates::AuthOk(key)) => return Ok(key.clone()),
Err(AuthError::ActionFailed(err)) => {
error!(?err, "State machine action failed");
return Err(err);
}
Err(AuthError::GuardFailed(err)) => {
error!(?err, "State machine guard failed");
return Err(err);
}
Err(AuthError::InvalidEvent) => {
error!("Invalid event for current state");
return Err(Error::InvalidChallengeSolution);
}
Err(AuthError::TransitionsFailed) => {
error!("Invalid state transition");
return Err(Error::InvalidChallengeSolution);
}
_ => (),
}
}
}
pub async fn authenticate_and_create(
mut props: ConnectionProps,
) -> Result<ClientSession, Error> {
let key = authenticate(&mut props).await?;
let session = ClientSession::new(props, key);
Ok(session)
}

View File

@@ -0,0 +1,136 @@
use arbiter_proto::proto::client::{
AuthChallenge, ClientResponse,
client_response::Payload as ClientResponsePayload,
};
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use ed25519_dalek::VerifyingKey;
use tracing::error;
use super::Error;
use crate::{actors::client::ConnectionProps, db::schema};
pub struct ChallengeRequest {
pub pubkey: VerifyingKey,
}
pub struct ChallengeContext {
pub challenge: AuthChallenge,
pub key: VerifyingKey,
}
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] / provide_key = AuthOk(VerifyingKey),
}
);
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::program_client::table
.filter(schema::program_client::public_key.eq(pubkey_bytes.to_vec()))
.select(schema::program_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::program_client::table)
.filter(schema::program_client::public_key.eq(pubkey_bytes.to_vec()))
.set(schema::program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
})
})
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
Error::PublicKeyNotRegistered
})
}
pub struct AuthContext<'a> {
pub(super) conn: &'a mut ConnectionProps,
}
impl<'a> AuthContext<'a> {
pub fn new(conn: &'a mut ConnectionProps) -> Self {
Self { conn }
}
}
impl AuthStateMachineContext for AuthContext<'_> {
type Error = Error;
async fn verify_solution(
&self,
ChallengeContext { challenge, key }: &ChallengeContext,
ChallengeSolution { solution }: &ChallengeSolution,
) -> Result<bool, Self::Error> {
let formatted_challenge =
arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey);
let signature = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid signature length");
Error::InvalidChallengeSolution
})?;
let valid = key.verify_strict(&formatted_challenge, &signature).is_ok();
Ok(valid)
}
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let nonce = create_nonce(&self.conn.db, pubkey.as_bytes()).await?;
let challenge = AuthChallenge {
pubkey: pubkey.as_bytes().to_vec(),
nonce,
};
self.conn
.transport
.send(Ok(ClientResponse {
payload: Some(ClientResponsePayload::AuthChallenge(challenge.clone())),
}))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge,
key: pubkey,
})
}
fn provide_key(
&mut self,
state_data: &ChallengeContext,
_: ChallengeSolution,
) -> Result<VerifyingKey, Self::Error> {
Ok(state_data.key)
}
}

View File

@@ -0,0 +1,48 @@
use arbiter_proto::{
proto::client::{ClientRequest, ClientResponse},
transport::Bi,
};
use kameo::actor::Spawn;
use tracing::{error, info};
use crate::{actors::client::session::ClientSession, db};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ClientError {
#[error("Expected message with payload")]
MissingRequestPayload,
#[error("Unexpected request payload")]
UnexpectedRequestPayload,
#[error("State machine error")]
StateTransitionFailed,
#[error(transparent)]
Auth(#[from] auth::Error),
}
pub type Transport = Box<dyn Bi<ClientRequest, Result<ClientResponse, ClientError>> + Send>;
pub struct ConnectionProps {
pub(crate) db: db::DatabasePool,
pub(crate) transport: Transport,
}
impl ConnectionProps {
pub fn new(db: db::DatabasePool, transport: Transport) -> Self {
Self { db, transport }
}
}
pub mod auth;
pub mod session;
pub async fn connect_client(props: ConnectionProps) {
match auth::authenticate_and_create(props).await {
Ok(session) => {
ClientSession::spawn(session);
info!("Client authenticated, session started");
}
Err(err) => {
error!(?err, "Authentication failed, closing connection");
}
}
}

View File

@@ -0,0 +1,90 @@
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
use ed25519_dalek::VerifyingKey;
use kameo::Actor;
use tokio::select;
use tracing::{error, info};
use crate::actors::client::{ClientError, ConnectionProps};
pub struct ClientSession {
props: ConnectionProps,
key: VerifyingKey,
}
impl ClientSession {
pub(crate) fn new(props: ConnectionProps, key: VerifyingKey) -> Self {
Self { props, key }
}
pub async fn process_transport_inbound(&mut self, req: ClientRequest) -> Output {
let msg = req.payload.ok_or_else(|| {
error!(actor = "client", "Received message with no payload");
ClientError::MissingRequestPayload
})?;
match msg {
_ => Err(ClientError::UnexpectedRequestPayload),
}
}
}
type Output = Result<ClientResponse, ClientError>;
impl Actor for ClientSession {
type Args = Self;
type Error = ();
async fn on_start(
args: Self::Args,
_: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
Ok(args)
}
async fn next(
&mut self,
_actor_ref: kameo::prelude::WeakActorRef<Self>,
mailbox_rx: &mut kameo::prelude::MailboxReceiver<Self>,
) -> Option<kameo::mailbox::Signal<Self>> {
loop {
select! {
signal = mailbox_rx.recv() => {
return signal;
}
msg = self.props.transport.recv() => {
match msg {
Some(request) => {
match self.process_transport_inbound(request).await {
Ok(resp) => {
if self.props.transport.send(Ok(resp)).await.is_err() {
error!(actor = "client", reason = "channel closed", "send.failed");
return Some(kameo::mailbox::Signal::Stop);
}
}
Err(err) => {
let _ = self.props.transport.send(Err(err)).await;
return Some(kameo::mailbox::Signal::Stop);
}
}
}
None => {
info!(actor = "client", "transport.closed");
return Some(kameo::mailbox::Signal::Stop);
}
}
}
}
}
}
}
impl ClientSession {
pub fn new_test(db: crate::db::DatabasePool) -> Self {
use arbiter_proto::transport::DummyTransport;
let transport: super::Transport = Box::new(DummyTransport::new());
let props = ConnectionProps::new(db, transport);
let key = VerifyingKey::from_bytes(&[0u8; 32]).unwrap();
Self { props, key }
}
}

View File

@@ -0,0 +1,118 @@
use arbiter_proto::proto::user_agent::{
AuthChallengeRequest, AuthChallengeSolution, UserAgentRequest,
user_agent_request::Payload as UserAgentRequestPayload,
};
use ed25519_dalek::VerifyingKey;
use tracing::error;
use crate::actors::user_agent::{
ConnectionProps,
auth::state::{AuthContext, AuthStateMachine}, session::UserAgentSession,
};
#[derive(thiserror::Error, Debug, PartialEq)]
pub enum Error {
#[error("Unexpected message payload")]
UnexpectedMessagePayload,
#[error("Invalid client public key length")]
InvalidClientPubkeyLength,
#[error("Invalid client public key encoding")]
InvalidAuthPubkeyEncoding,
#[error("Database pool unavailable")]
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Public key not registered")]
PublicKeyNotRegistered,
#[error("Transport error")]
Transport,
#[error("Invalid bootstrap token")]
InvalidBootstrapToken,
#[error("Bootstrapper actor unreachable")]
BootstrapperActorUnreachable,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
}
mod state;
use state::*;
fn parse_auth_event(payload: UserAgentRequestPayload) -> Result<AuthEvents, Error> {
match payload {
UserAgentRequestPayload::AuthChallengeRequest(AuthChallengeRequest {
pubkey,
bootstrap_token: None,
}) => {
let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?;
let pubkey = VerifyingKey::from_bytes(pubkey_bytes)
.map_err(|_| Error::InvalidAuthPubkeyEncoding)?;
Ok(AuthEvents::AuthRequest(ChallengeRequest {
pubkey: pubkey.into(),
}))
}
UserAgentRequestPayload::AuthChallengeRequest(AuthChallengeRequest {
pubkey,
bootstrap_token: Some(token),
}) => {
let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?;
let pubkey = VerifyingKey::from_bytes(pubkey_bytes)
.map_err(|_| Error::InvalidAuthPubkeyEncoding)?;
Ok(AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest {
pubkey: pubkey.into(),
token,
}))
}
UserAgentRequestPayload::AuthChallengeSolution(AuthChallengeSolution { signature }) => {
Ok(AuthEvents::ReceivedSolution(ChallengeSolution {
solution: signature,
}))
}
_ => Err(Error::UnexpectedMessagePayload),
}
}
pub async fn authenticate(props: &mut ConnectionProps) -> Result<VerifyingKey, Error> {
let mut state = AuthStateMachine::new(AuthContext::new(props));
loop {
// This is needed because `state` now holds mutable reference to `ConnectionProps`, so we can't directly access `props` here
let transport = state.context_mut().conn.transport.as_mut();
let Some(UserAgentRequest {
payload: Some(payload),
}) = transport.recv().await
else {
return Err(Error::Transport);
};
let event = parse_auth_event(payload)?;
match state.process_event(event).await {
Ok(AuthStates::AuthOk(key)) => return Ok(key.clone()),
Err(AuthError::ActionFailed(err)) => {
error!(?err, "State machine action failed");
return Err(err);
}
Err(AuthError::GuardFailed(err)) => {
error!(?err, "State machine guard failed");
return Err(err);
}
Err(AuthError::InvalidEvent) => {
error!("Invalid event for current state");
return Err(Error::InvalidChallengeSolution);
}
Err(AuthError::TransitionsFailed) => {
error!("Invalid state transition");
return Err(Error::InvalidChallengeSolution);
}
_ => (),
}
}
}
pub async fn authenticate_and_create(mut props: ConnectionProps) -> Result<UserAgentSession, Error> {
let key = authenticate(&mut props).await?;
let session = UserAgentSession::new(props, key.clone());
Ok(session)
}

View File

@@ -0,0 +1,202 @@
use arbiter_proto::proto::user_agent::{
AuthChallenge, UserAgentResponse,
user_agent_response::Payload as UserAgentResponsePayload,
};
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use ed25519_dalek::VerifyingKey;
use tracing::error;
use super::Error;
use crate::{
actors::{bootstrap::ConsumeToken, user_agent::ConnectionProps},
db::schema,
};
pub struct ChallengeRequest {
pub pubkey: VerifyingKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: VerifyingKey,
pub token: String,
}
pub struct ChallengeContext {
pub challenge: AuthChallenge,
pub key: VerifyingKey,
}
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] / provide_key_bootstrap = AuthOk(VerifyingKey),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) [async verify_solution] / provide_key = AuthOk(VerifyingKey),
}
);
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
})
})
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
Error::PublicKeyNotRegistered
})
}
async fn register_key(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<(), Error> {
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()),
schema::useragent_client::nonce.eq(1),
))
.execute(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})?;
Ok(())
}
pub struct AuthContext<'a> {
pub(super) conn: &'a mut ConnectionProps,
}
impl<'a> AuthContext<'a> {
pub fn new(conn: &'a mut ConnectionProps) -> Self {
Self { conn }
}
}
impl AuthStateMachineContext for AuthContext<'_> {
type Error = Error;
async fn verify_solution(
&self,
ChallengeContext { challenge, key }: &ChallengeContext,
ChallengeSolution { solution }: &ChallengeSolution,
) -> Result<bool, Self::Error> {
let formatted_challenge =
arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey);
let signature = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid signature length");
Error::InvalidChallengeSolution
})?;
let valid = key.verify_strict(&formatted_challenge, &signature).is_ok();
Ok(valid)
}
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let nonce = create_nonce(&self.conn.db, pubkey.as_bytes()).await?;
let challenge = AuthChallenge {
pubkey: pubkey.as_bytes().to_vec(),
nonce,
};
self.conn
.transport
.send(Ok(UserAgentResponse {
payload: Some(UserAgentResponsePayload::AuthChallenge(challenge.clone())),
}))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge,
key: pubkey,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
&self,
BootstrapAuthRequest { pubkey, token }: &BootstrapAuthRequest,
) -> Result<bool, Self::Error> {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.map_err(|e| {
error!(?pubkey, "Failed to consume bootstrap token: {e}");
Error::BootstrapperActorUnreachable
})?;
if !token_ok {
error!(?pubkey, "Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
register_key(&self.conn.db, pubkey.as_bytes()).await?;
Ok(true)
}
fn provide_key_bootstrap(
&mut self,
event_data: BootstrapAuthRequest,
) -> Result<VerifyingKey, Self::Error> {
Ok(event_data.pubkey)
}
fn provide_key(
&mut self,
state_data: &ChallengeContext,
_: ChallengeSolution,
) -> Result<VerifyingKey, Self::Error> {
Ok(state_data.key)
}
}

View File

@@ -1,57 +0,0 @@
use tonic::Status;
use crate::db;
#[derive(Debug, thiserror::Error)]
pub enum UserAgentError {
#[error("Missing payload in request")]
MissingPayload,
#[error("Invalid bootstrap token")]
InvalidBootstrapToken,
#[error("Public key not registered")]
PubkeyNotRegistered,
#[error("Invalid public key format")]
InvalidPubkey,
#[error("Invalid signature length")]
InvalidSignatureLength,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Invalid state for operation")]
InvalidState,
#[error("Actor unavailable")]
ActorUnavailable,
#[error("Database error")]
Database(#[from] diesel::result::Error),
#[error("Database pool error")]
DatabasePool(#[from] db::PoolError),
}
impl From<UserAgentError> for Status {
fn from(err: UserAgentError) -> Self {
match err {
UserAgentError::MissingPayload
| UserAgentError::InvalidBootstrapToken
| UserAgentError::InvalidPubkey
| UserAgentError::InvalidSignatureLength => Status::invalid_argument(err.to_string()),
UserAgentError::PubkeyNotRegistered | UserAgentError::InvalidChallengeSolution => {
Status::unauthenticated(err.to_string())
}
UserAgentError::InvalidState => Status::failed_precondition(err.to_string()),
UserAgentError::ActorUnavailable
| UserAgentError::Database(_)
| UserAgentError::DatabasePool(_) => Status::internal(err.to_string()),
}
}
}

View File

@@ -1,401 +1,60 @@
use std::{ops::DerefMut, sync::Mutex}; use arbiter_proto::{
proto::user_agent::{UserAgentRequest, UserAgentResponse},
use arbiter_proto::proto::{ transport::Bi,
UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest,
UserAgentResponse,
auth::{
self, AuthChallengeRequest, AuthOk, ClientMessage as ClientAuthMessage,
ServerMessage as AuthServerMessage,
client_message::Payload as ClientAuthPayload,
server_message::Payload as ServerAuthPayload,
},
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use kameo::actor::Spawn;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, dsl::update};
use diesel_async::RunQueryDsl;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, actor::Recipient, error::SendError, messages, prelude::Message};
use memsafe::MemSafe;
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::{ use crate::{actors::{GlobalActors, user_agent::session::UserAgentSession}, db};
ServerContext,
actors::{
GlobalActors,
bootstrap::ConsumeToken,
keyholder::{self, TryUnseal},
user_agent::state::{
ChallengeContext, DummyContext, UnsealContext, UserAgentEvents, UserAgentStateMachine,
UserAgentStates,
},
},
db::{self, schema},
};
mod error; #[derive(Debug, thiserror::Error, PartialEq)]
mod state; pub enum UserAgentError {
#[error("Expected message with payload")]
pub use error::UserAgentError; MissingRequestPayload,
#[error("Unexpected request payload")]
#[derive(Actor)] UnexpectedRequestPayload,
pub struct UserAgentActor { #[error("Invalid state for unseal encrypted key")]
db: db::DatabasePool, InvalidStateForUnsealEncryptedKey,
actors: GlobalActors, #[error("client_pubkey must be 32 bytes")]
state: UserAgentStateMachine<DummyContext>, InvalidClientPubkeyLength,
transport: Recipient<Result<UserAgentResponse, UserAgentError>>, #[error("State machine error")]
StateTransitionFailed,
#[error("Vault is not available")]
KeyHolderActorUnreachable,
#[error(transparent)]
Auth(#[from] auth::Error),
} }
impl UserAgentActor { pub type Transport =
pub(crate) fn new( Box<dyn Bi<UserAgentRequest, Result<UserAgentResponse, UserAgentError>> + Send>;
context: ServerContext,
transport: Recipient<Result<UserAgentResponse, UserAgentError>>,
) -> Self {
Self {
db: context.db.clone(),
actors: context.actors.clone(),
state: UserAgentStateMachine::new(DummyContext),
transport,
}
}
pub fn new_manual( pub struct ConnectionProps {
db: db::DatabasePool, db: db::DatabasePool,
actors: GlobalActors, actors: GlobalActors,
transport: Recipient<Result<UserAgentResponse, UserAgentError>>, transport: Transport,
) -> Self { }
impl ConnectionProps {
pub fn new(db: db::DatabasePool, actors: GlobalActors, transport: Transport) -> Self {
Self { Self {
db, db,
actors, actors,
state: UserAgentStateMachine::new(DummyContext),
transport, transport,
} }
} }
async fn process_request(&mut self, req: UserAgentRequest) -> Output {
let msg = req.payload.ok_or_else(|| {
error!(actor = "useragent", "Received message with no payload");
UserAgentError::MissingPayload
})?;
match msg {
UserAgentRequestPayload::AuthMessage(ClientAuthMessage {
payload: Some(ClientAuthPayload::AuthChallengeRequest(req)),
}) => self.handle_auth_challenge_request(req).await,
UserAgentRequestPayload::AuthMessage(ClientAuthMessage {
payload: Some(ClientAuthPayload::AuthChallengeSolution(solution)),
}) => self.handle_auth_challenge_solution(solution).await,
UserAgentRequestPayload::UnsealStart(unseal_start) => {
self.handle_unseal_request(unseal_start).await
}
UserAgentRequestPayload::UnsealEncryptedKey(unseal_encrypted_key) => {
self.handle_unseal_encrypted_key(unseal_encrypted_key).await
}
_ => Err(UserAgentError::MissingPayload),
}
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), UserAgentError> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
UserAgentError::InvalidState
})?;
Ok(())
}
async fn auth_with_bootstrap_token(
&mut self,
pubkey: ed25519_dalek::VerifyingKey,
token: String,
) -> Output {
let token_ok: bool = self
.actors
.bootstrapper
.ask(ConsumeToken { token })
.await
.map_err(|e| {
error!(?pubkey, "Failed to consume bootstrap token: {e}");
UserAgentError::ActorUnavailable
})?;
if !token_ok {
error!(?pubkey, "Invalid bootstrap token provided");
return Err(UserAgentError::InvalidBootstrapToken);
}
{
let mut conn = self.db.get().await?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey.as_bytes().to_vec()),
schema::useragent_client::nonce.eq(1),
))
.execute(&mut conn)
.await?;
}
self.transition(UserAgentEvents::ReceivedBootstrapToken)?;
Ok(auth_response(ServerAuthPayload::AuthOk(AuthOk {})))
}
async fn auth_with_challenge(&mut self, pubkey: VerifyingKey, pubkey_bytes: Vec<u8>) -> Output {
let nonce: Option<i32> = {
let mut db_conn = self.db.get().await?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(
schema::useragent_client::public_key.eq(pubkey.as_bytes().to_vec()),
)
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.filter(
schema::useragent_client::public_key.eq(pubkey.as_bytes().to_vec()),
)
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
})
})
.await
.optional()?
};
let Some(nonce) = nonce else {
error!(?pubkey, "Public key not found in database");
return Err(UserAgentError::PubkeyNotRegistered);
};
let challenge = auth::AuthChallenge {
pubkey: pubkey_bytes,
nonce,
};
self.transition(UserAgentEvents::SentChallenge(ChallengeContext {
challenge: challenge.clone(),
key: pubkey,
}))?;
info!(
?pubkey,
?challenge,
"Sent authentication challenge to client"
);
Ok(auth_response(ServerAuthPayload::AuthChallenge(challenge)))
}
fn verify_challenge_solution(
&self,
solution: &auth::AuthChallengeSolution,
) -> Result<(bool, &ChallengeContext), UserAgentError> {
let UserAgentStates::WaitingForChallengeSolution(challenge_context) = self.state.state()
else {
error!("Received challenge solution in invalid state");
return Err(UserAgentError::InvalidState);
};
let formatted_challenge = arbiter_proto::format_challenge(&challenge_context.challenge);
let signature = solution.signature.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid signature length");
UserAgentError::InvalidSignatureLength
})?;
let valid = challenge_context
.key
.verify_strict(&formatted_challenge, &signature)
.is_ok();
Ok((valid, challenge_context))
}
} }
type Output = Result<UserAgentResponse, UserAgentError>; pub mod session;
pub mod auth;
fn auth_response(payload: ServerAuthPayload) -> UserAgentResponse { pub async fn connect_user_agent(mut props: ConnectionProps) {
UserAgentResponse { match auth::authenticate_and_create( props).await {
payload: Some(UserAgentResponsePayload::AuthMessage(AuthServerMessage { Ok(session) => {
payload: Some(payload), UserAgentSession::spawn(session);
})), info!("User authenticated, session started");
} },
} Err(err) => {
error!(?err, "Authentication failed, closing connection");
fn unseal_response(payload: UserAgentResponsePayload) -> UserAgentResponse { },
UserAgentResponse {
payload: Some(payload),
}
}
#[messages]
impl UserAgentActor {
#[message]
pub async fn handle_unseal_request(&mut self, req: UnsealStart) -> Output {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
let client_pubkey_bytes: [u8; 32] = req
.client_pubkey
.try_into()
.map_err(|_| UserAgentError::InvalidPubkey)?;
let client_public_key = PublicKey::from(client_pubkey_bytes);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key,
}))?;
Ok(unseal_response(
UserAgentResponsePayload::UnsealStartResponse(UnsealStartResponse {
server_pubkey: public_key.as_bytes().to_vec(),
}),
))
}
#[message]
pub async fn handle_unseal_encrypted_key(&mut self, req: UnsealEncryptedKey) -> Output {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received unseal encrypted key in invalid state");
return Err(UserAgentError::InvalidState);
};
let ephemeral_secret = {
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");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Ok(unseal_response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)));
}
}
};
let nonce = XNonce::from_slice(&req.nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&unseal_context.client_public_key);
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
let mut seal_key_buffer = MemSafe::new(req.ciphertext.clone()).unwrap();
let decryption_result = {
let mut write_handle = seal_key_buffer.write().unwrap();
let write_handle = write_handle.deref_mut();
cipher.decrypt_in_place(nonce, &req.associated_data, write_handle)
};
match decryption_result {
Ok(_) => {
match self
.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(unseal_response(UserAgentResponsePayload::UnsealResult(
UnsealResult::Success.into(),
)))
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Ok(unseal_response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)))
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Ok(unseal_response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)))
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UserAgentError::ActorUnavailable)
}
}
}
Err(err) => {
error!(?err, "Failed to decrypt unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Ok(unseal_response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)))
}
}
}
#[message]
pub async fn handle_auth_challenge_request(&mut self, req: AuthChallengeRequest) -> Output {
let pubkey = req
.pubkey
.as_array()
.ok_or(UserAgentError::InvalidPubkey)?;
let pubkey = VerifyingKey::from_bytes(pubkey).map_err(|_err| {
error!(?pubkey, "Failed to convert to VerifyingKey");
UserAgentError::InvalidPubkey
})?;
self.transition(UserAgentEvents::AuthRequest)?;
match req.bootstrap_token {
Some(token) => self.auth_with_bootstrap_token(pubkey, token).await,
None => self.auth_with_challenge(pubkey, req.pubkey).await,
}
}
#[message]
pub async fn handle_auth_challenge_solution(
&mut self,
solution: auth::AuthChallengeSolution,
) -> Output {
let (valid, challenge_context) = self.verify_challenge_solution(&solution)?;
if valid {
info!(
?challenge_context,
"Client provided valid solution to authentication challenge"
);
self.transition(UserAgentEvents::ReceivedGoodSolution)?;
Ok(auth_response(ServerAuthPayload::AuthOk(AuthOk {})))
} else {
error!("Client provided invalid solution to authentication challenge");
self.transition(UserAgentEvents::ReceivedBadSolution)?;
Err(UserAgentError::InvalidChallengeSolution)
}
}
}
impl Message<UserAgentRequest> for UserAgentActor {
type Reply = ();
async fn handle(
&mut self,
msg: UserAgentRequest,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = self.process_request(msg).await;
if let Err(e) = self.transport.tell(result).await {
error!(actor = "useragent", "Failed to send response to transport: {}", e);
}
} }
} }

View File

@@ -0,0 +1,241 @@
use std::{ops::DerefMut, sync::Mutex};
use arbiter_proto::proto::user_agent::{
UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest,
UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, error::SendError};
use memsafe::MemSafe;
use tokio::select;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::{
keyholder::{self, TryUnseal},
user_agent::{ConnectionProps, UserAgentError},
};
mod state;
use state::{DummyContext, UnsealContext, UserAgentEvents, UserAgentStateMachine, UserAgentStates};
pub struct UserAgentSession {
props: ConnectionProps,
key: VerifyingKey,
state: UserAgentStateMachine<DummyContext>,
}
impl UserAgentSession {
pub(crate) fn new(props: ConnectionProps, key: VerifyingKey) -> Self {
Self {
props,
key,
state: UserAgentStateMachine::new(DummyContext),
}
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), UserAgentError> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
UserAgentError::StateTransitionFailed
})?;
Ok(())
}
pub async fn process_transport_inbound(&mut self, req: UserAgentRequest) -> Output {
let msg = req.payload.ok_or_else(|| {
error!(actor = "useragent", "Received message with no payload");
UserAgentError::MissingRequestPayload
})?;
match msg {
UserAgentRequestPayload::UnsealStart(unseal_start) => {
self.handle_unseal_request(unseal_start).await
}
UserAgentRequestPayload::UnsealEncryptedKey(unseal_encrypted_key) => {
self.handle_unseal_encrypted_key(unseal_encrypted_key).await
}
_ => Err(UserAgentError::UnexpectedRequestPayload),
}
}
}
type Output = Result<UserAgentResponse, UserAgentError>;
fn response(payload: UserAgentResponsePayload) -> UserAgentResponse {
UserAgentResponse {
payload: Some(payload),
}
}
impl UserAgentSession {
async fn handle_unseal_request(&mut self, req: UnsealStart) -> Output {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
let client_pubkey_bytes: [u8; 32] = req
.client_pubkey
.try_into()
.map_err(|_| UserAgentError::InvalidClientPubkeyLength)?;
let client_public_key = PublicKey::from(client_pubkey_bytes);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key,
}))?;
Ok(response(UserAgentResponsePayload::UnsealStartResponse(
UnsealStartResponse {
server_pubkey: public_key.as_bytes().to_vec(),
},
)))
}
async fn handle_unseal_encrypted_key(&mut self, req: UnsealEncryptedKey) -> Output {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received unseal encrypted key in invalid state");
return Err(UserAgentError::InvalidStateForUnsealEncryptedKey);
};
let ephemeral_secret = {
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");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Ok(response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)));
}
}
};
let nonce = XNonce::from_slice(&req.nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&unseal_context.client_public_key);
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
let mut seal_key_buffer = MemSafe::new(req.ciphertext.clone()).unwrap();
let decryption_result = {
let mut write_handle = seal_key_buffer.write().unwrap();
let write_handle = write_handle.deref_mut();
cipher.decrypt_in_place(nonce, &req.associated_data, write_handle)
};
match decryption_result {
Ok(_) => {
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(response(UserAgentResponsePayload::UnsealResult(
UnsealResult::Success.into(),
)))
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Ok(response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)))
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Ok(response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)))
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UserAgentError::KeyHolderActorUnreachable)
}
}
}
Err(err) => {
error!(?err, "Failed to decrypt unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Ok(response(UserAgentResponsePayload::UnsealResult(
UnsealResult::InvalidKey.into(),
)))
}
}
}
}
impl Actor for UserAgentSession {
type Args = Self;
type Error = ();
async fn on_start(
args: Self::Args,
_: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
Ok(args)
}
async fn next(
&mut self,
_actor_ref: kameo::prelude::WeakActorRef<Self>,
mailbox_rx: &mut kameo::prelude::MailboxReceiver<Self>,
) -> Option<kameo::mailbox::Signal<Self>> {
loop {
select! {
signal = mailbox_rx.recv() => {
return signal;
}
msg = self.props.transport.recv() => {
match msg {
Some(request) => {
match self.process_transport_inbound(request).await {
Ok(response) => {
if self.props.transport.send(Ok(response)).await.is_err() {
error!(actor = "useragent", reason = "channel closed", "send.failed");
return Some(kameo::mailbox::Signal::Stop);
}
}
Err(err) => {
let _ = self.props.transport.send(Err(err)).await;
return Some(kameo::mailbox::Signal::Stop);
}
}
}
None => {
info!(actor = "useragent", "transport.closed");
return Some(kameo::mailbox::Signal::Stop);
}
}
}
}
}
}
}
impl UserAgentSession {
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
use arbiter_proto::transport::DummyTransport;
let transport: super::Transport = Box::new(DummyTransport::new());
let props = ConnectionProps::new(db, actors, transport);
let key = VerifyingKey::from_bytes(&[0u8; 32]).unwrap();
Self {
props,
key,
state: UserAgentStateMachine::new(DummyContext),
}
}
}

View File

@@ -0,0 +1,27 @@
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

@@ -1,51 +0,0 @@
use std::sync::Mutex;
use arbiter_proto::proto::auth::AuthChallenge;
use ed25519_dalek::VerifyingKey;
use x25519_dalek::{EphemeralSecret, PublicKey};
/// Context for state machine with validated key and sent challenge
/// Challenge is then transformed to bytes using shared function and verified
#[derive(Clone, Debug)]
pub struct ChallengeContext {
pub challenge: AuthChallenge,
pub key: VerifyingKey,
}
pub struct UnsealContext {
pub client_public_key: PublicKey,
pub secret: Mutex<Option<EphemeralSecret>>,
}
smlang::statemachine!(
name: UserAgent,
custom_error: false,
transitions: {
*Init + AuthRequest = ReceivedAuthRequest,
ReceivedAuthRequest + ReceivedBootstrapToken = Idle,
ReceivedAuthRequest + SentChallenge(ChallengeContext) / move_challenge = WaitingForChallengeSolution(ChallengeContext),
WaitingForChallengeSolution(ChallengeContext) + ReceivedGoodSolution = Idle,
WaitingForChallengeSolution(ChallengeContext) + ReceivedBadSolution = AuthError, // block further transitions, but connection should close anyway
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)
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
fn move_challenge(&mut self, event_data: ChallengeContext) -> Result<ChallengeContext, ()> {
Ok(event_data)
}
}

View File

@@ -1,19 +1,22 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use arbiter_proto::{ use arbiter_proto::{
proto::{ClientRequest, ClientResponse, UserAgentRequest, UserAgentResponse}, proto::{
transport::{BiStream, GrpcTransportActor, wire}, client::{ClientRequest, ClientResponse},
user_agent::{UserAgentRequest, UserAgentResponse},
},
transport::{IdentityRecvConverter, SendConverter, grpc},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use kameo::actor::PreparedActor;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tracing::info;
use crate::{ use crate::{
actors::{ actors::{
client::handle_client, client::{self, ClientError, ConnectionProps as ClientConnectionProps, connect_client},
user_agent::UserAgentActor, user_agent::{self, ConnectionProps, UserAgentError, connect_user_agent},
}, },
context::ServerContext, context::ServerContext,
}; };
@@ -24,6 +27,102 @@ pub mod db;
const DEFAULT_CHANNEL_SIZE: usize = 1000; const DEFAULT_CHANNEL_SIZE: usize = 1000;
struct UserAgentGrpcSender;
impl SendConverter for UserAgentGrpcSender {
type Input = Result<UserAgentResponse, UserAgentError>;
type Output = Result<UserAgentResponse, Status>;
fn convert(&self, item: Self::Input) -> Self::Output {
match item {
Ok(message) => Ok(message),
Err(err) => Err(user_agent_error_status(err)),
}
}
}
struct ClientGrpcSender;
impl SendConverter for ClientGrpcSender {
type Input = Result<ClientResponse, ClientError>;
type Output = Result<ClientResponse, Status>;
fn convert(&self, item: Self::Input) -> Self::Output {
match item {
Ok(message) => Ok(message),
Err(err) => Err(client_error_status(err)),
}
}
}
fn client_error_status(value: ClientError) -> Status {
match value {
ClientError::MissingRequestPayload | ClientError::UnexpectedRequestPayload => {
Status::invalid_argument("Expected message with payload")
}
ClientError::StateTransitionFailed => Status::internal("State machine error"),
ClientError::Auth(ref err) => client_auth_error_status(err),
}
}
fn client_auth_error_status(value: &client::auth::Error) -> Status {
use client::auth::Error;
match value {
Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => {
Status::invalid_argument(value.to_string())
}
Error::InvalidAuthPubkeyEncoding => {
Status::invalid_argument("Failed to convert pubkey to VerifyingKey")
}
Error::InvalidSignatureLength => Status::invalid_argument("Invalid signature length"),
Error::PublicKeyNotRegistered | Error::InvalidChallengeSolution => {
Status::unauthenticated(value.to_string())
}
Error::Transport => Status::internal("Transport error"),
Error::DatabasePoolUnavailable => Status::internal("Database pool error"),
Error::DatabaseOperationFailed => Status::internal("Database error"),
}
}
fn user_agent_error_status(value: UserAgentError) -> Status {
match value {
UserAgentError::MissingRequestPayload | UserAgentError::UnexpectedRequestPayload => {
Status::invalid_argument("Expected message with payload")
}
UserAgentError::InvalidStateForUnsealEncryptedKey => {
Status::failed_precondition("Invalid state for unseal encrypted key")
}
UserAgentError::InvalidClientPubkeyLength => {
Status::invalid_argument("client_pubkey must be 32 bytes")
}
UserAgentError::StateTransitionFailed => Status::internal("State machine error"),
UserAgentError::KeyHolderActorUnreachable => Status::internal("Vault is not available"),
UserAgentError::Auth(ref err) => auth_error_status(err),
}
}
fn auth_error_status(value: &user_agent::auth::Error) -> Status {
use user_agent::auth::Error;
match value {
Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => {
Status::invalid_argument(value.to_string())
}
Error::InvalidAuthPubkeyEncoding => {
Status::invalid_argument("Failed to convert pubkey to VerifyingKey")
}
Error::PublicKeyNotRegistered | Error::InvalidChallengeSolution => {
Status::unauthenticated(value.to_string())
}
Error::InvalidBootstrapToken => Status::invalid_argument("Invalid bootstrap token"),
Error::Transport => Status::internal("Transport error"),
Error::BootstrapperActorUnreachable => {
Status::internal("Bootstrap token consumption failed")
}
Error::DatabasePoolUnavailable => Status::internal("Database pool error"),
Error::DatabaseOperationFailed => Status::internal("Database error"),
}
}
pub struct Server { pub struct Server {
context: ServerContext, context: ServerContext,
} }
@@ -39,44 +138,50 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for Server {
type UserAgentStream = ReceiverStream<Result<UserAgentResponse, Status>>; type UserAgentStream = ReceiverStream<Result<UserAgentResponse, Status>>;
type ClientStream = ReceiverStream<Result<ClientResponse, Status>>; type ClientStream = ReceiverStream<Result<ClientResponse, Status>>;
#[tracing::instrument(level = "debug", skip(self))]
async fn client( async fn client(
&self, &self,
request: Request<tonic::Streaming<ClientRequest>>, request: Request<tonic::Streaming<ClientRequest>>,
) -> Result<Response<Self::ClientStream>, Status> { ) -> Result<Response<Self::ClientStream>, Status> {
let req_stream = request.into_inner(); let req_stream = request.into_inner();
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE); let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
tokio::spawn(handle_client(
self.context.clone(), let transport = grpc::GrpcAdapter::new(
BiStream { tx,
request_stream: req_stream, req_stream,
response_sender: tx, IdentityRecvConverter::<ClientRequest>::new(),
}, ClientGrpcSender,
)); );
let props = ClientConnectionProps::new(self.context.db.clone(), Box::new(transport));
tokio::spawn(connect_client(props));
info!(event = "connection established", "grpc.client");
Ok(Response::new(ReceiverStream::new(rx))) Ok(Response::new(ReceiverStream::new(rx)))
} }
#[tracing::instrument(level = "debug", skip(self))]
async fn user_agent( async fn user_agent(
&self, &self,
request: Request<tonic::Streaming<UserAgentRequest>>, request: Request<tonic::Streaming<UserAgentRequest>>,
) -> Result<Response<Self::UserAgentStream>, Status> { ) -> Result<Response<Self::UserAgentStream>, Status> {
let req_stream = request.into_inner(); let req_stream = request.into_inner();
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE); let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
let context = self.context.clone();
wire( let transport = grpc::GrpcAdapter::new(
|prepared: PreparedActor<UserAgentActor>, recipient| { tx,
prepared.spawn(UserAgentActor::new(context, recipient)); req_stream,
}, IdentityRecvConverter::<UserAgentRequest>::new(),
|prepared: PreparedActor<GrpcTransportActor<_, _, _>>, business_recipient| { UserAgentGrpcSender,
prepared.spawn(GrpcTransportActor::new( );
tx, let props = ConnectionProps::new(
req_stream, self.context.db.clone(),
business_recipient, self.context.actors.clone(),
)); Box::new(transport),
}, );
) tokio::spawn(connect_user_agent(props));
.await;
info!(event = "connection established", "grpc.user_agent");
Ok(Response::new(ReceiverStream::new(rx))) Ok(Response::new(ReceiverStream::new(rx)))
} }

View File

@@ -0,0 +1,4 @@
mod common;
#[path = "client/auth.rs"]
mod auth;

View File

@@ -0,0 +1,107 @@
use arbiter_proto::transport::Bi;
use arbiter_proto::proto::client::{
AuthChallengeRequest, AuthChallengeSolution, ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
};
use arbiter_server::{
actors::client::{ConnectionProps, connect_client},
db::{self, schema},
};
use diesel::{ExpressionMethods as _, insert_into};
use diesel_async::RunQueryDsl;
use ed25519_dalek::Signer as _;
use super::common::ChannelTransport;
#[tokio::test]
#[test_log::test]
pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let props = ConnectionProps::new(db.clone(), Box::new(server_transport));
let task = tokio::spawn(connect_client(props));
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
pubkey: pubkey_bytes,
},
)),
})
.await
.unwrap();
// Auth fails, connect_client returns, transport drops
task.await.unwrap();
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::program_client::table)
.values(schema::program_client::public_key.eq(pubkey_bytes.clone()))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let props = ConnectionProps::new(db.clone(), Box::new(server_transport));
let task = tokio::spawn(connect_client(props));
// Send challenge request
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
pubkey: pubkey_bytes,
},
)),
})
.await
.unwrap();
// Read the challenge response
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp.payload {
Some(ClientResponsePayload::AuthChallenge(c)) => c,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign the challenge and send solution
let formatted_challenge = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey);
let signature = new_key.sign(&formatted_challenge);
test_transport
.send(ClientRequest {
payload: Some(ClientRequestPayload::AuthChallengeSolution(
AuthChallengeSolution {
signature: signature.to_bytes().to_vec(),
},
)),
})
.await
.unwrap();
// Auth completes, session spawned
task.await.unwrap();
}

View File

@@ -1,10 +1,14 @@
use arbiter_proto::transport::{Bi, Error};
use arbiter_server::{ use arbiter_server::{
actors::keyholder::KeyHolder, actors::keyholder::KeyHolder,
db::{self, schema}, db::{self, schema},
}; };
use async_trait::async_trait;
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use memsafe::MemSafe; use memsafe::MemSafe;
use tokio::sync::mpsc;
#[allow(dead_code)] #[allow(dead_code)]
pub async fn bootstrapped_keyholder(db: &db::DatabasePool) -> KeyHolder { pub async fn bootstrapped_keyholder(db: &db::DatabasePool) -> KeyHolder {
@@ -26,3 +30,46 @@ pub async fn root_key_history_id(db: &db::DatabasePool) -> i32 {
.unwrap(); .unwrap();
id.expect("root_key_id should be set after bootstrap") id.expect("root_key_id should be set after bootstrap")
} }
pub struct ChannelTransport<T, Y> {
receiver: mpsc::Receiver<T>,
sender: mpsc::Sender<Y>,
}
impl<T, Y> ChannelTransport<T, Y> {
pub fn new() -> (Self, ChannelTransport<Y, T>) {
let (tx1, rx1) = mpsc::channel(10);
let (tx2, rx2) = mpsc::channel(10);
(
Self {
receiver: rx1,
sender: tx2,
},
ChannelTransport {
receiver: rx2,
sender: tx1,
},
)
}
}
#[async_trait]
impl<T, Y> Bi<T, Y> for ChannelTransport<T, Y>
where
T: Send + 'static,
Y: Send + 'static,
{
async fn send(&mut self, item: Y) -> Result<(), Error> {
self.sender
.send(item)
.await
.map_err(|_| Error::ChannelClosed)
}
async fn recv(&mut self) -> Option<T> {
self.receiver.recv().await
}
}

View File

@@ -1,30 +1,5 @@
mod common; mod common;
use arbiter_proto::proto::UserAgentResponse;
use arbiter_server::actors::user_agent::UserAgentError;
use kameo::{Actor, actor::Recipient, actor::Spawn, prelude::Message};
/// A no-op actor that discards any messages it receives.
#[derive(Actor)]
struct NullSink;
impl Message<Result<UserAgentResponse, UserAgentError>> for NullSink {
type Reply = ();
async fn handle(
&mut self,
_msg: Result<UserAgentResponse, UserAgentError>,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
}
}
/// Creates a `Recipient` that silently discards all messages.
fn null_recipient() -> Recipient<Result<UserAgentResponse, UserAgentError>> {
let actor_ref = NullSink::spawn(NullSink);
actor_ref.recipient()
}
#[path = "user_agent/auth.rs"] #[path = "user_agent/auth.rs"]
mod auth; mod auth;
#[path = "user_agent/unseal.rs"] #[path = "user_agent/unseal.rs"]

View File

@@ -1,57 +1,50 @@
use arbiter_proto::proto::{ use arbiter_proto::proto::user_agent::{
UserAgentResponse, AuthChallengeRequest, AuthChallengeSolution, UserAgentRequest,
auth::{self, AuthChallengeRequest, AuthOk}, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}; };
use arbiter_proto::transport::Bi;
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
bootstrap::GetToken, bootstrap::GetToken,
user_agent::{HandleAuthChallengeRequest, HandleAuthChallengeSolution, UserAgentActor}, user_agent::{ConnectionProps, connect_user_agent},
}, },
db::{self, schema}, db::{self, schema},
}; };
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 ed25519_dalek::Signer as _;
use kameo::actor::Spawn;
use super::common::ChannelTransport;
#[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();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let user_agent =
UserAgentActor::new_manual(db.clone(), actors, super::null_recipient()); let (server_transport, mut test_transport) = ChannelTransport::new();
let user_agent_ref = UserAgentActor::spawn(user_agent); let props = ConnectionProps::new(db.clone(), actors, Box::new(server_transport));
let task = tokio::spawn(connect_user_agent(props));
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
let result = user_agent_ref test_transport
.ask(HandleAuthChallengeRequest { .send(UserAgentRequest {
req: AuthChallengeRequest { payload: Some(UserAgentRequestPayload::AuthChallengeRequest(
pubkey: pubkey_bytes, AuthChallengeRequest {
bootstrap_token: Some(token), pubkey: pubkey_bytes,
}, bootstrap_token: Some(token),
})
.await
.expect("Shouldn't fail to send message");
assert_eq!(
result,
UserAgentResponse {
payload: Some(UserAgentResponsePayload::AuthMessage(
arbiter_proto::proto::auth::ServerMessage {
payload: Some(arbiter_proto::proto::auth::server_message::Payload::AuthOk(
AuthOk {},
)),
}, },
)), )),
} })
); .await
.unwrap();
task.await.unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let stored_pubkey: Vec<u8> = schema::useragent_client::table let stored_pubkey: Vec<u8> = schema::useragent_client::table
@@ -66,49 +59,45 @@ pub async fn test_bootstrap_token_auth() {
#[test_log::test] #[test_log::test]
pub async fn test_bootstrap_invalid_token_auth() { 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 user_agent =
UserAgentActor::new_manual(db.clone(), actors, super::null_recipient()); let (server_transport, mut test_transport) = ChannelTransport::new();
let user_agent_ref = UserAgentActor::spawn(user_agent); let props = ConnectionProps::new(db.clone(), actors, Box::new(server_transport));
let task = tokio::spawn(connect_user_agent(props));
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
let result = user_agent_ref test_transport
.ask(HandleAuthChallengeRequest { .send(UserAgentRequest {
req: AuthChallengeRequest { payload: Some(UserAgentRequestPayload::AuthChallengeRequest(
pubkey: pubkey_bytes, AuthChallengeRequest {
bootstrap_token: Some("invalid_token".to_string()), pubkey: pubkey_bytes,
}, bootstrap_token: Some("invalid_token".to_string()),
},
)),
}) })
.await; .await
.unwrap();
match result { // Auth fails, connect_user_agent returns, transport drops
Err(kameo::error::SendError::HandlerError(err)) => { task.await.unwrap();
assert!(
matches!(err, arbiter_server::actors::user_agent::UserAgentError::InvalidBootstrapToken), // Verify no key was registered
"Expected InvalidBootstrapToken, got {err:?}" let mut conn = db.get().await.unwrap();
); let count: i64 = schema::useragent_client::table
} .count()
Err(other) => { .get_result::<i64>(&mut conn)
panic!("Expected SendError::HandlerError, got {other:?}"); .await
} .unwrap();
Ok(_) => { assert_eq!(count, 0);
panic!("Expected error due to invalid bootstrap token, but got success");
}
}
} }
#[tokio::test] #[tokio::test]
#[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 = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let user_agent =
UserAgentActor::new_manual(db.clone(), actors, super::null_recipient());
let user_agent_ref = UserAgentActor::spawn(user_agent);
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
@@ -122,50 +111,51 @@ pub async fn test_challenge_auth() {
.unwrap(); .unwrap();
} }
let result = user_agent_ref let (server_transport, mut test_transport) = ChannelTransport::new();
.ask(HandleAuthChallengeRequest { let props = ConnectionProps::new(db.clone(), actors, Box::new(server_transport));
req: AuthChallengeRequest { let task = tokio::spawn(connect_user_agent(props));
pubkey: pubkey_bytes,
bootstrap_token: None,
},
})
.await
.expect("Shouldn't fail to send message");
let UserAgentResponse { // Send challenge request
payload: test_transport
Some(UserAgentResponsePayload::AuthMessage(arbiter_proto::proto::auth::ServerMessage { .send(UserAgentRequest {
payload: payload: Some(UserAgentRequestPayload::AuthChallengeRequest(
Some(arbiter_proto::proto::auth::server_message::Payload::AuthChallenge(challenge)), AuthChallengeRequest {
})), pubkey: pubkey_bytes,
} = result bootstrap_token: None,
else {
panic!("Expected auth challenge response, got {result:?}");
};
let formatted_challenge = arbiter_proto::format_challenge(&challenge);
let signature = new_key.sign(&formatted_challenge);
let serialized_signature = signature.to_bytes().to_vec();
let result = user_agent_ref
.ask(HandleAuthChallengeSolution {
solution: auth::AuthChallengeSolution {
signature: serialized_signature,
},
})
.await
.expect("Shouldn't fail to send message");
assert_eq!(
result,
UserAgentResponse {
payload: Some(UserAgentResponsePayload::AuthMessage(
arbiter_proto::proto::auth::ServerMessage {
payload: Some(arbiter_proto::proto::auth::server_message::Payload::AuthOk(
AuthOk {},
)),
}, },
)), )),
} })
); .await
.unwrap();
// Read the challenge response
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp.payload {
Some(UserAgentResponsePayload::AuthChallenge(c)) => c,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign the challenge and send solution
let formatted_challenge = arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey);
let signature = new_key.sign(&formatted_challenge);
test_transport
.send(UserAgentRequest {
payload: Some(UserAgentRequestPayload::AuthChallengeSolution(
AuthChallengeSolution {
signature: signature.to_bytes().to_vec(),
},
)),
})
.await
.unwrap();
// Auth completes, session spawned
task.await.unwrap();
} }

View File

@@ -1,30 +1,26 @@
use arbiter_proto::proto::{ use arbiter_proto::proto::user_agent::{
UnsealEncryptedKey, UnsealResult, UnsealStart, auth::AuthChallengeRequest, UnsealEncryptedKey, UnsealResult, UnsealStart, UserAgentRequest,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, user_agent_response::Payload as UserAgentResponsePayload,
}; };
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
bootstrap::GetToken,
keyholder::{Bootstrap, Seal}, keyholder::{Bootstrap, Seal},
user_agent::{ user_agent::session::UserAgentSession,
HandleAuthChallengeRequest, HandleUnsealEncryptedKey, HandleUnsealRequest,
UserAgentActor,
},
}, },
db, db,
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use kameo::actor::{ActorRef, Spawn};
use memsafe::MemSafe; use memsafe::MemSafe;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
async fn setup_authenticated_user_agent( async fn setup_sealed_user_agent(
seal_key: &[u8], seal_key: &[u8],
) -> (arbiter_server::db::DatabasePool, ActorRef<UserAgentActor>) { ) -> (db::DatabasePool, UserAgentSession) {
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 .key_holder
.ask(Bootstrap { .ask(Bootstrap {
@@ -34,37 +30,23 @@ async fn setup_authenticated_user_agent(
.unwrap(); .unwrap();
actors.key_holder.ask(Seal).await.unwrap(); actors.key_holder.ask(Seal).await.unwrap();
let user_agent = let session = UserAgentSession::new_test(db.clone(), actors);
UserAgentActor::new_manual(db.clone(), actors.clone(), super::null_recipient());
let user_agent_ref = UserAgentActor::spawn(user_agent);
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); (db, session)
let auth_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
user_agent_ref
.ask(HandleAuthChallengeRequest {
req: AuthChallengeRequest {
pubkey: auth_key.verifying_key().to_bytes().to_vec(),
bootstrap_token: Some(token),
},
})
.await
.unwrap();
(db, user_agent_ref)
} }
async fn client_dh_encrypt( async fn client_dh_encrypt(
user_agent_ref: &ActorRef<UserAgentActor>, user_agent: &mut UserAgentSession,
key_to_send: &[u8], key_to_send: &[u8],
) -> UnsealEncryptedKey { ) -> UnsealEncryptedKey {
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_ref let response = user_agent
.ask(HandleUnsealRequest { .process_transport_inbound(UserAgentRequest {
req: UnsealStart { payload: Some(UserAgentRequestPayload::UnsealStart(UnsealStart {
client_pubkey: client_public.as_bytes().to_vec(), client_pubkey: client_public.as_bytes().to_vec(),
}, })),
}) })
.await .await
.unwrap(); .unwrap();
@@ -91,16 +73,22 @@ async fn client_dh_encrypt(
} }
} }
fn unseal_key_request(req: UnsealEncryptedKey) -> UserAgentRequest {
UserAgentRequest {
payload: Some(UserAgentRequestPayload::UnsealEncryptedKey(req)),
}
}
#[tokio::test] #[tokio::test]
#[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_ref) = setup_authenticated_user_agent(seal_key).await; let (_db, mut user_agent) = setup_sealed_user_agent(seal_key).await;
let encrypted_key = client_dh_encrypt(&user_agent_ref, seal_key).await; let encrypted_key = client_dh_encrypt(&mut user_agent, seal_key).await;
let response = user_agent_ref let response = user_agent
.ask(HandleUnsealEncryptedKey { req: encrypted_key }) .process_transport_inbound(unseal_key_request(encrypted_key))
.await .await
.unwrap(); .unwrap();
@@ -113,12 +101,12 @@ pub async fn test_unseal_success() {
#[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_ref) = setup_authenticated_user_agent(b"correct-key").await; let (_db, mut user_agent) = setup_sealed_user_agent(b"correct-key").await;
let encrypted_key = client_dh_encrypt(&user_agent_ref, b"wrong-key").await; let encrypted_key = client_dh_encrypt(&mut user_agent, b"wrong-key").await;
let response = user_agent_ref let response = user_agent
.ask(HandleUnsealEncryptedKey { req: encrypted_key }) .process_transport_inbound(unseal_key_request(encrypted_key))
.await .await
.unwrap(); .unwrap();
@@ -131,28 +119,26 @@ 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_ref) = setup_authenticated_user_agent(b"test-key").await; let (_db, mut user_agent) = setup_sealed_user_agent(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_ref user_agent
.ask(HandleUnsealRequest { .process_transport_inbound(UserAgentRequest {
req: UnsealStart { payload: Some(UserAgentRequestPayload::UnsealStart(UnsealStart {
client_pubkey: client_public.as_bytes().to_vec(), client_pubkey: client_public.as_bytes().to_vec(),
}, })),
}) })
.await .await
.unwrap(); .unwrap();
let response = user_agent_ref let response = user_agent
.ask(HandleUnsealEncryptedKey { .process_transport_inbound(unseal_key_request(UnsealEncryptedKey {
req: UnsealEncryptedKey { nonce: vec![0u8; 24],
nonce: vec![0u8; 24], ciphertext: vec![0u8; 32],
ciphertext: vec![0u8; 32], associated_data: vec![],
associated_data: vec![], }))
},
})
.await .await
.unwrap(); .unwrap();
@@ -162,49 +148,17 @@ pub async fn test_unseal_corrupted_ciphertext() {
); );
} }
#[tokio::test]
#[test_log::test]
pub async fn test_unseal_start_without_auth_fails() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let user_agent =
UserAgentActor::new_manual(db.clone(), actors, super::null_recipient());
let user_agent_ref = UserAgentActor::spawn(user_agent);
let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret);
let result = user_agent_ref
.ask(HandleUnsealRequest {
req: UnsealStart {
client_pubkey: client_public.as_bytes().to_vec(),
},
})
.await;
match result {
Err(kameo::error::SendError::HandlerError(err)) => {
assert!(
matches!(err, arbiter_server::actors::user_agent::UserAgentError::InvalidState),
"Expected InvalidState, got {err:?}"
);
}
other => panic!("Expected state machine error, got {other:?}"),
}
}
#[tokio::test] #[tokio::test]
#[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_ref) = setup_authenticated_user_agent(seal_key).await; let (_db, mut user_agent) = setup_sealed_user_agent(seal_key).await;
{ {
let encrypted_key = client_dh_encrypt(&user_agent_ref, b"wrong-key").await; let encrypted_key = client_dh_encrypt(&mut user_agent, b"wrong-key").await;
let response = user_agent_ref let response = user_agent
.ask(HandleUnsealEncryptedKey { req: encrypted_key }) .process_transport_inbound(unseal_key_request(encrypted_key))
.await .await
.unwrap(); .unwrap();
@@ -215,10 +169,10 @@ pub async fn test_unseal_retry_after_invalid_key() {
} }
{ {
let encrypted_key = client_dh_encrypt(&user_agent_ref, seal_key).await; let encrypted_key = client_dh_encrypt(&mut user_agent, seal_key).await;
let response = user_agent_ref let response = user_agent
.ask(HandleUnsealEncryptedKey { req: encrypted_key }) .process_transport_inbound(unseal_key_request(encrypted_key))
.await .await
.unwrap(); .unwrap();

View File

@@ -9,7 +9,13 @@ arbiter-proto.path = "../arbiter-proto"
kameo.workspace = true kameo.workspace = true
tokio = {workspace = true, features = ["net"]} tokio = {workspace = true, features = ["net"]}
tonic.workspace = true tonic.workspace = true
tonic.features = ["tls-aws-lc"]
tracing.workspace = true tracing.workspace = true
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
smlang.workspace = true smlang.workspace = true
x25519-dalek.workspace = true x25519-dalek.workspace = true
thiserror.workspace = true
tokio-stream.workspace = true
http = "1.4.0"
rustls-webpki = { version = "0.103.9", features = ["aws-lc-rs"] }
async-trait.workspace = true

View File

@@ -0,0 +1,72 @@
use arbiter_proto::{
proto::{
user_agent::{UserAgentRequest, UserAgentResponse},
arbiter_service_client::ArbiterServiceClient,
},
transport::{IdentityRecvConverter, IdentitySendConverter, grpc},
url::ArbiterUrl,
};
use ed25519_dalek::SigningKey;
use kameo::actor::{ActorRef, Spawn};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
#[derive(Debug, thiserror::Error)]
pub enum ConnectError {
#[error("Could establish connection")]
Connection(#[from] tonic::transport::Error),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error),
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
}
use super::UserAgentActor;
pub type UserAgentGrpc = ActorRef<
UserAgentActor<
grpc::GrpcAdapter<
IdentityRecvConverter<UserAgentResponse>,
IdentitySendConverter<UserAgentRequest>,
>,
>,
>;
pub async fn connect_grpc(
url: ArbiterUrl,
key: SigningKey,
) -> Result<UserAgentGrpc, ConnectError> {
let bootstrap_token = url.bootstrap_token.clone();
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor);
// TODO: if `host` is localhost, we need to verify server's process authenticity
let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))?
.tls_config(tls)?
.connect()
.await?;
let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(16);
let bistream = client.user_agent(ReceiverStream::new(rx)).await?;
let bistream = bistream.into_inner();
let adapter = grpc::GrpcAdapter::new(
tx,
bistream,
IdentityRecvConverter::new(),
IdentitySendConverter::new(),
);
let actor = UserAgentActor::spawn(UserAgentActor::new(key, bootstrap_token, adapter));
Ok(actor)
}

View File

@@ -1,66 +1,195 @@
use arbiter_proto::{proto::UserAgentRequest, transport::TransportActor}; use arbiter_proto::{
use ed25519_dalek::SigningKey; format_challenge,
use kameo::{ proto::user_agent::{
Actor, Reply, AuthChallengeRequest, AuthChallengeSolution, AuthOk,
actor::{ActorRef, WeakActorRef}, UserAgentRequest, UserAgentResponse,
prelude::Message, user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
transport::Bi,
}; };
use ed25519_dalek::{Signer, SigningKey};
use kameo::{Actor, actor::ActorRef};
use smlang::statemachine; use smlang::statemachine;
use tonic::transport::CertificateDer; use tokio::select;
use tracing::{debug, error}; use tracing::{error, info};
struct Storage {
pub identity: SigningKey,
pub server_ca_cert: CertificateDer<'static>,
}
#[derive(Debug)]
pub enum InitError {
StorageError,
Other(String),
}
statemachine! { statemachine! {
name: UserAgentStateMachine, name: UserAgent,
custom_error: false, custom_error: false,
transitions: { transitions: {
*Init + SendAuthChallenge = WaitingForAuthSolution *Init + SentAuthChallengeRequest = WaitingForServerAuth,
WaitingForServerAuth + ReceivedAuthChallenge = WaitingForAuthOk,
WaitingForServerAuth + ReceivedAuthOk = Authenticated,
WaitingForAuthOk + ReceivedAuthOk = Authenticated,
} }
} }
pub struct DummyContext;
impl UserAgentStateMachineContext for DummyContext {}
#[derive(Debug, thiserror::Error)]
pub enum InboundError {
#[error("Invalid user agent response")]
InvalidResponse,
#[error("Expected response payload")]
MissingResponsePayload,
#[error("Unexpected response payload")]
UnexpectedResponsePayload,
#[error("Invalid state for auth challenge")]
InvalidStateForAuthChallenge,
#[error("Invalid state for auth ok")]
InvalidStateForAuthOk,
#[error("State machine error")]
StateTransitionFailed,
#[error("Transport send failed")]
TransportSendFailed,
}
pub struct UserAgentActor<A: TransportActor<UserAgentRequest>> { pub struct UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
key: SigningKey, key: SigningKey,
server_ca_cert: CertificateDer<'static>, bootstrap_token: Option<String>,
sender: ActorRef<A>, state: UserAgentStateMachine<DummyContext>,
transport: Transport,
} }
impl<A: TransportActor<UserAgentRequest>> Actor for UserAgentActor<A> {
type Args = Self;
type Error = InitError; impl<Transport> UserAgentActor<Transport>
where
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> { Transport: Bi<UserAgentResponse, UserAgentRequest>,
todo!() {
pub fn new(key: SigningKey, bootstrap_token: Option<String>, transport: Transport) -> Self {
Self {
key,
bootstrap_token,
state: UserAgentStateMachine::new(DummyContext),
transport,
}
} }
async fn on_link_died( fn transition(&mut self, event: UserAgentEvents) -> Result<(), InboundError> {
self.state.process_event(event).map_err(|e| {
error!(?e, "useragent state transition failed");
InboundError::StateTransitionFailed
})?;
Ok(())
}
async fn send_auth_challenge_request(&mut self) -> Result<(), InboundError> {
let req = AuthChallengeRequest {
pubkey: self.key.verifying_key().to_bytes().to_vec(),
bootstrap_token: self.bootstrap_token.take(),
};
self.transition(UserAgentEvents::SentAuthChallengeRequest)?;
self.transport
.send(UserAgentRequest {
payload: Some(UserAgentRequestPayload::AuthChallengeRequest(req)),
})
.await
.map_err(|_| InboundError::TransportSendFailed)?;
info!(actor = "useragent", "auth.request.sent");
Ok(())
}
async fn handle_auth_challenge(
&mut self, &mut self,
_: WeakActorRef<Self>, challenge: arbiter_proto::proto::user_agent::AuthChallenge,
id: kameo::prelude::ActorId, ) -> Result<(), InboundError> {
_: kameo::prelude::ActorStopReason, self.transition(UserAgentEvents::ReceivedAuthChallenge)?;
) -> Result<std::ops::ControlFlow<kameo::prelude::ActorStopReason>, Self::Error> {
if id == self.sender.id() { let formatted = format_challenge(challenge.nonce, &challenge.pubkey);
error!("Transport actor died, stopping UserAgentActor"); let signature = self.key.sign(&formatted);
Ok(std::ops::ControlFlow::Break( let solution = AuthChallengeSolution {
kameo::prelude::ActorStopReason::Normal, signature: signature.to_bytes().to_vec(),
)) };
} else {
debug!( self.transport
"Linked actor {} died, but it's not the transport actor, ignoring", .send(UserAgentRequest {
id payload: Some(UserAgentRequestPayload::AuthChallengeSolution(solution)),
); })
Ok(std::ops::ControlFlow::Continue(())) .await
.map_err(|_| InboundError::TransportSendFailed)?;
info!(actor = "useragent", "auth.solution.sent");
Ok(())
}
fn handle_auth_ok(&mut self, _ok: AuthOk) -> Result<(), InboundError> {
self.transition(UserAgentEvents::ReceivedAuthOk)?;
info!(actor = "useragent", "auth.ok");
Ok(())
}
pub async fn process_inbound_transport(
&mut self,
inbound: UserAgentResponse
) -> Result<(), InboundError> {
let payload = inbound
.payload
.ok_or(InboundError::MissingResponsePayload)?;
match payload {
UserAgentResponsePayload::AuthChallenge(challenge) => {
self.handle_auth_challenge(challenge).await
}
UserAgentResponsePayload::AuthOk(ok) => self.handle_auth_ok(ok),
_ => Err(InboundError::UnexpectedResponsePayload),
} }
} }
} }
impl<Transport> Actor for UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
type Args = Self;
type Error = ();
async fn on_start(
mut args: Self::Args,
_actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> {
if let Err(err) = args.send_auth_challenge_request().await {
error!(?err, actor = "useragent", "auth.start.failed");
return Err(());
}
Ok(args)
}
async fn next(
&mut self,
_actor_ref: kameo::prelude::WeakActorRef<Self>,
mailbox_rx: &mut kameo::prelude::MailboxReceiver<Self>,
) -> Option<kameo::mailbox::Signal<Self>> {
loop {
select! {
signal = mailbox_rx.recv() => {
return signal;
}
inbound = self.transport.recv() => {
match inbound {
Some(inbound) => {
if let Err(err) = self.process_inbound_transport(inbound).await {
error!(?err, actor = "useragent", "transport.inbound.failed");
return Some(kameo::mailbox::Signal::Stop);
}
}
None => {
info!(actor = "useragent", "transport.closed");
return Some(kameo::mailbox::Signal::Stop);
}
}
}
}
}
}
}
mod grpc;
pub use grpc::{connect_grpc, ConnectError};

View File

@@ -0,0 +1,141 @@
use arbiter_proto::{
format_challenge,
proto::user_agent::{
AuthChallenge, AuthOk,
UserAgentRequest, UserAgentResponse,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
transport::Bi,
};
use arbiter_useragent::UserAgentActor;
use ed25519_dalek::SigningKey;
use kameo::actor::Spawn;
use tokio::sync::mpsc;
use tokio::time::{Duration, timeout};
use async_trait::async_trait;
struct TestTransport {
inbound_rx: mpsc::Receiver<UserAgentResponse>,
outbound_tx: mpsc::Sender<UserAgentRequest>,
}
#[async_trait]
impl Bi<UserAgentResponse, UserAgentRequest> for TestTransport {
async fn send(&mut self, item: UserAgentRequest) -> Result<(), arbiter_proto::transport::Error> {
self.outbound_tx
.send(item)
.await
.map_err(|_| arbiter_proto::transport::Error::ChannelClosed)
}
async fn recv(&mut self) -> Option<UserAgentResponse> {
self.inbound_rx.recv().await
}
}
fn make_transport() -> (
TestTransport,
mpsc::Sender<UserAgentResponse>,
mpsc::Receiver<UserAgentRequest>,
) {
let (inbound_tx, inbound_rx) = mpsc::channel(8);
let (outbound_tx, outbound_rx) = mpsc::channel(8);
(
TestTransport {
inbound_rx,
outbound_tx,
},
inbound_tx,
outbound_rx,
)
}
fn test_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
#[tokio::test]
async fn sends_auth_request_on_start_with_bootstrap_token() {
let key = test_key();
let pubkey = key.verifying_key().to_bytes().to_vec();
let bootstrap_token = Some("bootstrap-123".to_string());
let (transport, inbound_tx, mut outbound_rx) = make_transport();
let actor = UserAgentActor::spawn(UserAgentActor::new(key, bootstrap_token.clone(), transport));
let outbound = timeout(Duration::from_secs(1), outbound_rx.recv())
.await
.expect("timed out waiting for auth request")
.expect("channel closed before auth request");
let UserAgentRequest {
payload: Some(UserAgentRequestPayload::AuthChallengeRequest(req)),
} = outbound
else {
panic!("expected auth challenge request");
};
assert_eq!(req.pubkey, pubkey);
assert_eq!(req.bootstrap_token, bootstrap_token);
drop(inbound_tx);
drop(actor);
}
#[tokio::test]
async fn challenge_flow_sends_solution_from_transport_inbound() {
let key = test_key();
let verify_key = key.verifying_key();
let (transport, inbound_tx, mut outbound_rx) = make_transport();
let actor = UserAgentActor::spawn(UserAgentActor::new(key, None, transport));
let _initial_auth_request = timeout(Duration::from_secs(1), outbound_rx.recv())
.await
.expect("timed out waiting for initial auth request")
.expect("missing initial auth request");
let challenge = AuthChallenge {
pubkey: verify_key.to_bytes().to_vec(),
nonce: 42,
};
inbound_tx
.send(UserAgentResponse {
payload: Some(UserAgentResponsePayload::AuthChallenge(challenge.clone())),
})
.await
.unwrap();
let outbound = timeout(Duration::from_secs(1), outbound_rx.recv())
.await
.expect("timed out waiting for challenge solution")
.expect("missing challenge solution");
let UserAgentRequest {
payload: Some(UserAgentRequestPayload::AuthChallengeSolution(solution)),
} = outbound
else {
panic!("expected auth challenge solution");
};
let formatted = format_challenge(challenge.nonce, &challenge.pubkey);
let sig: ed25519_dalek::Signature = solution
.signature
.as_slice()
.try_into()
.expect("signature bytes length");
verify_key
.verify_strict(&formatted, &sig)
.expect("solution signature should verify");
inbound_tx
.send(UserAgentResponse {
payload: Some(UserAgentResponsePayload::AuthOk(AuthOk {})),
})
.await
.unwrap();
drop(inbound_tx);
drop(actor);
}