feat(user-agent-auth): add RSA and ECDSA auth key types
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed

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.
This commit is contained in:
2026-03-14 12:14:30 +01:00
parent a3c401194f
commit 6030f30901
20 changed files with 556 additions and 124 deletions

View File

@@ -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"

View File

@@ -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<UserAgentGrpc, ConnectError> {
let bootstrap_token = url.bootstrap_token.clone();
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();

View File

@@ -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<u8> {
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<u8> {
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::<sha2::Sha256>::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<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
key: SigningKey,
key: SigningKeyEnum,
bootstrap_token: Option<String>,
state: UserAgentStateMachine<DummyContext>,
transport: Transport,
@@ -60,7 +121,7 @@ impl<Transport> UserAgentActor<Transport>
where
Transport: Bi<UserAgentResponse, UserAgentRequest>,
{
pub fn new(key: SigningKey, bootstrap_token: Option<String>, transport: Transport) -> Self {
pub fn new(key: SigningKeyEnum, bootstrap_token: Option<String>, 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};

View File

@@ -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<UserAgentResponse>,
@@ -22,7 +21,10 @@ struct TestTransport {
#[async_trait]
impl Bi<UserAgentResponse, UserAgentRequest> 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");