7 Commits

Author SHA1 Message Date
Skipper
4a8e51ef32 docs: updated to new auth challenge format and removed stale TOCTOU race condition note
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-17 18:25:55 +02:00
Skipper
9ee86afc19 fix(useragent): now using new challenge format 2026-04-17 18:19:51 +02:00
Skipper
790026e93b fix(server::tests): api surface of auth challenge changed 2026-04-17 17:58:22 +02:00
Skipper
0e09afda5d refactor(server::{useragent::auth, client::auth}): use random based + timestamp nonce instead of monotonic counter in database 2026-04-17 17:44:42 +02:00
Skipper
51e6571d80 refactor(server): now keeps track of useragents, instead of 2026-04-17 00:00:43 +02:00
Skipper
3b828d5874 refactor(server::grpc::vault_gate): standard approach using / traits 2026-04-16 22:15:18 +02:00
Skipper
a6f94e3115 fix(server): sending fixed vault state when on stage 2026-04-16 19:36:41 +02:00
49 changed files with 1396 additions and 1211 deletions

View File

@@ -29,38 +29,23 @@ flowchart TD
A([Client connects]) --> B[Receive AuthChallengeRequest]
B --> C{pubkey in DB?}
C -- yes --> D[Read nonce\nIncrement nonce in DB]
D --> G
C -- yes --> G[Generate AuthChallenge]
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
E --> F{First response}
F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nUserAgent requests]
F2 --> F3[INSERT client\nnonce = 1]
F3 --> G[Send AuthChallenge\nwith nonce]
F2 --> F3[INSERT client]
F3 --> G
G --> H[Receive AuthChallengeSolution]
H --> I{Signature valid?}
I -- no --> Z
I -- yes --> J([Session started])
G --> H[Send AuthChallenge\ntimestamp + random bytes]
H --> I[Receive AuthChallengeSolution]
I --> K{Signature valid?}
K -- no --> Z
K -- yes --> J([Session started])
```
### Known Issue: Concurrent Registration Race (TOCTOU)
Two connections presenting the same previously-unknown public key can race through the approval flow simultaneously:
1. Both check the DB → neither is registered.
2. Both request approval from user agents → both receive approval.
3. Both `INSERT` the client record → the second insert silently overwrites the first, resetting the nonce.
This means the first connection's nonce is invalidated by the second, causing its challenge verification to fail. A fix requires either serialising new-client registration (e.g. an in-memory lock keyed on pubkey) or replacing the separate check + insert with an `INSERT OR IGNORE` / upsert guarded by a unique constraint on `public_key`.
### Nonce Semantics
The `program_client.nonce` column stores the **next usable nonce** — i.e. it is always one ahead of the nonce last issued in a challenge.
- **New client:** inserted with `nonce = 1`; the first challenge is issued with `nonce = 0`.
- **Existing client:** the current DB value is read and used as the challenge nonce, then immediately incremented within the same exclusive transaction, preventing replay.
Auth challenges are generated from fresh random bytes plus a timestamp. They are signed as the canonical challenge payload and are not persisted in `program_client`.
---

View File

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

View File

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

3
server/Cargo.lock generated
View File

@@ -683,6 +683,7 @@ dependencies = [
"arbiter-crypto",
"arbiter-proto",
"async-trait",
"chrono",
"http",
"rand 0.10.1",
"rustls-webpki",
@@ -696,7 +697,7 @@ dependencies = [
name = "arbiter-crypto"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"memsafe",
"ml-dsa",
"rand 0.10.1",

View File

@@ -24,3 +24,4 @@ http = "1.4.0"
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }
async-trait.workspace = true
rand.workspace = true
chrono.workspace = true

View File

@@ -1,4 +1,4 @@
use arbiter_crypto::authn::{CLIENT_CONTEXT, SigningKey, format_challenge};
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
use arbiter_proto::{
ClientMetadata,
proto::{
@@ -15,6 +15,7 @@ use arbiter_proto::{
shared::ClientInfo as ProtoClientInfo,
},
};
use chrono::DateTime;
use crate::{
storage::StorageError,
@@ -23,6 +24,8 @@ use crate::{
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Server sent invalid auth challenge")]
InvalidChallenge,
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
@@ -98,7 +101,15 @@ async fn send_auth_challenge_solution(
key: &SigningKey,
challenge: AuthChallenge,
) -> std::result::Result<(), AuthError> {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let challenge = authn::AuthChallenge {
nonce: *challenge
.random
.as_array()
.ok_or(AuthError::InvalidChallenge)?,
timestamp,
};
let challenge_payload: Vec<u8> = challenge.format();
let signature = key
.sign_message(&challenge_payload, CLIENT_CONTEXT)
.map_err(|_| AuthError::UnexpectedAuthResponse)?

View File

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

View File

@@ -1,17 +1,56 @@
use std::hash::Hash;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use chrono::{DateTime, Utc};
use ml_dsa::{
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
};
use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
concat_form.into_bytes()
const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone)]
pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE],
pub timestamp: DateTime<Utc>,
}
impl AuthChallenge {
pub fn generate(rng: &mut impl rand::CryptoRng) -> Self {
let timestamp = Utc::now();
let nonce = {
let mut array = [0; NONCE_SIZE];
rng.fill(&mut array);
array
};
Self { nonce, timestamp }
}
pub fn format(&self) -> Vec<u8> {
{
let mut buffer = Vec::from(self.nonce);
let stamp = self
.timestamp
.timestamp_nanos_opt()
.expect("We would be long dead by the time this triggers :)");
buffer.extend_from_slice(stamp.to_be_bytes().as_slice());
buffer
}
}
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, ()> {
let random_nonce = nonce.as_array().ok_or(())?;
Ok(AuthChallenge {
nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp),
})
}
}
pub type KeyParams = MlDsa87;
@@ -36,12 +75,10 @@ impl PublicKey {
self.0.encode().to_vec()
}
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
self.0.verify_with_context(
&format_challenge(nonce, &self.to_bytes()),
context,
&signature.0,
)
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
let challenge = challenge.format();
self.0
.verify_with_context(&challenge, context, &signature.0)
}
}
@@ -75,11 +112,14 @@ impl SigningKey {
.map(Into::into)
}
pub fn sign_challenge(&self, nonce: i32, context: &[u8]) -> Result<Signature, Error> {
self.sign_message(
&format_challenge(nonce, &self.public_key().to_bytes()),
context,
)
pub fn sign_challenge(
&self,
challenge: &AuthChallenge,
context: &[u8],
) -> Result<Signature, Error> {
let challenge = challenge.format();
self.sign_message(&challenge, context)
}
}
@@ -140,6 +180,8 @@ impl TryFrom<&'_ [u8]> for Signature {
mod tests {
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
#[test]
@@ -169,13 +211,13 @@ mod tests {
fn challenge_verification_uses_context_and_canonical_key_bytes() {
let key = SigningKey::generate();
let public_key = key.public_key();
let nonce = 17;
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = key
.sign_challenge(nonce, CLIENT_CONTEXT)
.sign_challenge(&challenge, CLIENT_CONTEXT)
.expect("signature should be created");
assert!(public_key.verify(nonce, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(nonce, USERAGENT_CONTEXT, &signature));
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
}
#[test]
@@ -185,10 +227,16 @@ mod tests {
assert_eq!(restored.public_key(), original.public_key());
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = restored
.sign_challenge(9, CLIENT_CONTEXT)
.sign_challenge(&challenge, CLIENT_CONTEXT)
.expect("signature should be created");
assert!(restored.public_key().verify(9, CLIENT_CONTEXT, &signature));
assert!(
restored
.public_key()
.verify(&challenge, CLIENT_CONTEXT, &signature)
);
}
}

View File

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

View File

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

View File

@@ -4,7 +4,11 @@ use thiserror::Error;
use crate::{
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, vault::Vault,
bootstrap::Bootstrapper,
evm::EvmActor,
flow_coordinator::FlowCoordinator,
useragent_registry::UserAgentRegistry,
vault::Vault,
},
db,
};
@@ -30,6 +34,7 @@ pub struct GlobalActors {
pub vault: ActorRef<Vault>,
pub bootstrapper: ActorRef<Bootstrapper>,
pub flow_coordinator: ActorRef<FlowCoordinator>,
pub useragent_registry: ActorRef<UserAgentRegistry>,
pub evm: ActorRef<EvmActor>,
pub events: ActorRef<MessageBus>,
}
@@ -42,11 +47,15 @@ impl GlobalActors {
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let message_bus = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let useragent_registry = UserAgentRegistry::spawn(UserAgentRegistry::default());
Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
vault: key_holder,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()),
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
useragent_registry.clone(),
)),
useragent_registry,
events: message_bus,
})
}

View File

@@ -1,57 +1,58 @@
use alloy::primitives::map::HashMap;
use arbiter_crypto::authn;
use kameo::{error::Infallible, prelude::*};
use std::{collections::HashMap, ops::ControlFlow};
use crate::{db::DatabasePool, peers::user_agent::{Credentials, UserAgentSession}};
use kameo::{
Actor,
actor::{ActorId, ActorRef},
error::Infallible,
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
};
use tracing::info;
use super::vault::{Vault, events as vault_events};
pub struct Args {
pub vault: ActorRef<Vault>,
pub pool: DatabasePool,
}
use crate::peers::user_agent::UserAgentSession;
#[derive(Default)]
pub struct UserAgentRegistry {
vault: ActorRef<Vault>,
pool: DatabasePool,
connected: HashMap<Credentials, ActorRef<UserAgentSession>>,
connected: HashMap<ActorId, ActorRef<UserAgentSession>>,
}
impl Message<vault_events::Bootstrapped> for UserAgentRegistry {
type Reply = ();
async fn handle(
&mut self,
msg: vault_events::Bootstrapped,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
todo!()
}
}
impl Message<vault_events::Unsealed> for UserAgentRegistry {
type Reply = ();
async fn handle(
&mut self,
msg: vault_events::Unsealed,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
todo!()
}
}
impl Actor for UserAgentRegistry {
type Args = Args;
type Args = Self;
type Error = Infallible;
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(Self {
vault: args.vault,
pool: args.pool,
connected: HashMap::default(),
})
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
id: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.connected.remove(&id).is_some() {
info!(?id, actor = "UserAgentRegistry", event = "useragent.disconnected");
}
Ok(ControlFlow::Continue(()))
}
}
#[messages]
impl UserAgentRegistry {
#[message(ctx)]
pub async fn connect_useragent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "UserAgentRegistry", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.connected.insert(actor.id(), actor);
}
#[message]
pub fn get_connected(&self) -> Vec<ActorRef<UserAgentSession>> {
self.connected.values().cloned().collect()
}
}

View File

@@ -1,9 +1,9 @@
use crate::{actors::vault::{self, GetState}, crypto::integrity::hashing::Hashable};
use crate::{
actors::vault::{self, GetState},
crypto::integrity::hashing::Hashable,
};
use hmac::Hmac;
use sha2::Sha256;
use std::future::Future;
use std::ops::Deref;
use std::pin::Pin;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
@@ -11,23 +11,16 @@ use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _;
pub mod hashing;
pub mod verified;
use crate::{
actors::vault::{SignIntegrity, Vault, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope as IntegrityEnvelopeRow, NewIntegrityEnvelope},
models::{IntegrityEnvelope, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>;
pub use self::verified::{Nested, VerificationOrigin, Verified};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
@@ -56,90 +49,71 @@ pub enum Error {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum AttestationStatus {
Attested,
Unavailable,
}
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>;
pub trait Integrable: Hashable {
const KIND: &'static str;
const VERSION: i32 = 1;
}
impl<T: Integrable> Integrable for &T {
const KIND: &'static str = T::KIND;
const VERSION: i32 = T::VERSION;
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
}
#[derive(Debug, Clone)]
pub struct EntityId(Vec<u8>);
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
impl Deref for EntityId {
type Target = [u8];
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
fn deref(&self) -> &Self::Target {
&self.0
pub trait IntoId {
fn into_id(self) -> Vec<u8>;
}
impl IntoId for i32 {
fn into_id(self) -> Vec<u8> {
self.to_be_bytes().to_vec()
}
}
impl From<i32> for EntityId {
fn from(value: i32) -> Self {
Self(value.to_be_bytes().to_vec())
impl IntoId for &'_ [u8] {
fn into_id(self) -> Vec<u8> {
self.to_vec()
}
}
impl From<&'_ [u8]> for EntityId {
fn from(bytes: &'_ [u8]) -> Self {
Self(bytes.to_vec())
}
}
pub async fn lookup_verified<E, Id, C, F, Fut>(
conn: &mut C,
vault: &ActorRef<Vault>,
entity_id: Id,
load: F,
) -> Result<VerifiedEntity<E, Id>, Error>
where
C: AsyncConnection<Backend = Sqlite>,
E: Integrable,
Id: Into<EntityId> + Clone,
F: FnOnce(&mut C) -> Fut,
Fut: Future<Output = Result<E, db::DatabaseError>>,
{
let entity = load(conn).await?;
verify_entity(conn, vault, entity, entity_id).await
}
pub async fn lookup_verified_from_query<E, Id, C, F>(
conn: &mut C,
vault: &ActorRef<Vault>,
load: F,
) -> Result<VerifiedEntity<E, Id>, Error>
where
C: AsyncConnection<Backend = Sqlite> + Send,
E: Integrable,
Id: Into<EntityId> + Clone,
F: for<'a> FnOnce(
&'a mut C,
) -> Pin<
Box<dyn Future<Output = Result<(Id, E), db::DatabaseError>> + Send + 'a>,
>,
{
let (entity_id, entity) = load(conn).await?;
verify_entity(conn, vault, entity, entity_id).await
}
pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: &E,
as_entity_id: Id,
) -> Result<Verified<Id, Nested<E>>, Error> {
let payload_hash = payload_hash(entity);
entity_id: impl IntoId,
) -> Result<(), Error> {
let payload_hash = payload_hash(&entity);
let entity_id = as_entity_id.clone().into();
let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
@@ -155,7 +129,7 @@ pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id: entity_id.to_vec(),
entity_id,
payload_version: E::VERSION,
key_version,
mac: mac.to_vec(),
@@ -174,19 +148,19 @@ pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
.await
.map_err(db::DatabaseError::from)?;
Ok(Verified::<Id, Nested<E>>::new(as_entity_id))
Ok(())
}
pub async fn check_entity_attestation<E: Integrable>(
pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: &E,
entity_id: impl Into<EntityId>,
entity_id: impl IntoId,
) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into();
let envelope: IntegrityEnvelopeRow = integrity_envelope::table
let entity_id = entity_id.into_id();
let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&*entity_id))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.first(conn)
.await
.map_err(|err| match err {
@@ -204,7 +178,7 @@ pub async fn check_entity_attestation<E: Integrable>(
});
}
let payload_hash = payload_hash(entity);
let payload_hash = payload_hash(&entity);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let result = vault
@@ -220,111 +194,24 @@ pub async fn check_entity_attestation<E: Integrable>(
Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
Err(SendError::HandlerError(vault::Error::Sealed)) => {
Ok(AttestationStatus::Unavailable)
}
Err(_) => Err(Error::VaultSend),
}
}
#[derive(Debug, Clone)]
#[repr(C)]
pub struct VerifiedEntity<E, Id> {
pub entity: Verified<E>,
pub entity_id: Verified<Id, Nested<E>>,
}
impl<E, Id> Deref for VerifiedEntity<E, Id> {
type Target = Verified<E>;
fn deref(&self) -> &Self::Target {
&self.entity
}
}
pub async fn verify_entity<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: E,
entity_id: Id,
) -> Result<VerifiedEntity<E, Id>, Error> {
match check_entity_attestation(conn, vault, &entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(VerifiedEntity {
entity: Verified::new(entity),
entity_id: Verified::new(entity_id),
}),
AttestationStatus::Unavailable => Err(Error::Vault(vault::Error::Sealed)),
}
}
pub async fn verify_entity_ref<'e, E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: &'e E,
entity_id: Id,
) -> Result<Verified<VerifiedEntity<&'e E, Id>, Nested<E>>, Error> {
match check_entity_attestation(conn, vault, entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(Verified::<VerifiedEntity<&'e E, Id>, Nested<E>>::new(
VerifiedEntity {
entity: Verified::new(entity),
entity_id: Verified::new(entity_id),
},
)),
AttestationStatus::Unavailable => Err(Error::Vault(vault::Error::Sealed)),
}
}
pub async fn delete_envelope<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
entity_id: impl Into<EntityId>,
) -> Result<usize, Error> {
let entity_id = entity_id.into();
let affected = diesel::delete(
integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&*entity_id)),
)
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(affected)
}
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
Ok(matches!(state, vault::VaultState::Unsealed))
}
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
#[cfg(test)]
mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use sha2::Digest;
use crate::{
@@ -337,7 +224,7 @@ mod tests {
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::hashing::Hashable;
use super::{Error, Integrable, check_entity_attestation, sign_entity};
use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone)]
struct DummyEntity {
@@ -385,8 +272,7 @@ mod tests {
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
.unwrap();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
@@ -397,11 +283,9 @@ mod tests {
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
let status = check_entity_attestation(&mut conn, &vault, &entity, ENTITY_ID)
verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
assert!(matches!(status, super::AttestationStatus::Attested));
}
#[tokio::test]
@@ -419,8 +303,7 @@ mod tests {
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
.unwrap();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
@@ -430,7 +313,35 @@ mod tests {
.await
.unwrap();
let err = check_entity_attestation(&mut conn, &vault, &entity, ENTITY_ID)
let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn changed_payload_fails_verification() {
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));

View File

@@ -1,151 +0,0 @@
use std::ops::Deref;
use super::Integrable;
mod private {
pub trait Sealed {}
}
/// Marker trait for type-level verification provenance.
///
/// This trait is intentionally sealed so external code cannot invent arbitrary
/// provenance tags and bypass the intended type-level guarantees.
pub trait VerificationOrigin: private::Sealed {
type Origin: VerificationOrigin;
}
/// Root provenance marker for values directly produced by integrity APIs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Root;
impl private::Sealed for Root {}
impl VerificationOrigin for Root {
type Origin = Self;
}
/// Nested provenance marker carrying the source integrable type and previous
/// provenance marker in the chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Nested<From, P: VerificationOrigin = Root>(core::marker::PhantomData<(From, P)>);
impl<T, P: VerificationOrigin> private::Sealed for Nested<T, P> {}
impl<T, P: VerificationOrigin> VerificationOrigin for Nested<T, P> {
type Origin = P::Origin;
}
#[derive(Debug, Clone, PartialEq, Eq)]
// #[derive(Copy)] // fixme!: soundness: Unimplemented Copy helps to avoid accidentally origin-unqualifying due to Deref impl.
#[repr(transparent)]
#[must_use = "Verified<T> is a proof-bearing wrapper; use self.drop_verification_provenance() to explicitly discard integrity provenance when needed"]
pub struct Verified<T, O: VerificationOrigin = Root> {
inner: T,
origin: core::marker::PhantomData<O>,
}
impl<T, O: VerificationOrigin> AsRef<T> for Verified<T, O> {
fn as_ref(&self) -> &T {
&self.inner
}
}
impl<T, N: Integrable, O: VerificationOrigin> Deref for Verified<T, Nested<N, O>> {
type Target = Verified<T, O::Origin>;
fn deref(&self) -> &Self::Target {
// SAFETY: `Verified<T, _>` is `#[repr(transparent)]` over `T`, so
// `&Verified<T, Nested<U, O>>` and `&Verified<T, O::Origin>` have identical layout.
unsafe { &*(self as *const Self as *const Verified<T, O::Origin>) }
}
}
impl<T> Deref for Verified<T, Root> {
type Target = T;
fn deref(&self) -> &Self::Target {
AsRef::as_ref(self)
}
}
impl<T, O: VerificationOrigin> Verified<T, O> {
/// Unwraps the verified value, discarding the integrity provenance.
pub fn drop_verification_provenance(self) -> T {
self.inner
}
/// Downgrades the origin provenance by recursively resolving the terminal
/// origin of the verification chain.
pub fn unqualify_origin(self) -> Verified<T, O::Origin> {
Verified {
inner: self.inner,
origin: core::marker::PhantomData,
}
}
/// Constructs a `Verified<T>` by wrapping a `T`.
#[cfg(not(test))]
pub(super) const fn new(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
#[cfg(test)]
pub(crate) const fn new(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::integrity::v1::hashing::Hashable;
use hmac::digest::Digest;
use std::mem::{align_of, size_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Marker;
impl Hashable for Marker {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(b"marker");
}
}
impl Integrable for Marker {
const KIND: &'static str = "marker";
}
#[test]
fn verified_root_exposes_inner_value() {
let verified = Verified::<_, Root>::new("root-value");
assert_eq!(verified.as_ref(), &"root-value");
assert_eq!(*verified, "root-value");
assert_eq!(verified.drop_verification_provenance(), "root-value");
assert_eq!(size_of::<Verified<&str>>(), size_of::<&str>());
assert_eq!(align_of::<Verified<&str>>(), align_of::<&str>());
}
#[test]
fn nested_verified_derefs_back_to_root() {
let verified: Verified<_, Nested<Marker, Nested<Marker>>> = Verified::new("nested-value");
let _: &Verified<&str, Root> = &verified;
let root_view: Verified<&str, Root> = verified.unqualify_origin();
assert_eq!(root_view.as_ref(), &"nested-value");
}
#[test]
fn nested_verified_can_be_unqualified_to_root() {
let verified: Verified<_, Nested<Marker>> = Verified::new("nested-value");
let downgraded = verified.unqualify_origin();
assert_eq!(downgraded.as_ref(), &"nested-value");
assert_eq!(downgraded.drop_verification_provenance(), "nested-value");
}
}

View File

@@ -195,7 +195,6 @@ pub struct ProgramClientMetadataHistory {
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient {
pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>,
pub metadata_id: i32,
pub created_at: SqliteTimestamp,
@@ -206,7 +205,6 @@ pub struct ProgramClient {
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient {
pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,

View File

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

View File

@@ -22,9 +22,8 @@ use tonic::Status;
use tracing::warn;
use crate::{
crypto::integrity::{Nested, Verified},
grpc::request_tracker::RequestTracker,
peers::client::{self, ClientConnection, ClientCredentials, auth},
peers::client::{self, ClientConnection, auth},
};
pub struct AuthTransportAdapter<'a> {
@@ -45,10 +44,14 @@ impl<'a> AuthTransportAdapter<'a> {
fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload {
match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => {
auth::Outbound::AuthChallenge { challenge } => {
AuthResponsePayload::Challenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes(),
nonce,
timestamp_nanos: challenge
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
random: challenge.nonce.to_vec(),
})
}
auth::Outbound::AuthSuccess => {
@@ -198,7 +201,7 @@ pub async fn start(
conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker,
) -> Result<Verified<i32, Nested<ClientCredentials>>, auth::Error> {
) -> Result<i32, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await
}

View File

@@ -19,7 +19,7 @@ use tracing::warn;
use crate::{
grpc::request_tracker::RequestTracker,
peers::user_agent::{AuthCredentials, UserAgentConnection, auth},
peers::user_agent::{Credentials, UserAgentConnection, auth},
};
pub struct AuthTransportAdapter<'a> {
@@ -77,8 +77,15 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
) -> Result<(), TransportError> {
use auth::{Error, Outbound};
let payload = match item {
Ok(Outbound::AuthChallenge { nonce }) => {
AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce })
Ok(Outbound::AuthChallenge { challenge }) => {
AuthResponsePayload::Challenge(ProtoAuthChallenge {
timestamp_nanos: challenge
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
random: challenge.nonce.to_vec(),
})
}
Ok(Outbound::AuthSuccess) => {
AuthResponsePayload::Result(ProtoAuthResult::Success.into())
@@ -183,7 +190,7 @@ pub async fn start(
conn: &mut UserAgentConnection,
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &mut RequestTracker,
) -> Result<AuthCredentials, auth::Error> {
) -> Result<Credentials, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
auth::authenticate(conn, &mut transport).await
}

View File

@@ -1,57 +1,16 @@
use arbiter_proto::{
proto::user_agent::{
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealResult as ProtoUnsealResult,
request::Payload as UnsealRequestPayload,
response::Payload as UnsealResponsePayload,
},
},
},
transport::{Bi, Error as TransportError, Receiver, Sender},
};
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
use async_trait::async_trait;
use tonic::Status;
use tracing::warn;
use super::auth::AuthTransportAdapter;
use crate::peers::user_agent::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
use crate::{
grpc::TryConvert,
peers::user_agent::vault_gate::{self as vault_gate},
};
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
impl AuthTransportAdapter<'_> {
async fn send_query_state(&mut self) -> Result<(), TransportError> {
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
self.send_response_payload(wrap_vault_response(VaultResponsePayload::State(
ProtoVaultState::Sealed.into(),
)))
.await
}
}
mod inbound;
mod outbound;
#[async_trait]
impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
@@ -60,7 +19,10 @@ impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(?error, "Failed to receive user agent request during vault gate");
warn!(
?error,
"Failed to receive user agent request during vault gate"
);
return None;
}
};
@@ -78,86 +40,12 @@ impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
return None;
};
let vault_req = match payload {
UserAgentRequestPayload::Vault(req) => req,
_ => {
let _ = self
.bi_mut()
.send(Err(Status::permission_denied(
"Only vault operations are permitted before unsealing",
)))
.await;
match payload.try_convert() {
Ok(inbound) => return Some(inbound),
Err(status) => {
let _ = self.bi_mut().send(Err(status)).await;
return None;
}
};
let Some(vault_payload) = vault_req.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing vault request payload")))
.await;
return None;
};
match vault_payload {
VaultRequestPayload::QueryState(_) => {
if self.send_query_state().await.is_err() {
return None;
}
continue;
}
VaultRequestPayload::Unseal(req) => {
let Some(unseal_payload) = req.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing unseal request payload")))
.await;
return None;
};
match unseal_payload {
UnsealRequestPayload::Start(start) => {
let Ok(bytes) = <[u8; 32]>::try_from(start.client_pubkey) else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument(
"Invalid X25519 public key",
)))
.await;
return None;
};
return Some(vault_gate::Inbound::HandleHandshake(HandleHandshake {
client_pubkey: x25519_dalek::PublicKey::from(bytes),
}));
}
UnsealRequestPayload::EncryptedKey(key) => {
return Some(vault_gate::Inbound::HandleUnsealEncryptedKey(
HandleUnsealEncryptedKey {
nonce: key.nonce,
ciphertext: key.ciphertext,
associated_data: key.associated_data,
},
));
}
}
}
VaultRequestPayload::Bootstrap(req) => {
let Some(encrypted_key) = req.encrypted_key else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument(
"Missing bootstrap encrypted key",
)))
.await;
return None;
};
return Some(vault_gate::Inbound::HandleBootstrapEncryptedKey(
HandleBootstrapEncryptedKey {
nonce: encrypted_key.nonce,
ciphertext: encrypted_key.ciphertext,
associated_data: encrypted_key.associated_data,
},
));
}
}
}
}
@@ -180,55 +68,10 @@ impl Sender<Result<vault_gate::Outbound, vault_gate::Error>> for AuthTransportAd
}
};
let payload = match outbound {
vault_gate::Outbound::HandleHandshake(Ok(response)) => {
wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
))
match outbound.try_convert() {
Ok(payload) => self.send_response_payload(payload).await,
Err(status) => self.bi_mut().send(Err(status)).await,
}
vault_gate::Outbound::HandleHandshake(Err(err)) => {
warn!(?err, "handshake failed");
return self
.bi_mut()
.send(Err(Status::internal("Failed to start unseal flow")))
.await;
}
vault_gate::Outbound::HandleUnsealEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(?err, "unseal failed");
return self
.bi_mut()
.send(Err(Status::internal("Failed to unseal vault")))
.await;
}
};
wrap_unseal_response(UnsealResponsePayload::Result(proto_result.into()))
}
vault_gate::Outbound::HandleBootstrapEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoBootstrapResult::InvalidKey,
Err(vault_gate::Error::AlreadyBootstrapped) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(?err, "bootstrap failed");
return self
.bi_mut()
.send(Err(Status::internal("Failed to bootstrap vault")))
.await;
}
};
wrap_bootstrap_response(proto_result)
}
};
self.send_response_payload(payload).await
}
}

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
#![forbid(unsafe_code)]
use crate::context::ServerContext;
pub mod actors;

