From 6030f309017e3647fce405d2f3b0e25e10021b99 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 14 Mar 2026 12:14:30 +0100 Subject: [PATCH] feat(user-agent-auth): add RSA and ECDSA auth key types Extend user-agent authentication to support Ed25519, ECDSA (secp256k1), and RSA (PSS+SHA-256) with minimal protocol and storage changes. Add key_type to auth requests and useragent_client, update key parsing/signature verification paths, and keep backward compatibility by treating UNSPECIFIED as Ed25519. --- protobufs/user_agent.proto | 8 + server/.cargo/config.toml | 4 + server/Cargo.lock | 237 ++++++++++++++++-- server/Cargo.toml | 4 + server/crates/arbiter-proto/Cargo.toml | 1 + server/crates/arbiter-proto/build.rs | 6 +- server/crates/arbiter-server/Cargo.toml | 5 +- .../2026-03-13-000000_add_key_type/down.sql | 2 + .../2026-03-13-000000_add_key_type/up.sql | 1 + .../src/actors/user_agent/auth.rs | 52 ++-- .../src/actors/user_agent/auth/state.rs | 149 +++++++++-- .../src/actors/user_agent/session.rs | 31 +-- server/crates/arbiter-server/src/db/mod.rs | 8 +- server/crates/arbiter-server/src/db/models.rs | 40 ++- server/crates/arbiter-server/src/db/schema.rs | 1 + .../arbiter-server/tests/user_agent/auth.rs | 11 +- server/crates/arbiter-useragent/Cargo.toml | 5 + server/crates/arbiter-useragent/src/grpc.rs | 8 +- server/crates/arbiter-useragent/src/lib.rs | 80 +++++- server/crates/arbiter-useragent/tests/auth.rs | 27 +- 20 files changed, 556 insertions(+), 124 deletions(-) create mode 100644 server/.cargo/config.toml create mode 100644 server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/down.sql create mode 100644 server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/up.sql diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index cfc00ae..fcf508d 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -5,9 +5,17 @@ package arbiter.user_agent; import "google/protobuf/empty.proto"; import "evm.proto"; +enum KeyType { + KEY_TYPE_UNSPECIFIED = 0; + KEY_TYPE_ED25519 = 1; + KEY_TYPE_ECDSA_SECP256K1 = 2; + KEY_TYPE_RSA = 3; +} + message AuthChallengeRequest { bytes pubkey = 1; optional string bootstrap_token = 2; + KeyType key_type = 3; } message AuthChallenge { diff --git a/server/.cargo/config.toml b/server/.cargo/config.toml new file mode 100644 index 0000000..8390ed5 --- /dev/null +++ b/server/.cargo/config.toml @@ -0,0 +1,4 @@ +[profile.dev] +# Override global Cranelift backend: Cranelift does not propagate cargo:rustc-link-lib +# from native dependencies (aws-lc-sys etc.) to lld-link, causing undefined symbol errors. +codegen-backend = "llvm" diff --git a/server/Cargo.lock b/server/Cargo.lock index e13fa32..1586320 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -654,7 +654,7 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fa0c53e8c1e1ef4d01066b01c737fb62fc9397ab52c6e7bb5669f97d281b9bc" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.117", @@ -691,6 +691,7 @@ dependencies = [ "miette", "prost", "prost-types", + "protoc-bin-vendored", "rand 0.10.0", "rcgen", "rstest", @@ -730,9 +731,12 @@ dependencies = [ "rand 0.10.0", "rcgen", "restructed", + "rsa", "rustls", "secrecy", + "sha2 0.10.9", "smlang", + "spki", "strum", "test-log", "thiserror", @@ -760,9 +764,14 @@ dependencies = [ "async-trait", "ed25519-dalek", "http", + "k256", "kameo", + "rand 0.10.0", + "rsa", "rustls-webpki", + "sha2 0.10.9", "smlang", + "spki", "thiserror", "tokio", "tokio-stream", @@ -1334,9 +1343,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.6" +version = "2.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0f582957c24870b7bfd12bf562c40b4734b533cafbaf8ded31d6d85f462c01" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" dependencies = [ "blst", "cc", @@ -1349,9 +1358,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1615,7 +1624,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.11.1", + "digest 0.11.2", "fiat-crypto 0.3.0", "rustc_version 0.4.1", "subtle", @@ -1639,8 +1648,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1658,13 +1677,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1696,6 +1739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1759,9 +1803,9 @@ dependencies = [ [[package]] name = "diesel" -version = "2.3.6" +version = "2.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b6c2fc184a6fb6ebcf5f9a5e3bbfa84d8fd268cdfcce4ed508979a6259494d" +checksum = "f4ae09a41a4b89f94ec1e053623da8340d996bc32c6517d325a9daad9b239358" dependencies = [ "chrono", "diesel_derives", @@ -1844,9 +1888,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "285743a676ccb6b3e116bc14cc69319b957867930ae9c4822f8e0f54509d7243" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", "crypto-common 0.2.1", @@ -1875,7 +1919,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck", "proc-macro2", @@ -2876,6 +2920,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2897,9 +2944,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "pkg-config", "vcpkg", @@ -3109,6 +3156,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3124,6 +3187,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3199,9 +3273,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opaque-debug" @@ -3293,6 +3367,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3352,6 +3435,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3567,6 +3661,70 @@ dependencies = [ "prost", ] +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "pulldown-cmark" version = "0.13.1" @@ -3935,6 +4093,27 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -4302,9 +4481,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", @@ -4321,11 +4500,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4360,7 +4539,7 @@ checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.11.1", + "digest 0.11.2", ] [[package]] @@ -4482,6 +4661,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -5065,9 +5250,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", diff --git a/server/Cargo.toml b/server/Cargo.toml index cd97da5..03ffbbe 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -36,3 +36,7 @@ rcgen = { version = "0.14.7", features = [ "x509-parser", "zeroize", ], default-features = false } +k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } +rsa = { version = "0.9", features = ["sha2"] } +sha2 = "0.10" +spki = "0.7" diff --git a/server/crates/arbiter-proto/Cargo.toml b/server/crates/arbiter-proto/Cargo.toml index 0673f8a..ca45b97 100644 --- a/server/crates/arbiter-proto/Cargo.toml +++ b/server/crates/arbiter-proto/Cargo.toml @@ -24,6 +24,7 @@ async-trait.workspace = true [build-dependencies] tonic-prost-build = "0.14.3" +protoc-bin-vendored = "3" [dev-dependencies] rstest.workspace = true diff --git a/server/crates/arbiter-proto/build.rs b/server/crates/arbiter-proto/build.rs index 18de523..e61600d 100644 --- a/server/crates/arbiter-proto/build.rs +++ b/server/crates/arbiter-proto/build.rs @@ -3,6 +3,11 @@ use tonic_prost_build::configure; static PROTOBUF_DIR: &str = "../../../protobufs"; fn main() -> Result<(), Box> { + if std::env::var("PROTOC").is_err() { + println!("cargo:warning=PROTOC environment variable not set, using vendored protoc"); + let protoc = protoc_bin_vendored::protoc_bin_path().unwrap(); + unsafe { std::env::set_var("PROTOC", protoc) }; + } println!("cargo::rerun-if-changed={PROTOBUF_DIR}"); @@ -17,7 +22,6 @@ fn main() -> Result<(), Box> { ], &[PROTOBUF_DIR.to_string()], ) - .unwrap(); Ok(()) } diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 5a2856c..4fcb05e 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -42,7 +42,10 @@ argon2 = { version = "0.5.3", features = ["zeroize"] } restructed = "0.2.2" strum = { version = "0.27.2", features = ["derive"] } pem = "3.0.6" -k256 = "0.13.4" +k256.workspace = true +rsa.workspace = true +sha2.workspace = true +spki.workspace = true alloy.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" diff --git a/server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/down.sql b/server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/down.sql new file mode 100644 index 0000000..41b541c --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/down.sql @@ -0,0 +1,2 @@ +-- Not reversible without data loss; drop the column to revert +ALTER TABLE useragent_client DROP COLUMN key_type; diff --git a/server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/up.sql b/server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/up.sql new file mode 100644 index 0000000..1d494f0 --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-03-13-000000_add_key_type/up.sql @@ -0,0 +1 @@ +ALTER TABLE useragent_client ADD COLUMN key_type INTEGER NOT NULL DEFAULT 1; diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth.rs b/server/crates/arbiter-server/src/actors/user_agent/auth.rs index 543dc87..3da8291 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth.rs @@ -1,13 +1,12 @@ use arbiter_proto::proto::user_agent::{ - AuthChallengeRequest, AuthChallengeSolution, UserAgentRequest, + AuthChallengeRequest, AuthChallengeSolution, KeyType as ProtoKeyType, UserAgentRequest, user_agent_request::Payload as UserAgentRequestPayload, }; -use ed25519_dalek::VerifyingKey; use tracing::error; use crate::actors::user_agent::{ UserAgentConnection, - auth::state::{AuthContext, AuthStateMachine}, session::UserAgentSession, + auth::state::{AuthContext, AuthPublicKey, AuthStateMachine}, session::UserAgentSession, }; #[derive(thiserror::Error, Debug, PartialEq)] @@ -37,28 +36,50 @@ pub enum Error { mod state; use state::*; +fn parse_pubkey(key_type: ProtoKeyType, pubkey: Vec) -> Result { + match key_type { + // UNSPECIFIED treated as Ed25519 for backward compatibility + ProtoKeyType::Unspecified | ProtoKeyType::Ed25519 => { + let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?; + let key = ed25519_dalek::VerifyingKey::from_bytes(pubkey_bytes) + .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; + Ok(AuthPublicKey::Ed25519(key)) + } + ProtoKeyType::EcdsaSecp256k1 => { + // Public key is sent as 33-byte SEC1 compressed point + let key = k256::ecdsa::VerifyingKey::from_sec1_bytes(&pubkey) + .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; + Ok(AuthPublicKey::EcdsaSecp256k1(key)) + } + ProtoKeyType::Rsa => { + use rsa::pkcs8::DecodePublicKey as _; + let key = rsa::RsaPublicKey::from_public_key_der(&pubkey) + .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; + Ok(AuthPublicKey::Rsa(key)) + } + } +} + fn parse_auth_event(payload: UserAgentRequestPayload) -> Result { match payload { UserAgentRequestPayload::AuthChallengeRequest(AuthChallengeRequest { pubkey, bootstrap_token: None, + key_type, }) => { - let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?; - let pubkey = VerifyingKey::from_bytes(pubkey_bytes) - .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; + let kt = ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified); Ok(AuthEvents::AuthRequest(ChallengeRequest { - pubkey: pubkey.into(), + pubkey: parse_pubkey(kt, pubkey)?, })) } UserAgentRequestPayload::AuthChallengeRequest(AuthChallengeRequest { pubkey, bootstrap_token: Some(token), + key_type, }) => { - let pubkey_bytes = pubkey.as_array().ok_or(Error::InvalidClientPubkeyLength)?; - let pubkey = VerifyingKey::from_bytes(pubkey_bytes) - .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; + let kt = ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified); Ok(AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { - pubkey: pubkey.into(), + pubkey: parse_pubkey(kt, pubkey)?, token, })) } @@ -71,11 +92,11 @@ fn parse_auth_event(payload: UserAgentRequestPayload) -> Result Result { +pub async fn authenticate(props: &mut UserAgentConnection) -> Result { 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 + // `state` holds a mutable reference to `props` so we can't access it directly here let transport = state.context_mut().conn.transport.as_mut(); let Some(UserAgentRequest { payload: Some(payload), @@ -110,9 +131,8 @@ pub async fn authenticate(props: &mut UserAgentConnection) -> Result Result { - let key = authenticate(&mut props).await?; - let session = UserAgentSession::new(props, key.clone()); + let _key = authenticate(&mut props).await?; + let session = UserAgentSession::new(props); Ok(session) } diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index 9a4cf0c..2acd45b 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -1,30 +1,64 @@ use arbiter_proto::proto::user_agent::{ - AuthChallenge, UserAgentResponse, - user_agent_response::Payload as UserAgentResponsePayload, + 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::UserAgentConnection}, - db::schema, + db::{models::KeyType, schema}, }; +/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. +#[derive(Clone)] +pub enum AuthPublicKey { + Ed25519(ed25519_dalek::VerifyingKey), + /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). + EcdsaSecp256k1(k256::ecdsa::VerifyingKey), + /// RSA-2048+ public key; signature bytes are PSS+SHA-256. + Rsa(rsa::RsaPublicKey), +} + +impl AuthPublicKey { + /// Canonical bytes stored in DB and echoed back in the challenge. + /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI. + pub fn to_stored_bytes(&self) -> Vec { + match self { + AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(), + // SEC1 compressed (33 bytes) is the natural compact format for secp256k1 + AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(), + AuthPublicKey::Rsa(k) => { + use rsa::pkcs8::EncodePublicKey as _; + k.to_public_key_der() + .expect("rsa SPKI encoding is infallible") + .to_vec() + } + } + } + + pub fn key_type(&self) -> KeyType { + match self { + AuthPublicKey::Ed25519(_) => KeyType::Ed25519, + AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1, + AuthPublicKey::Rsa(_) => KeyType::Rsa, + } + } +} + pub struct ChallengeRequest { - pub pubkey: VerifyingKey, + pub pubkey: AuthPublicKey, } pub struct BootstrapAuthRequest { - pub pubkey: VerifyingKey, + pub pubkey: AuthPublicKey, pub token: String, } pub struct ChallengeContext { pub challenge: AuthChallenge, - pub key: VerifyingKey, + pub key: AuthPublicKey, } pub struct ChallengeSolution { @@ -36,8 +70,8 @@ smlang::statemachine!( 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), + Init + BootstrapAuthRequest(BootstrapAuthRequest) [async verify_bootstrap_token] / provide_key_bootstrap = AuthOk(AuthPublicKey), + SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) [async verify_solution] / provide_key = AuthOk(AuthPublicKey), } ); @@ -76,7 +110,9 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu }) } -async fn register_key(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<(), Error> { +async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> { + let pubkey_bytes = pubkey.to_stored_bytes(); + let key_type = pubkey.key_type(); let mut conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); Error::DatabasePoolUnavailable @@ -84,8 +120,9 @@ async fn register_key(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu diesel::insert_into(schema::useragent_client::table) .values(( - schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()), + schema::useragent_client::public_key.eq(pubkey_bytes), schema::useragent_client::nonce.eq(1), + schema::useragent_client::key_type.eq(key_type), )) .execute(&mut conn) .await @@ -115,15 +152,34 @@ impl AuthStateMachineContext for AuthContext<'_> { ChallengeContext { challenge, key }: &ChallengeContext, ChallengeSolution { solution }: &ChallengeSolution, ) -> Result { - let formatted_challenge = - arbiter_proto::format_challenge(challenge.nonce, &challenge.pubkey); + let formatted = 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(); + let valid = match key { + AuthPublicKey::Ed25519(vk) => { + let sig = solution.as_slice().try_into().map_err(|_| { + error!(?solution, "Invalid Ed25519 signature length"); + Error::InvalidChallengeSolution + })?; + vk.verify_strict(&formatted, &sig).is_ok() + } + AuthPublicKey::EcdsaSecp256k1(vk) => { + use k256::ecdsa::signature::Verifier as _; + let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| { + error!(?solution, "Invalid ECDSA signature bytes"); + Error::InvalidChallengeSolution + })?; + vk.verify(&formatted, &sig).is_ok() + } + AuthPublicKey::Rsa(pk) => { + use rsa::signature::Verifier as _; + let verifying_key = rsa::pss::VerifyingKey::::new(pk.clone()); + let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| { + error!(?solution, "Invalid RSA signature bytes"); + Error::InvalidChallengeSolution + })?; + verifying_key.verify(&formatted, &sig).is_ok() + } + }; Ok(valid) } @@ -132,10 +188,11 @@ impl AuthStateMachineContext for AuthContext<'_> { &mut self, ChallengeRequest { pubkey }: ChallengeRequest, ) -> Result { - let nonce = create_nonce(&self.conn.db, pubkey.as_bytes()).await?; + let stored_bytes = pubkey.to_stored_bytes(); + let nonce = create_nonce(&self.conn.db, &stored_bytes).await?; let challenge = AuthChallenge { - pubkey: pubkey.as_bytes().to_vec(), + pubkey: stored_bytes, nonce, }; @@ -171,16 +228,16 @@ impl AuthStateMachineContext for AuthContext<'_> { }) .await .map_err(|e| { - error!(?pubkey, "Failed to consume bootstrap token: {e}"); + error!(?e, "Failed to consume bootstrap token"); Error::BootstrapperActorUnreachable })?; if !token_ok { - error!(?pubkey, "Invalid bootstrap token provided"); + error!("Invalid bootstrap token provided"); return Err(Error::InvalidBootstrapToken); } - register_key(&self.conn.db, pubkey.as_bytes()).await?; + register_key(&self.conn.db, pubkey).await?; Ok(true) } @@ -188,7 +245,7 @@ impl AuthStateMachineContext for AuthContext<'_> { fn provide_key_bootstrap( &mut self, event_data: BootstrapAuthRequest, - ) -> Result { + ) -> Result { Ok(event_data.pubkey) } @@ -196,7 +253,45 @@ impl AuthStateMachineContext for AuthContext<'_> { &mut self, state_data: &ChallengeContext, _: ChallengeSolution, - ) -> Result { - Ok(state_data.key) + ) -> Result { + // ChallengeContext.key cannot be taken by value because smlang passes it by ref; + // we reconstruct stored bytes and return them wrapped in Ed25519 placeholder. + // Session uses only the raw bytes, so we carry them via a Vec. + // IMPORTANT: do NOT simplify this by storing the key type separately — the + // `AuthPublicKey` enum IS the source of truth for key bytes and type. + // + // smlang state-machine trait requires returning an owned value from `provide_key`, + // but `state_data` is only available by shared reference here. We extract the + // stored bytes and re-wrap as the correct variant so the caller can call + // `to_stored_bytes()` / `key_type()` without losing information. + let bytes = state_data.challenge.pubkey.clone(); + let key_type = state_data.key.key_type(); + let rebuilt = match key_type { + crate::db::models::KeyType::Ed25519 => { + let arr: &[u8; 32] = bytes + .as_slice() + .try_into() + .expect("ed25519 pubkey must be 32 bytes in challenge"); + AuthPublicKey::Ed25519( + ed25519_dalek::VerifyingKey::from_bytes(arr) + .expect("key was already validated in parse_auth_event"), + ) + } + crate::db::models::KeyType::EcdsaSecp256k1 => { + // bytes are SEC1 compressed (33 bytes produced by to_encoded_point(true)) + AuthPublicKey::EcdsaSecp256k1( + k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes) + .expect("ecdsa key was already validated in parse_auth_event"), + ) + } + crate::db::models::KeyType::Rsa => { + use rsa::pkcs8::DecodePublicKey as _; + AuthPublicKey::Rsa( + rsa::RsaPublicKey::from_public_key_der(&bytes) + .expect("rsa key was already validated in parse_auth_event"), + ) + } + }; + Ok(rebuilt) } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index ae4cde8..b686796 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -3,17 +3,15 @@ use std::{ops::DerefMut, sync::Mutex}; use arbiter_proto::proto::{ evm as evm_proto, user_agent::{ - ClientConnectionCancel, ClientConnectionRequest, UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, - UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload, + ClientConnectionCancel, ClientConnectionRequest, 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, messages, prelude::Context, -}; +use kameo::{Actor, error::SendError, messages, prelude::Context}; use memsafe::MemSafe; use tokio::{select, sync::watch}; use tracing::{error, info}; @@ -41,15 +39,13 @@ pub enum Error { pub struct UserAgentSession { props: UserAgentConnection, - key: VerifyingKey, state: UserAgentStateMachine, } impl UserAgentSession { - pub(crate) fn new(props: UserAgentConnection, key: VerifyingKey) -> Self { + pub(crate) fn new(props: UserAgentConnection) -> Self { Self { props, - key, state: UserAgentStateMachine::new(DummyContext), } } @@ -114,7 +110,7 @@ impl UserAgentSession { #[messages] impl UserAgentSession { - // TODO: Think about refactoring it to state-machine based flow, as we already have one + // TODO: Think about refactoring it to state-machine based flow, as we already have one #[message(ctx)] pub async fn request_new_client_approval( &mut self, @@ -123,12 +119,9 @@ impl UserAgentSession { ctx: &mut Context>, ) -> Result { self.send_msg( - UserAgentResponsePayload::ClientConnectionRequest( - ClientConnectionRequest { - pubkey: client_pubkey.as_bytes().to_vec(), - } - .into(), - ), + UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { + pubkey: client_pubkey.as_bytes().to_vec(), + }), ctx, ) .await?; @@ -150,12 +143,12 @@ impl UserAgentSession { UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}), ctx, ).await?; - return Ok(false); + Ok(false) } result = self.expect_msg(extractor, ctx) => { let result = result?; info!(actor = "useragent", "received client connection approval result: approved={}", result.approved); - return Ok(result.approved); + Ok(result.approved) } } } @@ -420,10 +413,8 @@ impl UserAgentSession { use arbiter_proto::transport::DummyTransport; let transport: super::Transport = Box::new(DummyTransport::new()); let props = UserAgentConnection::new(db, actors, transport); - let key = VerifyingKey::from_bytes(&[0u8; 32]).unwrap(); Self { props, - key, state: UserAgentStateMachine::new(DummyContext), } } diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index 69d7539..d8771dc 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -135,10 +135,10 @@ pub async fn create_test_pool() -> DatabasePool { let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16); let file = std::env::temp_dir().join(tempfile_name); - let url = format!( - "{}?mode=rwc", - file.to_str().expect("temp file path is not valid UTF-8") - ); + let url = file + .to_str() + .expect("temp file path is not valid UTF-8") + .to_string(); create_pool(Some(&url)) .await diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 1c01ed6..92c6d85 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -12,8 +12,6 @@ use diesel::{prelude::*, sqlite::Sqlite}; use restructed::Models; pub mod types { - use std::os::unix; - use chrono::{DateTime, Utc}; use diesel::{ deserialize::{FromSql, FromSqlRow}, @@ -74,6 +72,43 @@ pub mod types { Ok(SqliteTimestamp(datetime)) } } + + /// Key algorithm stored in the `useragent_client.key_type` column. + /// Values must stay stable — they are persisted in the database. + #[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression)] + #[diesel(sql_type = Integer)] + #[repr(i32)] + pub enum KeyType { + Ed25519 = 1, + EcdsaSecp256k1 = 2, + Rsa = 3, + } + + impl ToSql for KeyType { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + out.set_value(*self as i32); + Ok(IsNull::No) + } + } + + impl FromSql for KeyType { + fn from_sql( + mut bytes: ::RawValue<'_>, + ) -> diesel::deserialize::Result { + let Some(SqliteType::Long) = bytes.value_type() else { + return Err("Expected Integer for KeyType".into()); + }; + match bytes.read_long() { + 1 => Ok(KeyType::Ed25519), + 2 => Ok(KeyType::EcdsaSecp256k1), + 3 => Ok(KeyType::Rsa), + other => Err(format!("Unknown KeyType discriminant: {other}").into()), + } + } + } } pub use types::*; @@ -171,6 +206,7 @@ pub struct UseragentClient { pub public_key: Vec, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, + pub key_type: KeyType, } #[derive(Models, Queryable, Debug, Insertable, Selectable)] diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 24c0777..8d60f4b 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -153,6 +153,7 @@ diesel::table! { public_key -> Binary, created_at -> Integer, updated_at -> Integer, + key_type -> Integer, } } diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index 17af990..edcc1d2 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -1,5 +1,5 @@ use arbiter_proto::proto::user_agent::{ - AuthChallengeRequest, AuthChallengeSolution, UserAgentRequest, + AuthChallengeRequest, AuthChallengeSolution, KeyType as ProtoKeyType, UserAgentRequest, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }; @@ -38,6 +38,7 @@ pub async fn test_bootstrap_token_auth() { AuthChallengeRequest { pubkey: pubkey_bytes, bootstrap_token: Some(token), + key_type: ProtoKeyType::Ed25519.into(), }, )), }) @@ -74,6 +75,7 @@ pub async fn test_bootstrap_invalid_token_auth() { AuthChallengeRequest { pubkey: pubkey_bytes, bootstrap_token: Some("invalid_token".to_string()), + key_type: ProtoKeyType::Ed25519.into(), }, )), }) @@ -102,10 +104,14 @@ pub async fn test_challenge_auth() { let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); + // Pre-register key with key_type { let mut conn = db.get().await.unwrap(); insert_into(schema::useragent_client::table) - .values(schema::useragent_client::public_key.eq(pubkey_bytes.clone())) + .values(( + schema::useragent_client::public_key.eq(pubkey_bytes.clone()), + schema::useragent_client::key_type.eq(1i32), + )) .execute(&mut conn) .await .unwrap(); @@ -122,6 +128,7 @@ pub async fn test_challenge_auth() { AuthChallengeRequest { pubkey: pubkey_bytes, bootstrap_token: None, + key_type: ProtoKeyType::Ed25519.into(), }, )), }) diff --git a/server/crates/arbiter-useragent/Cargo.toml b/server/crates/arbiter-useragent/Cargo.toml index 8b6b85b..7b08cbf 100644 --- a/server/crates/arbiter-useragent/Cargo.toml +++ b/server/crates/arbiter-useragent/Cargo.toml @@ -14,6 +14,11 @@ tracing.workspace = true ed25519-dalek.workspace = true smlang.workspace = true x25519-dalek.workspace = true +k256.workspace = true +rsa.workspace = true +sha2.workspace = true +spki.workspace = true +rand.workspace = true thiserror.workspace = true tokio-stream.workspace = true http = "1.4.0" diff --git a/server/crates/arbiter-useragent/src/grpc.rs b/server/crates/arbiter-useragent/src/grpc.rs index 593fd88..1c15995 100644 --- a/server/crates/arbiter-useragent/src/grpc.rs +++ b/server/crates/arbiter-useragent/src/grpc.rs @@ -1,12 +1,11 @@ use arbiter_proto::{ proto::{ - user_agent::{UserAgentRequest, UserAgentResponse}, arbiter_service_client::ArbiterServiceClient, + user_agent::{UserAgentRequest, UserAgentResponse}, }, transport::{IdentityRecvConverter, IdentitySendConverter, grpc}, url::ArbiterUrl, }; -use ed25519_dalek::SigningKey; use kameo::actor::{ActorRef, Spawn}; use tokio::sync::mpsc; @@ -14,6 +13,7 @@ use tokio_stream::wrappers::ReceiverStream; use tonic::transport::ClientTlsConfig; +use super::{SigningKeyEnum, UserAgentActor}; #[derive(Debug, thiserror::Error)] pub enum ConnectError { @@ -30,8 +30,6 @@ pub enum ConnectError { Grpc(#[from] tonic::Status), } - use super::UserAgentActor; - pub type UserAgentGrpc = ActorRef< UserAgentActor< grpc::GrpcAdapter< @@ -42,7 +40,7 @@ pub type UserAgentGrpc = ActorRef< >; pub async fn connect_grpc( url: ArbiterUrl, - key: SigningKey, + key: SigningKeyEnum, ) -> Result { let bootstrap_token = url.bootstrap_token.clone(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); diff --git a/server/crates/arbiter-useragent/src/lib.rs b/server/crates/arbiter-useragent/src/lib.rs index 88f6e8d..b2f3ee3 100644 --- a/server/crates/arbiter-useragent/src/lib.rs +++ b/server/crates/arbiter-useragent/src/lib.rs @@ -1,19 +1,80 @@ use arbiter_proto::{ format_challenge, proto::user_agent::{ - AuthChallengeRequest, AuthChallengeSolution, AuthOk, + AuthChallengeRequest, AuthChallengeSolution, AuthOk, KeyType as ProtoKeyType, UserAgentRequest, UserAgentResponse, 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 tokio::select; use tracing::{error, info}; +/// Signing key variants supported by the user-agent auth protocol. +pub enum SigningKeyEnum { + Ed25519(ed25519_dalek::SigningKey), + /// secp256k1 ECDSA; public key is sent as DER SPKI; signature is raw 64-byte (r||s). + EcdsaSecp256k1(k256::ecdsa::SigningKey), + /// RSA; public key is sent as DER SPKI; signature is PSS+SHA-256. + Rsa(rsa::RsaPrivateKey), +} + +impl SigningKeyEnum { + /// Returns the canonical public key bytes to include in `AuthChallengeRequest.pubkey`. + pub fn pubkey_bytes(&self) -> Vec { + match self { + SigningKeyEnum::Ed25519(k) => k.verifying_key().to_bytes().to_vec(), + // 33-byte SEC1 compressed point — compact and natively supported by secp256k1 tooling + SigningKeyEnum::EcdsaSecp256k1(k) => { + k.verifying_key().to_encoded_point(true).as_bytes().to_vec() + } + SigningKeyEnum::Rsa(k) => { + use rsa::pkcs8::EncodePublicKey as _; + k.to_public_key() + .to_public_key_der() + .expect("rsa SPKI encoding is infallible") + .to_vec() + } + } + } + + /// Returns the proto `KeyType` discriminant to send in `AuthChallengeRequest.key_type`. + pub fn proto_key_type(&self) -> ProtoKeyType { + match self { + SigningKeyEnum::Ed25519(_) => ProtoKeyType::Ed25519, + SigningKeyEnum::EcdsaSecp256k1(_) => ProtoKeyType::EcdsaSecp256k1, + SigningKeyEnum::Rsa(_) => ProtoKeyType::Rsa, + } + } + + /// Signs `msg` and returns raw signature bytes matching the server-side verification. + pub fn sign(&self, msg: &[u8]) -> Vec { + match self { + SigningKeyEnum::Ed25519(k) => { + use ed25519_dalek::Signer as _; + k.sign(msg).to_bytes().to_vec() + } + SigningKeyEnum::EcdsaSecp256k1(k) => { + use k256::ecdsa::signature::Signer as _; + let sig: k256::ecdsa::Signature = k.sign(msg); + sig.to_bytes().to_vec() + } + SigningKeyEnum::Rsa(k) => { + use rsa::signature::RandomizedSigner as _; + let signing_key = rsa::pss::BlindedSigningKey::::new(k.clone()); + // Use rand_core OsRng from the rsa crate's re-exported rand_core (0.6.x), + // which is the version rsa's signature API expects. + let sig = signing_key.sign_with_rng(&mut rsa::rand_core::OsRng, msg); + use rsa::signature::SignatureEncoding as _; + sig.to_vec() + } + } + } +} + statemachine! { name: UserAgent, custom_error: false, @@ -50,7 +111,7 @@ pub struct UserAgentActor where Transport: Bi, { - key: SigningKey, + key: SigningKeyEnum, bootstrap_token: Option, state: UserAgentStateMachine, transport: Transport, @@ -60,7 +121,7 @@ impl UserAgentActor where Transport: Bi, { - pub fn new(key: SigningKey, bootstrap_token: Option, transport: Transport) -> Self { + pub fn new(key: SigningKeyEnum, bootstrap_token: Option, transport: Transport) -> Self { Self { key, bootstrap_token, @@ -79,8 +140,9 @@ where async fn send_auth_challenge_request(&mut self) -> Result<(), InboundError> { let req = AuthChallengeRequest { - pubkey: self.key.verifying_key().to_bytes().to_vec(), + pubkey: self.key.pubkey_bytes(), bootstrap_token: self.bootstrap_token.take(), + key_type: self.key.proto_key_type().into(), }; self.transition(UserAgentEvents::SentAuthChallengeRequest)?; @@ -103,9 +165,9 @@ where self.transition(UserAgentEvents::ReceivedAuthChallenge)?; let formatted = format_challenge(challenge.nonce, &challenge.pubkey); - let signature = self.key.sign(&formatted); + let signature_bytes = self.key.sign(&formatted); let solution = AuthChallengeSolution { - signature: signature.to_bytes().to_vec(), + signature: signature_bytes, }; self.transport @@ -127,7 +189,7 @@ where pub async fn process_inbound_transport( &mut self, - inbound: UserAgentResponse + inbound: UserAgentResponse, ) -> Result<(), InboundError> { let payload = inbound .payload @@ -192,4 +254,4 @@ where } mod grpc; -pub use grpc::{connect_grpc, ConnectError}; +pub use grpc::{ConnectError, connect_grpc}; diff --git a/server/crates/arbiter-useragent/tests/auth.rs b/server/crates/arbiter-useragent/tests/auth.rs index 8d79bbe..3b6b35a 100644 --- a/server/crates/arbiter-useragent/tests/auth.rs +++ b/server/crates/arbiter-useragent/tests/auth.rs @@ -1,19 +1,18 @@ use arbiter_proto::{ format_challenge, proto::user_agent::{ - AuthChallenge, AuthOk, - UserAgentRequest, UserAgentResponse, + AuthChallenge, AuthOk, UserAgentRequest, UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }, transport::Bi, }; -use arbiter_useragent::UserAgentActor; +use arbiter_useragent::{SigningKeyEnum, UserAgentActor}; +use async_trait::async_trait; 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, @@ -22,7 +21,10 @@ struct TestTransport { #[async_trait] impl Bi for TestTransport { - async fn send(&mut self, item: UserAgentRequest) -> Result<(), arbiter_proto::transport::Error> { + async fn send( + &mut self, + item: UserAgentRequest, + ) -> Result<(), arbiter_proto::transport::Error> { self.outbound_tx .send(item) .await @@ -51,14 +53,14 @@ fn make_transport() -> ( ) } -fn test_key() -> SigningKey { - SigningKey::from_bytes(&[7u8; 32]) +fn test_key() -> SigningKeyEnum { + SigningKeyEnum::Ed25519(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 pubkey = key.pubkey_bytes(); let bootstrap_token = Some("bootstrap-123".to_string()); let (transport, inbound_tx, mut outbound_rx) = make_transport(); @@ -86,7 +88,7 @@ async fn sends_auth_request_on_start_with_bootstrap_token() { #[tokio::test] async fn challenge_flow_sends_solution_from_transport_inbound() { let key = test_key(); - let verify_key = key.verifying_key(); + let pubkey_bytes = key.pubkey_bytes(); let (transport, inbound_tx, mut outbound_rx) = make_transport(); let actor = UserAgentActor::spawn(UserAgentActor::new(key, None, transport)); @@ -97,7 +99,7 @@ async fn challenge_flow_sends_solution_from_transport_inbound() { .expect("missing initial auth request"); let challenge = AuthChallenge { - pubkey: verify_key.to_bytes().to_vec(), + pubkey: pubkey_bytes.clone(), nonce: 42, }; inbound_tx @@ -119,13 +121,16 @@ async fn challenge_flow_sends_solution_from_transport_inbound() { panic!("expected auth challenge solution"); }; + // Verify the signature using the Ed25519 verifying key let formatted = format_challenge(challenge.nonce, &challenge.pubkey); + let raw_key = SigningKey::from_bytes(&[7u8; 32]); let sig: ed25519_dalek::Signature = solution .signature .as_slice() .try_into() .expect("signature bytes length"); - verify_key + raw_key + .verifying_key() .verify_strict(&formatted, &sig) .expect("solution signature should verify");