6 Commits

Author SHA1 Message Date
hdbg
352ee3ee63 fix(server): previously, user agent auth accepted invalid signatures
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:28:07 +02:00
hdbg
dd51d756da refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-04-04 14:21:52 +02:00
CleverWild
0bb6e596ac feat(auth): implement attestation status verification for public keys
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 12:10:45 +02:00
CleverWild
881f16bb1a fix(keyholder): comment drift 2026-04-04 12:02:50 +02:00
CleverWild
78895bca5b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-04-04 12:00:39 +02:00
CleverWild
a02ef68a70 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-03-30 00:17:04 +02:00
19 changed files with 612 additions and 141 deletions

1
server/Cargo.lock generated
View File

@@ -737,6 +737,7 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"fatality", "fatality",
"futures", "futures",
"hmac",
"insta", "insta",
"k256", "k256",
"kameo", "kameo",

View File

@@ -49,6 +49,7 @@ pem = "3.0.6"
k256.workspace = true k256.workspace = true
rsa.workspace = true rsa.workspace = true
sha2.workspace = true sha2.workspace = true
hmac = "0.12"
spki.workspace = true spki.workspace = true
alloy.workspace = true alloy.workspace = true
prost-types.workspace = true prost-types.workspace = true

View File

@@ -47,6 +47,7 @@ create table if not exists useragent_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
pubkey_integrity_tag blob,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1) key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))

View File

@@ -8,7 +8,7 @@ use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
use crate::safe_cell::SafeCell; use crate::{crypto::{KeyCell, derive_key, encryption::v1::{self, Nonce}, integrity::v1::compute_integrity_tag}, safe_cell::SafeCell};
use crate::{ use crate::{
db::{ db::{
self, self,
@@ -17,9 +17,7 @@ use crate::{
}, },
safe_cell::SafeCellHandle as _, safe_cell::SafeCellHandle as _,
}; };
use encryption::v1::{self, KeyCell, Nonce};
pub mod encryption;
#[derive(Default, EnumDiscriminants)] #[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))] #[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -115,7 +113,7 @@ impl KeyHolder {
.await?; .await?;
let mut nonce = let mut nonce =
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| { Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!( error!(
"Broken database: invalid nonce for root key history id={}", "Broken database: invalid nonce for root key history id={}",
root_key_id root_key_id
@@ -144,12 +142,12 @@ impl KeyHolder {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
let salt = v1::generate_salt(); let salt = v1::generate_salt();
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt); let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = v1::Nonce::default(); let root_key_nonce = Nonce::default();
let data_encryption_nonce = v1::Nonce::default(); let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice(); let root_key_reader = reader.as_slice();
@@ -225,7 +223,7 @@ impl KeyHolder {
error!("Broken database: invalid salt for root key"); error!("Broken database: invalid salt for root key");
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt); let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -245,7 +243,7 @@ impl KeyHolder {
self.state = State::Unsealed { self.state = State::Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: v1::KeyCell::try_from(root_key).map_err(|err| { root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size"); error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase Error::BrokenDatabase
})?, })?,
@@ -256,7 +254,25 @@ impl KeyHolder {
Ok(()) Ok(())
} }
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext // Signs a generic integrity payload using the vault-derived integrity key
#[message]
pub fn sign_integrity_tag(
&mut self,
purpose_tag: Vec<u8>,
data_parts: Vec<Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped);
};
let tag = compute_integrity_tag(
root_key,
&purpose_tag,
data_parts.iter().map(Vec::as_slice),
);
Ok(tag.to_vec())
}
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else { let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -292,6 +308,7 @@ impl KeyHolder {
let State::Unsealed { let State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
..
} = &mut self.state } = &mut self.state
else { else {
return Err(Error::NotBootstrapped); return Err(Error::NotBootstrapped);

View File

@@ -1,17 +1,27 @@
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::error::SendError;
use tracing::error; use tracing::error;
use super::Error; use super::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::ConsumeToken, bootstrap::ConsumeToken,
keyholder::{self, SignIntegrityTag},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
}, },
crypto::integrity::v1::USERAGENT_INTEGRITY_TAG,
db::schema, db::schema,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
NotAttested,
Unavailable,
}
pub struct ChallengeRequest { pub struct ChallengeRequest {
pub pubkey: AuthPublicKey, pub pubkey: AuthPublicKey,
} }
@@ -40,7 +50,11 @@ smlang::statemachine!(
} }
); );
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> { async fn create_nonce(
db: &crate::db::DatabasePool,
pubkey_bytes: &[u8],
key_type: crate::db::models::KeyType,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| { let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
@@ -50,12 +64,14 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
Box::pin(async move { Box::pin(async move {
let current_nonce = schema::useragent_client::table let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.select(schema::useragent_client::nonce) .select(schema::useragent_client::nonce)
.first::<i32>(conn) .first::<i32>(conn)
.await?; .await?;
update(schema::useragent_client::table) update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.set(schema::useragent_client::nonce.eq(current_nonce + 1)) .set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn) .execute(conn)
.await?; .await?;
@@ -75,7 +91,11 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
}) })
} }
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> { async fn register_key(
db: &crate::db::DatabasePool,
pubkey: &AuthPublicKey,
integrity_tag: Option<Vec<u8>>,
) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes(); let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type(); let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
@@ -88,6 +108,7 @@ async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> R
schema::useragent_client::public_key.eq(pubkey_bytes), schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1), schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type), schema::useragent_client::key_type.eq(key_type),
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
)) ))
.execute(&mut conn) .execute(&mut conn)
.await .await
@@ -120,8 +141,15 @@ where
&mut self, &mut self,
ChallengeRequest { pubkey }: ChallengeRequest, ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> { ) -> Result<ChallengeContext, Self::Error> {
match self.verify_pubkey_attestation_status(&pubkey).await? {
AttestationStatus::Attested | AttestationStatus::Unavailable => {}
AttestationStatus::NotAttested => {
return Err(Error::InvalidChallengeSolution);
}
}
let stored_bytes = pubkey.to_stored_bytes(); let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?; let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
self.transport self.transport
.send(Ok(Outbound::AuthChallenge { nonce })) .send(Ok(Outbound::AuthChallenge { nonce }))
@@ -161,7 +189,15 @@ where
return Err(Error::InvalidBootstrapToken); return Err(Error::InvalidBootstrapToken);
} }
register_key(&self.conn.db, &pubkey).await?; let integrity_tag = self
.try_sign_pubkey_integrity_tag(&pubkey)
.await
.map_err(|err| {
error!(?err, "Failed to sign user-agent pubkey integrity tag");
Error::internal("Failed to sign user-agent pubkey integrity tag")
})?;
register_key(&self.conn.db, &pubkey, integrity_tag).await?;
self.transport self.transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
@@ -210,13 +246,112 @@ where
} }
}; };
if valid { match valid {
self.transport true => {
.send(Ok(Outbound::AuthSuccess)) self.transport
.await .send(Ok(Outbound::AuthSuccess))
.map_err(|_| Error::Transport)?; .await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
false => {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
}
}
}
}
impl<T> AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
async fn try_sign_pubkey_integrity_tag(
&self,
pubkey: &AuthPublicKey,
) -> Result<Option<Vec<u8>>, Error> {
let signed = self
.conn
.actors
.key_holder
.ask(SignIntegrityTag {
purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(),
data_parts: vec![
(pubkey.key_type() as i32).to_be_bytes().to_vec(),
pubkey.to_stored_bytes(),
],
})
.await;
match signed {
Ok(tag) => Ok(Some(tag)),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(None),
Err(SendError::HandlerError(err)) => {
error!(
?err,
"Keyholder failed to sign user-agent pubkey integrity tag"
);
Err(Error::internal(
"Keyholder failed to sign user-agent pubkey integrity tag",
))
}
Err(err) => {
error!(
?err,
"Failed to contact keyholder for user-agent pubkey integrity tag"
);
Err(Error::internal(
"Failed to contact keyholder for user-agent pubkey integrity tag",
))
}
}
}
async fn verify_pubkey_attestation_status(
&self,
pubkey: &AuthPublicKey,
) -> Result<AttestationStatus, Error> {
let stored_tag: Option<Option<Vec<u8>>> = {
let mut conn = self.conn.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey.to_stored_bytes()))
.filter(schema::useragent_client::key_type.eq(pubkey.key_type()))
.select(schema::useragent_client::pubkey_integrity_tag)
.first::<Option<Vec<u8>>>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
};
let Some(stored_tag) = stored_tag else {
return Err(Error::UnregisteredPublicKey);
};
let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else {
// Vault sealed/unbootstrapped: cannot verify integrity yet.
return Ok(AttestationStatus::Unavailable);
};
match stored_tag {
Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested),
Some(_) => {
error!("User-agent pubkey integrity tag mismatch");
Ok(AttestationStatus::NotAttested)
}
None => {
error!("Missing pubkey integrity tag for registered key while vault is unsealed");
Ok(AttestationStatus::NotAttested)
}
} }
Ok(key.clone())
} }
} }

View File

@@ -2,22 +2,21 @@ use std::sync::Mutex;
use alloy::primitives::Address; use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::sql_types::ops::Add; use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper, dsl::update};
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError; use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context; use kameo::prelude::Context;
use kameo::{message, messages};
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState; use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error; use crate::actors::user_agent::session::Error;
use crate::crypto::integrity::v1::USERAGENT_INTEGRITY_TAG;
use crate::db::models::{ use crate::db::models::{
CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata, EvmWalletAccess, KeyType, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
}; };
use crate::db::schema::evm_wallet_access;
use crate::evm::policies::{Grant, SpecificGrant}; use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell; use crate::safe_cell::SafeCell;
use crate::{ use crate::{
@@ -25,7 +24,7 @@ use crate::{
evm::{ evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants, Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
}, },
keyholder::{self, Bootstrap, TryUnseal}, keyholder::{self, Bootstrap, SignIntegrityTag, TryUnseal},
user_agent::session::{ user_agent::session::{
UserAgentSession, UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates}, state::{UnsealContext, UserAgentEvents, UserAgentStates},
@@ -87,6 +86,56 @@ impl UserAgentSession {
} }
} }
} }
async fn backfill_missing_useragent_pubkey_integrity_tags(&mut self) -> Result<(), Error> {
use crate::db::schema::useragent_client;
let mut conn = self.props.db.get().await?;
let missing_rows: Vec<(i32, Vec<u8>, KeyType)> = useragent_client::table
.filter(useragent_client::pubkey_integrity_tag.is_null())
.select((
useragent_client::id,
useragent_client::public_key,
useragent_client::key_type,
))
.load(&mut conn)
.await?;
drop(conn);
if missing_rows.is_empty() {
return Ok(());
}
let mut updates = Vec::with_capacity(missing_rows.len());
for (id, public_key, key_type) in missing_rows {
let tag = self
.props
.actors
.key_holder
.ask(SignIntegrityTag {
purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(),
data_parts: vec![(key_type as i32).to_be_bytes().to_vec(), public_key],
})
.await
.map_err(|err| {
error!(?err, "Failed to sign integrity tag");
Error::internal("Failed to sign integrity tag")
})?;
updates.push((id, tag));
}
let mut conn = self.props.db.get().await?;
for (id, tag) in updates {
update(useragent_client::table)
.filter(useragent_client::id.eq(id))
.set(useragent_client::pubkey_integrity_tag.eq(Some(tag)))
.execute(&mut conn)
.await?;
}
info!("Backfilled missing user-agent pubkey integrity tags");
Ok(())
}
} }
pub struct UnsealStartResponse { pub struct UnsealStartResponse {
@@ -174,6 +223,8 @@ impl UserAgentSession {
.await .await
{ {
Ok(_) => { Ok(_) => {
self.backfill_missing_useragent_pubkey_integrity_tags()
.await?;
info!("Successfully unsealed key with client-provided key"); info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?; self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(()) Ok(())
@@ -235,6 +286,8 @@ impl UserAgentSession {
.await .await
{ {
Ok(_) => { Ok(_) => {
self.backfill_missing_useragent_pubkey_integrity_tags()
.await?;
info!("Successfully bootstrapped vault with client-provided key"); info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?; self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(()) Ok(())

View File

@@ -0,0 +1 @@
pub mod v1;

View File

@@ -0,0 +1,111 @@
use argon2::password_hash::Salt as ArgonSalt;
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce(pub [u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
#[cfg(test)]
mod tests {
use std::ops::Deref as _;
use super::*;
use crate::{crypto::derive_key, safe_cell::{SafeCell, SafeCellHandle as _}};
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_key(password, &salt);
let mut key2 = derive_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -0,0 +1 @@
pub mod v1;

View File

@@ -0,0 +1,77 @@
use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _};
use chacha20poly1305::Key;
use hmac::Mac as _;
pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes();
pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes();
/// Computes an integrity tag for a specific domain and payload shape.
pub fn compute_integrity_tag<'a, I>(
integrity_key: &mut KeyCell,
purpose_tag: &[u8],
data_parts: I,
) -> [u8; 32]
where
I: IntoIterator<Item = &'a [u8]>,
{
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut output_tag = [0u8; 32];
integrity_key.0.read_inline(|integrity_key_bytes: &Key| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref())
.expect("HMAC key initialization must not fail for 32-byte key");
mac.update(purpose_tag);
for data_part in data_parts {
mac.update(data_part);
}
output_tag.copy_from_slice(&mac.finalize().into_bytes());
});
output_tag
}
#[cfg(test)]
mod tests {
use crate::{crypto::{derive_key, encryption::v1::generate_salt}, safe_cell::{SafeCell, SafeCellHandle as _}};
use super::{compute_integrity_tag, USERAGENT_INTEGRITY_TAG};
#[test]
pub fn integrity_tag_deterministic() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type = 1i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type.as_slice(), b"pubkey".as_ref()],
);
assert_eq!(t1, t2);
}
#[test]
pub fn integrity_tag_changes_with_payload() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type_1 = 1i32.to_be_bytes();
let key_type_2 = 2i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_1.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_2.as_slice(), b"pubkey".as_ref()],
);
assert_ne!(t1, t2);
}
}

View File

@@ -1,52 +1,18 @@
use std::ops::Deref as _; use std::ops::Deref as _;
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt}; use argon2::{Algorithm, Argon2};
use chacha20poly1305::{ use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce, AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload}, aead::{AeadMut, Error, Payload},
}; };
use rand::{ use rand::{Rng as _, SeedableRng as _, rngs::{StdRng, SysRng}};
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _}; use crate::{safe_cell::{SafeCell, SafeCellHandle as _}};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes(); pub mod encryption;
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes(); pub mod integrity;
pub const NONCE_LENGTH: usize = 24; use encryption::v1::{Nonce, Salt};
#[derive(Default)]
pub struct Nonce([u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub struct KeyCell(pub SafeCell<Key>); pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell { impl From<SafeCell<Key>> for KeyCell {
@@ -133,22 +99,9 @@ impl KeyCell {
} }
} }
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
/// User password might be of different length, have not enough entropy, etc... /// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell { pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap(); let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params); let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
@@ -171,37 +124,8 @@ pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use crate::{safe_cell::{SafeCell, SafeCellHandle as _}};
use crate::safe_cell::SafeCell; use super::{derive_key, encryption::v1::{Nonce, generate_salt}};
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_seal_key(password, &salt);
let mut key2 = derive_seal_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test] #[test]
pub fn encrypt_decrypt() { pub fn encrypt_decrypt() {
@@ -209,7 +133,7 @@ mod tests {
let password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); let salt = generate_salt();
let mut key = derive_seal_key(password, &salt); let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data"; let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec(); let mut buffer = b"secret data".to_vec();
@@ -226,18 +150,4 @@ mod tests {
let buffer = buffer.read(); let buffer = buffer.read();
assert_eq!(*buffer, b"secret data"); assert_eq!(*buffer, b"secret data");
} }
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
} }

View File

@@ -242,6 +242,7 @@ pub struct UseragentClient {
pub id: i32, pub id: i32,
pub nonce: i32, pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub pubkey_integrity_tag: Option<Vec<u8>>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType, pub key_type: KeyType,

View File

@@ -178,6 +178,7 @@ diesel::table! {
id -> Integer, id -> Integer,
nonce -> Integer, nonce -> Integer,
public_key -> Binary, public_key -> Binary,
pubkey_integrity_tag -> Nullable<Binary>,
key_type -> Integer, key_type -> Integer,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,

View File

@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use crate::context::ServerContext; use crate::context::ServerContext;
pub mod crypto;
pub mod actors; pub mod actors;
pub mod context; pub mod context;
pub mod db; pub mod db;

View File

@@ -1,7 +1,5 @@
use arbiter_server::{ use arbiter_server::{
actors::keyholder::{Error, KeyHolder}, actors::keyholder::{Error, KeyHolder}, crypto::encryption::v1::{Nonce, ROOT_KEY_TAG}, db::{self, models, schema}, safe_cell::{SafeCell, SafeCellHandle as _}
db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{QueryDsl, SelectableHelper}; use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -27,13 +25,13 @@ async fn test_bootstrap() {
assert_eq!(row.schema_version, 1); assert_eq!(row.schema_version, 1);
assert_eq!( assert_eq!(
row.tag, row.tag,
arbiter_server::actors::keyholder::encryption::v1::ROOT_KEY_TAG ROOT_KEY_TAG
); );
assert!(!row.ciphertext.is_empty()); assert!(!row.ciphertext.is_empty());
assert!(!row.salt.is_empty()); assert!(!row.salt.is_empty());
assert_eq!( assert_eq!(
row.data_encryption_nonce, row.data_encryption_nonce,
arbiter_server::actors::keyholder::encryption::v1::Nonce::default().to_vec() Nonce::default().to_vec()
); );
} }

View File

@@ -1,9 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use arbiter_server::{ use arbiter_server::{
actors::keyholder::{Error, encryption::v1}, actors::keyholder::Error, crypto::encryption::v1::Nonce, db::{self, models, schema}, safe_cell::{SafeCell, SafeCellHandle as _}
db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::update}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::update};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -102,7 +100,7 @@ async fn test_nonce_never_reused() {
assert_eq!(nonces.len(), unique.len(), "all nonces must be unique"); assert_eq!(nonces.len(), unique.len(), "all nonces must be unique");
for (i, row) in rows.iter().enumerate() { for (i, row) in rows.iter().enumerate() {
let mut expected = v1::Nonce::default(); let mut expected = Nonce::default();
for _ in 0..=i { for _ in 0..=i {
expected.increment(); expected.increment();
} }

View File

@@ -3,9 +3,11 @@ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
bootstrap::GetToken, bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, auth}, user_agent::{AuthPublicKey, UserAgentConnection, auth},
}, },
db::{self, schema}, db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, insert_into}; use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -165,3 +167,124 @@ pub async fn test_challenge_auth() {
task.await.unwrap().unwrap(); task.await.unwrap().unwrap();
} }
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None,
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution)
));
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
// Pre-register key with key_type
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign a different challenge value so signature format is valid but verification must fail.
let wrong_challenge = arbiter_proto::format_challenge(challenge + 1, &pubkey_bytes);
let signature = new_key.sign(&wrong_challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes().to_vec(),
})
.await
.unwrap();
let expected_err = task.await.unwrap();
println!("Received expected error: {expected_err:#?}");
assert!(matches!(
expected_err,
Err(auth::Error::InvalidChallengeSolution)
));
}

View File

@@ -2,14 +2,17 @@ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
keyholder::{Bootstrap, Seal}, keyholder::{Bootstrap, Seal},
user_agent::{UserAgentSession, session::connection::{ user_agent::{
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, UserAgentSession,
}}, session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
}, },
db, db,
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _; use kameo::actor::Spawn as _;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -149,3 +152,42 @@ pub async fn test_unseal_retry_after_invalid_key() {
assert!(matches!(response, Ok(()))); assert!(matches!(response, Ok(())));
} }
} }
#[tokio::test]
#[test_log::test]
pub async fn test_unseal_backfills_missing_pubkey_integrity_tags() {
let seal_key = b"test-seal-key";
let (db, user_agent) = setup_sealed_user_agent(seal_key).await;
{
let mut conn = db.get().await.unwrap();
insert_into(arbiter_server::db::schema::useragent_client::table)
.values((
arbiter_server::db::schema::useragent_client::public_key
.eq(vec![1u8, 2u8, 3u8, 4u8]),
arbiter_server::db::schema::useragent_client::key_type.eq(1i32),
arbiter_server::db::schema::useragent_client::pubkey_integrity_tag
.eq(Option::<Vec<u8>>::None),
))
.execute(&mut conn)
.await
.unwrap();
}
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await;
let response = user_agent.ask(encrypted_key).await;
assert!(matches!(response, Ok(())));
{
let mut conn = db.get().await.unwrap();
let tags: Vec<Option<Vec<u8>>> = arbiter_server::db::schema::useragent_client::table
.select(arbiter_server::db::schema::useragent_client::pubkey_integrity_tag)
.load(&mut conn)
.await
.unwrap();
assert!(
tags.iter()
.all(|tag| matches!(tag, Some(v) if v.len() == 32))
);
}
}