View File

@@ -1,4 +1,4 @@
use arbiter_crypto::authn::{self, CLIENT_CONTEXT};
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
use arbiter_proto::{
ClientMetadata,
transport::{Bi, expect_message},
@@ -18,7 +18,7 @@ use crate::{
flow_coordinator::{self, RequestClientApproval},
vault::Vault,
},
crypto::integrity::{self, Nested, Verified},
crypto::integrity::{self, AttestationStatus},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
@@ -74,19 +74,14 @@ pub enum Inbound {
#[derive(Debug, Clone)]
pub enum Outbound {
AuthChallenge {
pubkey: authn::PublicKey,
nonce: i32,
},
AuthChallenge { challenge: AuthChallenge },
AuthSuccess,
}
/// Returns the current nonce and client ID for a registered client.
/// Returns `None` if the pubkey is not registered.
async fn get_current_nonce_and_id(
async fn get_client_id(
db: &db::DatabasePool,
pubkey: &authn::PublicKey,
) -> Result<Option<(i32, i32)>, Error> {
) -> Result<Option<i32>, Error> {
let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
@@ -94,8 +89,8 @@ async fn get_current_nonce_and_id(
})?;
program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(&mut conn)
.select(program_client::id)
.first::<i32>(&mut conn)
.await
.optional()
.map_err(|e| {
@@ -104,51 +99,41 @@ async fn get_current_nonce_and_id(
})
}
/// Atomically increments the nonce and re-signs the integrity envelope.
/// Returns the new nonce, which is used as the challenge nonce.
async fn create_nonce(
async fn verify_integrity(
db: &db::DatabasePool,
vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
) -> Result<i32, Error> {
let pubkey_bytes = pubkey.to_bytes();
let pubkey = pubkey.clone();
let mut conn = db.get().await.map_err(|e| {
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(program_client::nonce + 1))
.returning((program_client::id, program_client::nonce))
.get_result(conn)
.await?;
let id = get_client_id(db, pubkey).await?.ok_or_else(|| {
error!("Client not found during integrity verification");
Error::DatabaseOperationFailed
})?;
integrity::sign_entity(
conn,
&vault,
let attestation = integrity::verify_entity(
&mut db_conn,
vault,
&ClientCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity sign failed after nonce update");
Error::DatabaseOperationFailed
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?;
Ok(new_nonce)
})
})
.await
if attestation != AttestationStatus::Attested {
error!("Integrity attestation unavailable for client {id}");
return Err(Error::IntegrityCheckFailed);
}
Ok(())
}
async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Result<(), Error> {
@@ -176,7 +161,7 @@ async fn insert_client(
vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
metadata: &ClientMetadata,
) -> Result<Verified<i32, Nested<ClientCredentials>>, Error> {
) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client};
let pubkey = pubkey.clone();
let metadata = metadata.clone();
@@ -190,8 +175,6 @@ async fn insert_client(
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
const NONCE_START: i32 = 1;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
@@ -206,19 +189,17 @@ async fn insert_client(
.values((
program_client::public_key.eq(pubkey.to_bytes()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(NONCE_START),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(conn)
.await?;
let verified_id = integrity::sign_entity(
integrity::sign_entity(
conn,
&vault,
&ClientCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
},
client_id,
)
@@ -228,7 +209,7 @@ async fn insert_client(
Error::DatabaseOperationFailed
})?;
Ok(verified_id)
Ok(client_id)
})
})
.await
@@ -236,7 +217,7 @@ async fn insert_client(
async fn sync_client_metadata(
db: &db::DatabasePool,
client_id: &Verified<i32, Nested<ClientCredentials>>,
client_id: i32,
metadata: &ClientMetadata,
) -> Result<(), Error> {
use crate::db::schema::{client_metadata, client_metadata_history};
@@ -253,7 +234,7 @@ async fn sync_client_metadata(
Box::pin(async move {
let (current_metadata_id, current): (i32, ProgramClientMetadata) =
program_client::table
.find(client_id.as_ref())
.find(client_id)
.inner_join(client_metadata::table)
.select((
program_client::metadata_id,
@@ -272,7 +253,7 @@ async fn sync_client_metadata(
insert_into(client_metadata_history::table)
.values((
client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id.as_ref()),
client_metadata_history::client_id.eq(client_id),
))
.execute(conn)
.await?;
@@ -287,7 +268,7 @@ async fn sync_client_metadata(
.get_result::<i32>(conn)
.await?;
update(program_client::table.find(client_id.as_ref()))
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
@@ -308,15 +289,14 @@ async fn sync_client_metadata(
async fn challenge_client<T>(
transport: &mut T,
pubkey: authn::PublicKey,
nonce: i32,
challenge: AuthChallenge,
) -> Result<(), Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
{
transport
.send(Ok(Outbound::AuthChallenge {
pubkey: pubkey.clone(),
nonce,
challenge: challenge.clone(),
}))
.await
.map_err(|e| {
@@ -334,7 +314,7 @@ where
Error::Transport
})?;
if !pubkey.verify(nonce, CLIENT_CONTEXT, &signature) {
if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) {
error!("Challenge solution verification failed");
return Err(Error::InvalidChallengeSolution);
}
@@ -342,10 +322,7 @@ where
Ok(())
}
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<Verified<i32, Nested<ClientCredentials>>, Error>
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
@@ -353,28 +330,10 @@ where
return Err(Error::Transport);
};
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
Some((id, nonce)) => {
let mut db_conn = props.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
integrity::verify_entity(
&mut db_conn,
&props.actors.vault,
ClientCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?
.entity_id
let client_id = match get_client_id(&props.db, &pubkey).await? {
Some(id) => {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
id
}
None => {
approve_new_client(
@@ -389,9 +348,10 @@ where
}
};
sync_client_metadata(&props.db, &client_id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.vault, &pubkey).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge = AuthChallenge::generate(&mut rand::rng());
challenge_client(transport, pubkey, challenge).await?;
transport
.send(Ok(Outbound::AuthSuccess))

View File

@@ -18,7 +18,6 @@ pub struct ClientProfile {
pub struct ClientCredentials {
pub pubkey: authn::PublicKey,
pub nonce: i32,
}
impl Integrable for ClientCredentials {
@@ -28,7 +27,6 @@ impl Integrable for ClientCredentials {
impl Hashable for ClientCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.pubkey.to_bytes());
self.nonce.hash(hasher);
}
}

View File

@@ -10,24 +10,19 @@ use crate::{
flow_coordinator::RegisterClient,
vault::VaultState,
},
crypto::integrity::{Nested, Verified},
db,
evm::VetError,
};
use super::ClientConnection;
use super::ClientCredentials;
pub struct ClientSession {
props: ClientConnection,
client_id: Verified<i32, Nested<ClientCredentials>>,
client_id: i32,
}
impl ClientSession {
pub(crate) fn new(
props: ClientConnection,
client_id: Verified<i32, Nested<ClientCredentials>>,
) -> Self {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props, client_id }
}
}
@@ -60,7 +55,7 @@ impl ClientSession {
.actors
.evm
.ask(ClientSignTransaction {
client_id: *self.client_id.as_ref(),
client_id: self.client_id,
wallet_address,
transaction,
})
@@ -98,12 +93,11 @@ impl Actor for ClientSession {
}
impl ClientSession {
#[cfg(test)]
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self {
props,
client_id: Verified::new(0),
client_id: 0,
}
}
}

View File

@@ -1,11 +1,12 @@
use arbiter_crypto::authn;
use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi;
use tracing::error;
mod state;
use state::*;
use super::{AuthCredentials, UserAgentConnection};
use super::Credentials;
use super::UserAgentConnection;
#[derive(Debug, Clone)]
pub enum Inbound {
@@ -44,7 +45,7 @@ impl From<diesel::result::Error> for Error {
#[derive(Debug, Clone)]
pub enum Outbound {
AuthChallenge { nonce: i32 },
AuthChallenge { challenge: AuthChallenge },
AuthSuccess,
}
@@ -52,12 +53,11 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
match payload {
Inbound::AuthChallengeRequest {
pubkey,
bootstrap_token: None,
} => AuthEvents::AuthRequest(ChallengeRequest { pubkey }),
Inbound::AuthChallengeRequest {
bootstrap_token,
} => AuthEvents::AuthRequest(ChallengeRequest {
pubkey,
bootstrap_token: Some(token),
} => AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { pubkey, token }),
bootstrap_token,
}),
Inbound::AuthChallengeSolution { signature } => {
AuthEvents::ReceivedSolution(ChallengeSolution {
solution: signature,
@@ -69,14 +69,13 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
pub async fn authenticate<T>(
props: &mut UserAgentConnection,
transport: &mut T,
) -> Result<AuthCredentials, Error>
) -> Result<Credentials, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
let mut state = AuthStateMachine::new(AuthContext::new(props, transport));
loop {
// `state` holds a mutable reference to `props` so we can't access it directly here
let Some(payload) = state.context_mut().transport.recv().await else {
return Err(Error::Transport);
};

View File

@@ -1,32 +1,26 @@
use super::super::{AuthCredentials, Credentials, UserAgentConnection};
use arbiter_crypto::authn::{self, USERAGENT_CONTEXT};
use super::super::{Credentials, UserAgentConnection};
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, sqlite::Sqlite, update};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{crypto::integrity::{Nested, Verified}, peers::user_agent::auth::Outbound};
use crate::{
actors::{bootstrap::ConsumeToken, vault::Vault},
crypto::integrity,
actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::useragent_client},
peers::user_agent::auth::Outbound,
};
pub struct ChallengeRequest {
pub pubkey: authn::PublicKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: authn::PublicKey,
pub token: String,
pub bootstrap_token: Option<String>,
}
pub struct ChallengeContext {
pub id: i32,
pub challenge_nonce: i32,
pub key: authn::PublicKey,
pub challenge: AuthChallenge,
pub pubkey: authn::PublicKey,
pub bootstrap_token: Option<String>,
}
pub struct ChallengeSolution {
@@ -38,116 +32,25 @@ smlang::statemachine!(
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthCredentials),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthCredentials),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials),
}
);
const NONCE_START: i32 = 1;
/// Returns the current nonce, ready to use for the challenge nonce.
async fn get_current_nonce_and_id(
db: &DatabasePool,
key: &authn::PublicKey,
) -> Result<(i32, i32), Error> {
let mut db_conn = db.get().await.map_err(|e| {
async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<Option<i32>, Error> {
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
useragent_client::table
.filter(useragent_client::public_key.eq(key.to_bytes()))
.select((useragent_client::id, useragent_client::nonce))
.first::<(i32, i32)>(conn)
.await
})
})
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.select(useragent_client::id)
.first::<i32>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
.ok_or_else(|| {
error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn verify_integrity(
db: &DatabasePool,
vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let _result = integrity::verify_entity(
&mut db_conn,
vault,
&AuthCredentials {
creds: Credentials {
id,
pubkey: pubkey.clone(),
},
new_nonce: nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::internal("Integrity verification failed")
})?;
Ok(())
}
async fn compute_current_nonce(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
pubkey: &authn::PublicKey,
) -> Result<(i32, i32), Error> {
update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
.returning((useragent_client::id, useragent_client::nonce))
.get_result(conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error incrementing nonce");
Error::internal("Database operation failed")
})
}
async fn resign_credentials(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
id: i32,
pubkey: &authn::PublicKey,
new_nonce: i32,
) -> Result<Verified<i32, Nested<AuthCredentials>>, Error> {
integrity::sign_entity(
conn,
vault,
&AuthCredentials {
creds: Credentials {
id,
pubkey: pubkey.clone(),
},
new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity signature update failed");
Error::internal("Database error")
})
}
@@ -159,10 +62,7 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
})?;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((
useragent_client::public_key.eq(pubkey_bytes),
useragent_client::nonce.eq(NONCE_START),
))
.values((useragent_client::public_key.eq(pubkey_bytes),))
.returning(useragent_client::id)
.get_result(&mut conn)
.await
@@ -193,38 +93,25 @@ where
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
ChallengeRequest {
pubkey,
bootstrap_token,
}: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let is_signing = integrity::is_signing_available(&self.conn.actors.vault)
.await
.unwrap_or(false);
if is_signing {
verify_integrity(&self.conn.db, &self.conn.actors.vault, &pubkey).await?;
// Verify pubkey is registered (unless bootstrapping)
if bootstrap_token.is_none() {
let id = get_client_id(&self.conn.db, &pubkey).await?;
if id.is_none() {
return Err(Error::UnregisteredPublicKey);
}
}
let vault = self.conn.actors.vault.clone();
let mut conn = self.conn.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = conn
.exclusive_transaction(|conn| {
let pubkey = pubkey.clone();
let vault = vault.clone();
Box::pin(async move {
let (id, new_nonce) = compute_current_nonce(conn, &pubkey).await?;
if is_signing {
resign_credentials(conn, &vault, id, &pubkey, new_nonce).await?;
}
Result::<_, Error>::Ok((id, new_nonce))
})
})
.await?;
let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
.send(Ok(Outbound::AuthChallenge {
challenge: challenge.clone(),
}))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
@@ -232,18 +119,41 @@ where
})?;
Ok(ChallengeContext {
id,
challenge_nonce: nonce,
key: pubkey,
challenge,
pubkey,
bootstrap_token,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthCredentials, Self::Error> {
ChallengeContext {
challenge,
pubkey,
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
})?;
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
if !valid {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
return Err(Error::InvalidChallengeSolution);
}
// Resolve client id: bootstrap (consume token + register) or lookup
let id = match bootstrap_token {
Some(token) => {
let token_ok: bool = self
.conn
.actors
@@ -258,72 +168,29 @@ where
})?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
match token_ok {
true => {
let id = register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(AuthCredentials {
creds: Credentials { id, pubkey },
new_nonce: NONCE_START,
})
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
return Err(Error::InvalidBootstrapToken);
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
id,
challenge_nonce,
key,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthCredentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| {
error!("Failed to decode signature in challenge solution");
Error::InvalidChallengeSolution
})?;
register_key(&self.conn.db, pubkey).await?
}
None => get_client_id(&self.conn.db, pubkey)
.await?
.ok_or(Error::UnregisteredPublicKey)?,
};
let valid = key.verify(*challenge_nonce, USERAGENT_CONTEXT, &signature);
match valid {
true => {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(AuthCredentials {
creds: Credentials {
id: *id,
pubkey: key.clone(),
},
new_nonce: *challenge_nonce,
Ok(Credentials {
id,
pubkey: pubkey.clone(),
})
}
false => {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
}
}
}
}

View File

@@ -1,7 +1,10 @@
use crate::{
actors::GlobalActors,
crypto::integrity::{self, Integrable},
db::{self, DatabaseError},
actors::{
GlobalActors,
vault::{GetState, Vault},
},
crypto::integrity::{self, AttestationStatus, Integrable},
db::{self, DatabaseError, DatabasePool},
peers::client::ClientProfile,
};
use arbiter_crypto::authn;
@@ -11,7 +14,7 @@ pub use auth::authenticate;
use kameo::actor::{ActorRef, Spawn as _};
pub use session::UserAgentSession;
use tokio::sync::oneshot;
use tracing::warn;
use tracing::{error, warn};
use vault_gate::VaultGate;
use crate::crypto::integrity::hashing::Hashable;
@@ -20,24 +23,11 @@ pub mod auth;
pub mod session;
pub mod vault_gate;
#[derive(Debug, Clone, Hash)]
#[derive(Debug, Clone)]
pub struct Credentials {
pub id: i32,
pub pubkey: authn::PublicKey,
}
impl Hashable for Credentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.id.hash(hasher);
self.pubkey.hash(hasher);
}
}
#[derive(Debug, Clone)]
pub struct AuthCredentials {
pub creds: Credentials,
// denotes new nonce, not current
pub new_nonce: i32,
}
impl Hashable for authn::PublicKey {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
@@ -45,14 +35,14 @@ impl Hashable for authn::PublicKey {
}
}
impl Hashable for AuthCredentials {
impl Hashable for Credentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.creds.hash(hasher);
self.new_nonce.hash(hasher);
self.id.hash(hasher);
self.pubkey.hash(hasher);
}
}
impl Integrable for AuthCredentials {
impl Integrable for Credentials {
const KIND: &'static str = "useragent_credentials";
}
@@ -95,38 +85,44 @@ impl From<auth::Error> for Error {
}
}
pub async fn start<T>(
props: &mut UserAgentConnection,
mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<UserAgentSession>, Error>
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
{
let auth_creds = authenticate(props, &mut transport).await?;
let creds = match integrity::is_signing_available(&props.actors.vault)
async fn verify_integrity(
db: &DatabasePool,
vault: &ActorRef<Vault>,
credentials: &Credentials,
) -> Result<(), Error> {
let mut conn = db
.get()
.await
.map_err(|_| Error::Internal("Integrity verification failed".into()))?
{
// credentials were checked by `auth` stage
true => auth_creds.creds,
false => run_vault_gate(props, &mut transport, auth_creds).await?,
};
.map_err(|_| Error::Internal("DB unavailable".into()))?;
match integrity::verify_entity(&mut conn, &vault, credentials, credentials.id).await {
Ok(AttestationStatus::Attested) => Ok(()),
Ok(AttestationStatus::Unavailable) => {
Err(Error::Internal("Vault sealed during promotion".into()))
}
Err(e) => {
error!(?e, "Integrity verification failed during unseal promotion");
Err(Error::Internal("Integrity check failed".into()))
}
}
}
Ok(UserAgentSession::spawn(UserAgentSession::new(
props.clone(),
creds,
oob_sender,
)))
async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let vault_state = vault
.ask(GetState {})
.await
.map_err(|_| Error::Internal("Failed to contact the vault".into()))?;
Ok(!matches!(
vault_state,
crate::actors::vault::VaultState::Unsealed
))
}
async fn run_vault_gate<T>(
props: &UserAgentConnection,
transport: &mut T,
auth_creds: AuthCredentials,
) -> Result<Credentials, Error>
auth_creds: Credentials,
) -> Result<(), Error>
where
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send + ?Sized,
{
@@ -175,3 +171,29 @@ where
gate.kill();
result
}
pub async fn start<T>(
props: &mut UserAgentConnection,
mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<UserAgentSession>, Error>
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
{
let creds = authenticate(props, &mut transport).await?;
// should run vault gate only if sealed / unbootstrapped
if should_run_gate(&props.actors.vault).await? {
run_vault_gate(props, &mut transport, creds.clone()).await?;
}
// checking the integrity
verify_integrity(&props.db, &props.actors.vault, &creds).await?;
Ok(UserAgentSession::spawn(UserAgentSession::new(
props.clone(),
creds,
oob_sender,
)))
}

View File

@@ -1,27 +1,22 @@
use std::sync::Mutex;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
safecell::SafeCellHandle as _,
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use chacha20poly1305::aead::KeyInit;
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use tracing::error;
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::{
evm::{
use crate::actors::evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
},
vault::{self, Bootstrap, TryUnseal},
};
};
use crate::db::models::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};

View File

@@ -1,20 +1,18 @@
use arbiter_crypto::authn;
use diesel::{ExpressionMethods, QueryDsl};
use diesel_async::{RunQueryDsl};
use kameo_actors::message_bus::Register;
use std::{borrow::Cow, collections::HashMap};
use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages, prelude::Message};
use kameo::{Actor, actor::ActorRef, messages};
use thiserror::Error;
use tracing::error;
use crate::{
actors::{
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController},
vault::events,
}, crypto::integrity, db::schema::useragent_client, peers::{client::ClientProfile, user_agent::{AuthCredentials, Credentials}}
flow_coordinator::client_connect_approval::ClientApprovalController,
useragent_registry::ConnectUseragent,
},
peers::{client::ClientProfile, user_agent::Credentials},
};
use super::{OutOfBand, UserAgentConnection};
@@ -123,17 +121,17 @@ impl Actor for UserAgentSession {
) -> Result<Self, Self::Error> {
args.props
.actors
.flow_coordinator
.ask(RegisterUserAgent {
.useragent_registry
.ask(ConnectUseragent {
actor: this.clone(),
})
.await
.map_err(|err| {
error!(
?err,
"Failed to register user agent connection with flow coordinator"
"Failed to register user agent connection with user agent registry"
);
Error::internal("Failed to register user agent connection with flow coordinator")
Error::internal("Failed to register user agent connection with user agent registry")
})?;
Ok(args)
}

View File

@@ -1,22 +1,24 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::transport::Bi;
use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
use kameo::{Actor, error::SendError, messages, prelude::Message};
use kameo_actors::message_bus::Register;
use tokio::sync::oneshot;
use tracing::{error, info};
use tracing::{error, info, warn};
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub mod state;
use state::*;
use super::{AuthCredentials, Credentials};
use super::Credentials;
use crate::{
actors::{
GlobalActors,
vault::{self, Bootstrap, TryUnseal, events},
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
},
crypto::integrity::{self, AttestationStatus},
crypto::integrity::{self},
db::DatabasePool,
peers::user_agent::UserAgentConnection,
};
#[derive(Debug, thiserror::Error)]
@@ -43,8 +45,8 @@ pub struct HandshakeResponse {
}
pub struct VaultGate {
pub auth_creds: AuthCredentials,
pub promotion_tx: Option<oneshot::Sender<Result<Credentials, Error>>>,
pub auth_creds: Credentials,
pub promotion_tx: Option<oneshot::Sender<Result<(), Error>>>,
pub state: State,
pub actors: GlobalActors,
pub db: DatabasePool,
@@ -52,10 +54,10 @@ pub struct VaultGate {
impl VaultGate {
pub fn new(
auth_creds: AuthCredentials,
auth_creds: Credentials,
actors: GlobalActors,
db: DatabasePool,
promotion_tx: oneshot::Sender<Result<Credentials, Error>>,
promotion_tx: oneshot::Sender<Result<(), Error>>,
) -> Self {
Self {
auth_creds,
@@ -228,6 +230,18 @@ impl VaultGate {
}
}
}
#[message]
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
let answer = self
.actors
.vault
.ask(GetState {})
.await
.map_err(|_| Error::internal("failed to query vault"))?;
Ok(answer)
}
}
impl Message<events::Bootstrapped> for VaultGate {
@@ -239,14 +253,23 @@ impl Message<events::Bootstrapped> for VaultGate {
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = async {
let mut conn = self.db.get().await.map_err(|_| Error::internal("DB unavailable"))?;
integrity::sign_entity(&mut conn, &self.actors.vault, &self.auth_creds, self.auth_creds.creds.id)
let mut conn = self
.db
.get()
.await
.map_err(|_| Error::internal("DB unavailable"))?;
integrity::sign_entity(
&mut conn,
&self.actors.vault,
&self.auth_creds,
self.auth_creds.id,
)
.await
.map_err(|e| {
error!(?e, "Failed to sign integrity envelope on bootstrap");
Error::internal("Integrity sign failed")
})?;
Ok(self.auth_creds.creds.clone())
Ok(())
}
.await;
@@ -265,30 +288,8 @@ impl Message<events::Unsealed> for VaultGate {
_: events::Unsealed,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = async {
let mut conn = self.db.get().await.map_err(|_| Error::internal("DB unavailable"))?;
match integrity::check_entity_attestation(
&mut conn,
&self.actors.vault,
&self.auth_creds,
self.auth_creds.creds.id,
)
.await
{
Ok(AttestationStatus::Attested) => Ok(self.auth_creds.creds.clone()),
Ok(AttestationStatus::Unavailable) => {
Err(Error::internal("Vault sealed during promotion"))
}
Err(e) => {
error!(?e, "Integrity verification failed during unseal promotion");
Err(Error::InvalidKey)
}
}
}
.await;
if let Some(tx) = self.promotion_tx.take() {
let _ = tx.send(result);
let _ = tx.send(Ok(()));
}
ctx.stop();
}

View File

@@ -1,6 +1,5 @@
use std::sync::Mutex;
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
use x25519_dalek::{PublicKey, SharedSecret};
pub struct Handshake {
client_pubkey: PublicKey,

View File

@@ -1,5 +1,5 @@
use arbiter_crypto::{
authn::{self, CLIENT_CONTEXT, format_challenge},
authn::{self, AuthChallenge, CLIENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::ClientMetadata;
@@ -58,7 +58,6 @@ async fn insert_registered_client(
&actors.vault,
&ClientCredentials {
pubkey: pubkey.into(),
nonce: 1,
},
client_id,
)
@@ -66,12 +65,8 @@ async fn insert_registered_client(
.unwrap();
}
fn sign_client_challenge(
key: &SigningKey<MlDsa87>,
nonce: i32,
pubkey: &authn::PublicKey,
) -> authn::Signature {
let challenge = format_challenge(nonce, &pubkey.to_bytes());
fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -> authn::Signature {
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, CLIENT_CONTEXT)
.unwrap()
@@ -86,10 +81,7 @@ async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
.to_vec();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(sentinel_key),
schema::useragent_client::key_type.eq(1i32),
))
.values((schema::useragent_client::public_key.eq(sentinel_key),))
.execute(&mut conn)
.await
.unwrap();
@@ -175,14 +167,14 @@ pub async fn test_challenge_auth() {
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign the challenge and send solution
let signature = sign_client_challenge(&new_key, challenge.1, &challenge.0);
let signature = sign_client_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution { signature })
@@ -230,11 +222,11 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
.unwrap();
let response = test_transport.recv().await.unwrap().unwrap();
let (pubkey, nonce) = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
let challenge = match response {
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_client_challenge(&new_key, nonce, &pubkey);
let signature = sign_client_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution { signature })
.await
@@ -292,11 +284,11 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
.unwrap();
let response = test_transport.recv().await.unwrap().unwrap();
let (pubkey, nonce) = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
let challenge = match response {
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_client_challenge(&new_key, nonce, &pubkey);
let signature = sign_client_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution { signature })
.await

View File

@@ -1,33 +1,145 @@
use arbiter_crypto::{
authn::{self, USERAGENT_CONTEXT, format_challenge},
authn::{self, AuthChallenge, USERAGENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
use arbiter_server::{
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
crypto::integrity,
db::{self, schema},
peers::user_agent::{AuthCredentials, Credentials, UserAgentConnection, auth},
peers::user_agent::{self, Credentials, UserAgentConnection, auth, vault_gate},
};
use async_trait::async_trait;
use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl;
use ml_dsa::{KeyGen, MlDsa87, SigningKey, signature::Keypair as _};
use tokio::sync::mpsc;
use super::common::ChannelTransport;
fn sign_useragent_challenge(
key: &SigningKey<MlDsa87>,
nonce: i32,
pubkey_bytes: &[u8],
challenge: &AuthChallenge,
) -> authn::Signature {
let challenge = format_challenge(nonce, pubkey_bytes);
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, USERAGENT_CONTEXT)
.unwrap()
.into()
}
fn tamper_challenge(challenge: &AuthChallenge) -> AuthChallenge {
let mut challenge = challenge.clone();
challenge.nonce[0] ^= 1;
challenge
}
struct NullOobSender;
#[async_trait]
impl Sender<user_agent::OutOfBand> for NullOobSender {
async fn send(&mut self, _item: user_agent::OutOfBand) -> Result<(), TransportError> {
Ok(())
}
}
struct StartServerTransport {
auth_rx: mpsc::Receiver<auth::Inbound>,
auth_tx: mpsc::Sender<Result<auth::Outbound, auth::Error>>,
vault_rx: mpsc::Receiver<vault_gate::Inbound>,
vault_tx: mpsc::Sender<Result<vault_gate::Outbound, vault_gate::Error>>,
}
struct StartTestTransport {
auth_rx: mpsc::Receiver<Result<auth::Outbound, auth::Error>>,
auth_tx: mpsc::Sender<auth::Inbound>,
}
fn start_transport_pair() -> (StartServerTransport, StartTestTransport) {
let (auth_in_tx, auth_in_rx) = mpsc::channel(10);
let (auth_out_tx, auth_out_rx) = mpsc::channel(10);
let (_vault_in_tx, vault_in_rx) = mpsc::channel(10);
let (vault_out_tx, _vault_out_rx) = mpsc::channel(10);
(
StartServerTransport {
auth_rx: auth_in_rx,
auth_tx: auth_out_tx,
vault_rx: vault_in_rx,
vault_tx: vault_out_tx,
},
StartTestTransport {
auth_rx: auth_out_rx,
auth_tx: auth_in_tx,
},
)
}
#[async_trait]
impl Receiver<auth::Inbound> for StartServerTransport {
async fn recv(&mut self) -> Option<auth::Inbound> {
self.auth_rx.recv().await
}
}
#[async_trait]
impl Sender<Result<auth::Outbound, auth::Error>> for StartServerTransport {
async fn send(&mut self, item: Result<auth::Outbound, auth::Error>) -> Result<(), TransportError> {
self.auth_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
impl arbiter_proto::transport::Bi<auth::Inbound, Result<auth::Outbound, auth::Error>>
for StartServerTransport
{
}
#[async_trait]
impl Receiver<vault_gate::Inbound> for StartServerTransport {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
self.vault_rx.recv().await
}
}
#[async_trait]
impl Sender<Result<vault_gate::Outbound, vault_gate::Error>> for StartServerTransport {
async fn send(
&mut self,
item: Result<vault_gate::Outbound, vault_gate::Error>,
) -> Result<(), TransportError> {
self.vault_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
impl arbiter_proto::transport::Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>>
for StartServerTransport
{
}
#[async_trait]
impl Receiver<Result<auth::Outbound, auth::Error>> for StartTestTransport {
async fn recv(&mut self) -> Option<Result<auth::Outbound, auth::Error>> {
self.auth_rx.recv().await
}
}
#[async_trait]
impl Sender<auth::Inbound> for StartTestTransport {
async fn send(&mut self, item: auth::Inbound) -> Result<(), TransportError> {
self.auth_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
#[tokio::test]
#[test_log::test]
pub async fn test_bootstrap_token_auth() {
@@ -58,14 +170,29 @@ pub async fn test_bootstrap_token_auth() {
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
match response {
Ok(auth::Outbound::AuthSuccess) => {}
other => panic!("Expected AuthSuccess, got {other:?}"),
}
assert!(matches!(response, Ok(auth::Outbound::AuthSuccess)));
task.await.unwrap().unwrap();
@@ -100,6 +227,23 @@ pub async fn test_bootstrap_invalid_token_auth() {
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidBootstrapToken)
@@ -133,10 +277,7 @@ pub async fn test_challenge_auth() {
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
@@ -144,13 +285,10 @@ pub async fn test_challenge_auth() {
integrity::sign_entity(
&mut conn,
&actors.vault,
&AuthCredentials {
creds: Credentials {
&Credentials {
id,
pubkey: new_key.verifying_key().into(),
},
new_nonce: 1,
},
id,
)
.await
@@ -178,13 +316,13 @@ pub async fn test_challenge_auth() {
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, challenge, &pubkey_bytes);
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
@@ -225,20 +363,17 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.execute(&mut conn)
.await
.unwrap();
}
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let (server_transport, mut test_transport) = start_transport_pair();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
user_agent::start(&mut props, server_transport, Box::new(NullOobSender)).await
});
test_transport
@@ -249,9 +384,36 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
assert!(matches!(response, Ok(auth::Outbound::AuthSuccess)));
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::Internal { .. })
Err(user_agent::Error::Internal(_))
));
}
@@ -274,10 +436,7 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
@@ -285,13 +444,10 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
integrity::sign_entity(
&mut conn,
&actors.vault,
&AuthCredentials {
creds: Credentials {
&Credentials {
id,
pubkey: new_key.verifying_key().into(),
},
new_nonce: 1,
},
id,
)
.await
@@ -319,13 +475,13 @@ pub async fn test_challenge_auth_rejects_invalid_signature() {
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, challenge + 1, &pubkey_bytes);
let signature = sign_useragent_challenge(&new_key, &tamper_challenge(&challenge));
test_transport
.send(auth::Inbound::AuthChallengeSolution {

View File

@@ -9,8 +9,10 @@ use arbiter_server::{
},
db,
peers::user_agent::{
AuthCredentials, Credentials,
vault_gate::{Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate},
Credentials,
vault_gate::{
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,
},
},
};
@@ -21,7 +23,11 @@ use x25519_dalek::{EphemeralSecret, PublicKey};
async fn setup_sealed_gate(
seal_key: &[u8],
) -> (db::DatabasePool, kameo::actor::ActorRef<VaultGate>, oneshot::Receiver<Result<Credentials, VaultGateError>>) {
) -> (
db::DatabasePool,
kameo::actor::ActorRef<VaultGate>,
oneshot::Receiver<Result<(), VaultGateError>>,
) {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
@@ -36,10 +42,7 @@ async fn setup_sealed_gate(
let (promotion_tx, promotion_rx) = oneshot::channel();
let pubkey = authn::SigningKey::generate().public_key();
let auth_creds = AuthCredentials {
creds: Credentials { id: 1, pubkey },
new_nonce: 1,
};
let auth_creds = Credentials { id: 1, pubkey };
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
(db, gate, promotion_rx)

View File

@@ -87,7 +87,7 @@ async fn test_new_restores_sealed_state() {
.await
.unwrap();
let err = actor2.decrypt(1).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped));
assert!(matches!(err, Error::Sealed));
}
#[tokio::test]

View File

@@ -7,6 +7,7 @@ import 'package:arbiter/features/identity/pk_manager.dart';
import 'package:arbiter/proto/arbiter.pbgrpc.dart';
import 'package:arbiter/proto/user_agent/auth.pb.dart' as ua_auth;
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/src/rust/api.dart';
import 'package:grpc/grpc.dart';
import 'package:mtcore/markettakers.dart';
@@ -92,7 +93,10 @@ Future<Connection> connectAndAuthorize(
);
}
final challenge = _formatChallenge(authResponse.challenge, pubkey);
final challenge = await formatChallenge(
random: authResponse.challenge.random,
timestamp: authResponse.challenge.timestampNanos.toInt(),
);
talker.info(
'Received auth challenge, signing with key ${base64Encode(pubkey)}',
);
@@ -164,9 +168,3 @@ Future<Connection> _connect(StoredServerInfo serverInfo) async {
return Connection(channel: channel, tx: tx, rx: rx);
}
List<int> _formatChallenge(ua_auth.AuthChallenge challenge, List<int> pubkey) {
final encodedPubkey = base64Encode(pubkey);
final payload = "${challenge.nonce}:$encodedPubkey";
return utf8.encode(payload);
}

View File

@@ -12,6 +12,7 @@
import 'dart:core' as $core;
import 'package:fixnum/fixnum.dart' as $fixnum;
import 'package:protobuf/protobuf.dart' as $pb;
import '../shared/client.pb.dart' as $0;
@@ -94,12 +95,12 @@ class AuthChallengeRequest extends $pb.GeneratedMessage {
class AuthChallenge extends $pb.GeneratedMessage {
factory AuthChallenge({
$core.List<$core.int>? pubkey,
$core.int? nonce,
$fixnum.Int64? timestampNanos,
$core.List<$core.int>? random,
}) {
final result = create();
if (pubkey != null) result.pubkey = pubkey;
if (nonce != null) result.nonce = nonce;
if (timestampNanos != null) result.timestampNanos = timestampNanos;
if (random != null) result.random = random;
return result;
}
@@ -117,9 +118,11 @@ class AuthChallenge extends $pb.GeneratedMessage {
package:
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.client.auth'),
createEmptyInstance: create)
..a<$fixnum.Int64>(
1, _omitFieldNames ? '' : 'timestampNanos', $pb.PbFieldType.OU6,
defaultOrMaker: $fixnum.Int64.ZERO)
..a<$core.List<$core.int>>(
1, _omitFieldNames ? '' : 'pubkey', $pb.PbFieldType.OY)
..aI(2, _omitFieldNames ? '' : 'nonce')
2, _omitFieldNames ? '' : 'random', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -142,22 +145,22 @@ class AuthChallenge extends $pb.GeneratedMessage {
static AuthChallenge? _defaultInstance;
@$pb.TagNumber(1)
$core.List<$core.int> get pubkey => $_getN(0);
$fixnum.Int64 get timestampNanos => $_getI64(0);
@$pb.TagNumber(1)
set pubkey($core.List<$core.int> value) => $_setBytes(0, value);
set timestampNanos($fixnum.Int64 value) => $_setInt64(0, value);
@$pb.TagNumber(1)
$core.bool hasPubkey() => $_has(0);
$core.bool hasTimestampNanos() => $_has(0);
@$pb.TagNumber(1)
void clearPubkey() => $_clearField(1);
void clearTimestampNanos() => $_clearField(1);
@$pb.TagNumber(2)
$core.int get nonce => $_getIZ(1);
$core.List<$core.int> get random => $_getN(1);
@$pb.TagNumber(2)
set nonce($core.int value) => $_setSignedInt32(1, value);
set random($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasNonce() => $_has(1);
$core.bool hasRandom() => $_has(1);
@$pb.TagNumber(2)
void clearNonce() => $_clearField(2);
void clearRandom() => $_clearField(2);
}
class AuthChallengeSolution extends $pb.GeneratedMessage {

View File

@@ -62,15 +62,15 @@ final $typed_data.Uint8List authChallengeRequestDescriptor = $convert.base64Deco
const AuthChallenge$json = {
'1': 'AuthChallenge',
'2': [
{'1': 'pubkey', '3': 1, '4': 1, '5': 12, '10': 'pubkey'},
{'1': 'nonce', '3': 2, '4': 1, '5': 5, '10': 'nonce'},
{'1': 'timestamp_nanos', '3': 1, '4': 1, '5': 4, '10': 'timestampNanos'},
{'1': 'random', '3': 2, '4': 1, '5': 12, '10': 'random'},
],
};
/// Descriptor for `AuthChallenge`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List authChallengeDescriptor = $convert.base64Decode(
'Cg1BdXRoQ2hhbGxlbmdlEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5EhQKBW5vbmNlGAIgASgFUg'
'Vub25jZQ==');
'Cg1BdXRoQ2hhbGxlbmdlEicKD3RpbWVzdGFtcF9uYW5vcxgBIAEoBFIOdGltZXN0YW1wTmFub3'
'MSFgoGcmFuZG9tGAIgASgMUgZyYW5kb20=');
@$core.Deprecated('Use authChallengeSolutionDescriptor instead')
const AuthChallengeSolution$json = {

View File

@@ -12,6 +12,7 @@
import 'dart:core' as $core;
import 'package:fixnum/fixnum.dart' as $fixnum;
import 'package:protobuf/protobuf.dart' as $pb;
import 'auth.pbenum.dart';
@@ -90,10 +91,12 @@ class AuthChallengeRequest extends $pb.GeneratedMessage {
class AuthChallenge extends $pb.GeneratedMessage {
factory AuthChallenge({
$core.int? nonce,
$fixnum.Int64? timestampNanos,
$core.List<$core.int>? random,
}) {
final result = create();
if (nonce != null) result.nonce = nonce;
if (timestampNanos != null) result.timestampNanos = timestampNanos;
if (random != null) result.random = random;
return result;
}
@@ -111,7 +114,11 @@ class AuthChallenge extends $pb.GeneratedMessage {
package: const $pb.PackageName(
_omitMessageNames ? '' : 'arbiter.user_agent.auth'),
createEmptyInstance: create)
..aI(1, _omitFieldNames ? '' : 'nonce')
..a<$fixnum.Int64>(
1, _omitFieldNames ? '' : 'timestampNanos', $pb.PbFieldType.OU6,
defaultOrMaker: $fixnum.Int64.ZERO)
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'random', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -134,13 +141,22 @@ class AuthChallenge extends $pb.GeneratedMessage {
static AuthChallenge? _defaultInstance;
@$pb.TagNumber(1)
$core.int get nonce => $_getIZ(0);
$fixnum.Int64 get timestampNanos => $_getI64(0);
@$pb.TagNumber(1)
set nonce($core.int value) => $_setSignedInt32(0, value);
set timestampNanos($fixnum.Int64 value) => $_setInt64(0, value);
@$pb.TagNumber(1)
$core.bool hasNonce() => $_has(0);
$core.bool hasTimestampNanos() => $_has(0);
@$pb.TagNumber(1)
void clearNonce() => $_clearField(1);
void clearTimestampNanos() => $_clearField(1);
@$pb.TagNumber(2)
$core.List<$core.int> get random => $_getN(1);
@$pb.TagNumber(2)
set random($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasRandom() => $_has(1);
@$pb.TagNumber(2)
void clearRandom() => $_clearField(2);
}
class AuthChallengeSolution extends $pb.GeneratedMessage {

View File

@@ -67,13 +67,15 @@ final $typed_data.Uint8List authChallengeRequestDescriptor = $convert.base64Deco
const AuthChallenge$json = {
'1': 'AuthChallenge',
'2': [
{'1': 'nonce', '3': 1, '4': 1, '5': 5, '10': 'nonce'},
{'1': 'timestamp_nanos', '3': 1, '4': 1, '5': 4, '10': 'timestampNanos'},
{'1': 'random', '3': 2, '4': 1, '5': 12, '10': 'random'},
],
};
/// Descriptor for `AuthChallenge`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List authChallengeDescriptor = $convert
.base64Decode('Cg1BdXRoQ2hhbGxlbmdlEhQKBW5vbmNlGAEgASgFUgVub25jZQ==');
final $typed_data.Uint8List authChallengeDescriptor = $convert.base64Decode(
'Cg1BdXRoQ2hhbGxlbmdlEicKD3RpbWVzdGFtcF9uYW5vcxgBIAEoBFIOdGltZXN0YW1wTmFub3'
'MSFgoGcmFuZG9tGAIgASgMUgZyYW5kb20=');
@$core.Deprecated('Use authChallengeSolutionDescriptor instead')
const AuthChallengeSolution$json = {

View File

@@ -6,6 +6,14 @@
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
Future<Uint8List> formatChallenge({
required List<int> random,
required PlatformInt64 timestamp,
}) => RustLib.instance.api.crateApiFormatChallenge(
random: random,
timestamp: timestamp,
);
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<MldsaKey>>
abstract class MldsaKey implements RustOpaqueInterface {
static Future<MldsaKey> fromBytes({required List<int> bytes}) =>

View File

@@ -64,7 +64,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -437661335;
int get rustContentHash => 1247923898;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -89,6 +89,11 @@ abstract class RustLibApi extends BaseApi {
Future<Uint8List> crateApiMldsaKeyToBytes({required MldsaKey that});
Future<Uint8List> crateApiFormatChallenge({
required List<int> random,
required PlatformInt64 timestamp,
});
RustArcIncrementStrongCountFnType
get rust_arc_increment_strong_count_MldsaKey;
@@ -267,6 +272,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiMldsaKeyToBytesConstMeta =>
const TaskConstMeta(debugName: "MldsaKey_to_bytes", argNames: ["that"]);
@override
Future<Uint8List> crateApiFormatChallenge({
required List<int> random,
required PlatformInt64 timestamp,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_list_prim_u_8_loose(random, serializer);
sse_encode_i_64(timestamp, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 6,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_list_prim_u_8_strict,
decodeErrorData: sse_decode_String,
),
constMeta: kCrateApiFormatChallengeConstMeta,
argValues: [random, timestamp],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiFormatChallengeConstMeta => const TaskConstMeta(
debugName: "format_challenge",
argNames: ["random", "timestamp"],
);
RustArcIncrementStrongCountFnType
get rust_arc_increment_strong_count_MldsaKey => wire
.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMldsaKey;
@@ -314,6 +353,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw as String;
}
@protected
PlatformInt64 dco_decode_i_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dcoDecodeI64(raw);
}
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -394,6 +439,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return utf8.decoder.convert(inner);
}
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getPlatformInt64();
}
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -491,6 +542,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer);
}
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putPlatformInt64(self);
}
@protected
void sse_encode_list_prim_u_8_loose(
List<int> self,

View File

@@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String dco_decode_String(dynamic raw);
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@@ -85,6 +88,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@@ -136,6 +142,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);

View File

@@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String dco_decode_String(dynamic raw);
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@@ -87,6 +90,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@@ -138,6 +144,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);

4
useragent/mise.toml Normal file
View File

@@ -0,0 +1,4 @@
[tasks.codegen]
run = '''
flutter_rust_bridge_codegen generate
'''

View File

@@ -232,10 +232,11 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "arbiter-crypto"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"memsafe",
"ml-dsa",
"rand",
"x-wing",
]
[[package]]
@@ -486,12 +487,6 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -713,10 +708,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.0",
"rand_core",
]
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -837,6 +846,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -884,6 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
"rand_core",
]
[[package]]
@@ -901,6 +920,32 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "curve25519-dalek"
version = "5.0.0-pre.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dart-sys"
version = "4.1.5"
@@ -1314,6 +1359,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1773,10 +1824,35 @@ version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
dependencies = [
"ctutils",
"typenum",
"zeroize",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -2005,7 +2081,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.0",
]
[[package]]
name = "kem"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
dependencies = [
"crypto-common 0.2.1",
"rand_core",
]
[[package]]
@@ -2207,12 +2293,27 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ml-kem"
version = "0.3.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04437cb1a66c0b78740927b76cc61f218344b9f6ef3dd430e283274a718ef0e9"
dependencies = [
"hybrid-array",
"kem",
"module-lattice",
"rand_core",
"sha3",
"zeroize",
]
[[package]]
name = "module-lattice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "164eb3faeaecbd14b0b2a917c1b4d0c035097a9c559b0bed85c2cdd032bc8faa"
dependencies = [
"ctutils",
"hybrid-array",
"num-traits",
"zeroize",
@@ -3488,6 +3589,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -4761,6 +4868,20 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x-wing"
version = "0.1.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17d0d5f4d1f26b9b9e7477af1d3bef960e1d1fb64edab7912fde472a8a8432e"
dependencies = [
"kem",
"ml-kem",
"rand_core",
"sha3",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "x11-dl"
version = "2.21.0"
@@ -4793,6 +4914,17 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x25519-dalek"
version = "3.0.0-pre.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d5d6ff67acd3945b933e592bfa7143db4fcbb2f871754b6b9fbd7847fc5aea"
dependencies = [
"curve25519-dalek",
"rand_core",
"zeroize",
]
[[package]]
name = "xcursor"
version = "0.3.10"

View File

@@ -1,13 +1,15 @@
use anyhow::anyhow;
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
use flutter_rust_bridge::frb;
use arbiter_crypto::authn::{self, USERAGENT_CONTEXT};
#[frb(opaque)]
pub struct MldsaKey(authn::SigningKey);
impl MldsaKey {
pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| anyhow!("Invalid key length"))?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow!("Invalid key length"))?;
Ok(Self(authn::SigningKey::from_seed(bytes)))
}
@@ -27,3 +29,10 @@ impl MldsaKey {
self.0.public_key().to_bytes().to_vec()
}
}
pub fn format_challenge(random: Vec<u8>, timestamp: i64) -> Result<Vec<u8>, String> {
let challenge = AuthChallenge::from_parts(&random, timestamp)
.map_err(|_| "Invalid nonce length".to_string())?;
Ok(challenge.format())
}

View File

@@ -39,7 +39,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -437661335;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1247923898;
// Section: executor
@@ -267,6 +267,40 @@ fn wire__crate__api__MldsaKey_to_bytes_impl(
},
)
}
fn wire__crate__api__format_challenge_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "format_challenge",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_random = <Vec<u8>>::sse_decode(&mut deserializer);
let api_timestamp = <i64>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, String>((move || {
let output_ok = crate::api::format_challenge(api_random, api_timestamp)?;
Ok(output_ok)
})())
}
},
)
}
// Section: related_funcs
@@ -312,6 +346,13 @@ impl SseDecode for String {
}
}
impl SseDecode for i64 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
deserializer.cursor.read_i64::<NativeEndian>().unwrap()
}
}
impl SseDecode for Vec<u8> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -371,6 +412,7 @@ fn pde_ffi_dispatcher_primary_impl(
3 => wire__crate__api__MldsaKey_get_public_key_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__api__MldsaKey_sign_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__MldsaKey_to_bytes_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__format_challenge_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -436,6 +478,13 @@ impl SseEncode for String {
}
}
impl SseEncode for i64 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
serializer.cursor.write_i64::<NativeEndian>(self).unwrap();
}
}
impl SseEncode for Vec<u8> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {