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

This commit is contained in:
CleverWild
2026-03-30 00:17:04 +02:00
parent e5be55e141
commit a02ef68a70
11 changed files with 434 additions and 13 deletions

1
server/Cargo.lock generated
View File

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

View File

@@ -49,6 +49,7 @@ pem = "3.0.6"
k256.workspace = true
rsa.workspace = true
sha2.workspace = true
hmac = "0.12"
spki.workspace = true
alloy.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,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null,
pubkey_integrity_tag blob,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))

View File

@@ -5,6 +5,7 @@ use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use hmac::Mac as _;
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
@@ -14,6 +15,8 @@ use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
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();
pub const NONCE_LENGTH: usize = 24;
@@ -169,6 +172,46 @@ pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell
key.into()
}
/// Derives a dedicated key used only for user-agent pubkey integrity tags.
pub fn derive_useragent_integrity_key(seal_key: &mut KeyCell) -> KeyCell {
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut derived = SafeCell::new(Key::default());
seal_key.0.read_inline(|seal_key_bytes| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(seal_key_bytes.as_ref())
.expect("HMAC key initialization must not fail for 32-byte key");
mac.update(USERAGENT_INTEGRITY_DERIVE_TAG);
let output = mac.finalize().into_bytes();
let mut writer = derived.write();
let writer: &mut [u8] = writer.as_mut();
writer.copy_from_slice(&output);
});
derived.into()
}
/// Computes an integrity tag for a user-agent pubkey DB entry.
pub fn compute_useragent_pubkey_integrity_tag(
integrity_key: &mut KeyCell,
key_type_discriminant: i32,
public_key: &[u8],
) -> [u8; 32] {
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut tag = [0u8; 32];
integrity_key.0.read_inline(|integrity_key_bytes| {
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(USERAGENT_INTEGRITY_TAG);
mac.update(&key_type_discriminant.to_be_bytes());
mac.update(public_key);
tag.copy_from_slice(&mac.finalize().into_bytes());
});
tag
}
#[cfg(test)]
mod tests {
use super::*;
@@ -240,4 +283,24 @@ mod tests {
]
);
}
#[test]
pub fn useragent_integrity_tag_deterministic() {
let salt = generate_salt();
let mut seal_key = derive_seal_key(SafeCell::new(b"password".to_vec()), &salt);
let mut integrity_key = derive_useragent_integrity_key(&mut seal_key);
let t1 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 1, b"pubkey");
let t2 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 1, b"pubkey");
assert_eq!(t1, t2);
}
#[test]
pub fn useragent_integrity_tag_changes_with_key_type() {
let salt = generate_salt();
let mut seal_key = derive_seal_key(SafeCell::new(b"password".to_vec()), &salt);
let mut integrity_key = derive_useragent_integrity_key(&mut seal_key);
let t1 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 1, b"pubkey");
let t2 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 2, b"pubkey");
assert_ne!(t1, t2);
}
}

View File

@@ -32,6 +32,7 @@ enum State {
Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
useragent_integrity_key: KeyCell,
},
}
@@ -145,6 +146,7 @@ impl KeyHolder {
}
let salt = v1::generate_salt();
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let useragent_integrity_key = v1::derive_useragent_integrity_key(&mut seal_key);
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
@@ -193,6 +195,7 @@ impl KeyHolder {
self.state = State::Unsealed {
root_key,
root_key_history_id,
useragent_integrity_key,
};
info!("Keyholder bootstrapped successfully");
@@ -226,6 +229,7 @@ impl KeyHolder {
Error::BrokenDatabase
})?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let useragent_integrity_key = v1::derive_useragent_integrity_key(&mut seal_key);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -249,6 +253,7 @@ impl KeyHolder {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
useragent_integrity_key,
};
info!("Keyholder unsealed successfully");
@@ -257,6 +262,28 @@ impl KeyHolder {
}
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
#[message]
pub fn sign_useragent_pubkey_integrity_tag(
&mut self,
public_key: Vec<u8>,
key_type: models::KeyType,
) -> Result<Vec<u8>, Error> {
let State::Unsealed {
useragent_integrity_key,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
let tag = v1::compute_useragent_pubkey_integrity_tag(
useragent_integrity_key,
key_type as i32,
&public_key,
);
Ok(tag.to_vec())
}
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -292,6 +319,7 @@ impl KeyHolder {
let State::Unsealed {
root_key,
root_key_history_id,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapped);

View File

@@ -1,12 +1,14 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use kameo::error::SendError;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
keyholder::{self, SignUseragentPubkeyIntegrityTag},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
},
db::schema,
@@ -40,7 +42,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| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
@@ -50,12 +56,14 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.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)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.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))
.execute(conn)
.await?;
@@ -75,7 +83,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 key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
@@ -88,6 +100,7 @@ async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> R
schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type),
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
))
.execute(&mut conn)
.await
@@ -120,8 +133,11 @@ where
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
self.verify_pubkey_integrity_before_challenge(&pubkey)
.await?;
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
.send(Ok(Outbound::AuthChallenge { nonce }))
@@ -161,7 +177,15 @@ where
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
.send(Ok(Outbound::AuthSuccess))
@@ -220,3 +244,91 @@ where
Ok(key.clone())
}
}
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(SignUseragentPubkeyIntegrityTag {
public_key: pubkey.to_stored_bytes(),
key_type: pubkey.key_type(),
})
.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_integrity_before_challenge(
&self,
pubkey: &AuthPublicKey,
) -> Result<(), 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(());
};
let Some(stored_tag) = stored_tag else {
error!("Missing pubkey integrity tag for registered key while vault is unsealed");
return Err(Error::InvalidChallengeSolution);
};
if stored_tag != expected_tag {
error!("User-agent pubkey integrity tag mismatch");
return Err(Error::InvalidChallengeSolution);
}
Ok(())
}
}

View File

@@ -2,12 +2,11 @@ use std::sync::Mutex;
use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::sql_types::ops::Add;
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper, dsl::update};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context;
use kameo::{message, messages};
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -15,9 +14,8 @@ use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnsw
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
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::safe_cell::SafeCell;
use crate::{
@@ -25,7 +23,7 @@ use crate::{
evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
keyholder::{self, Bootstrap, SignUseragentPubkeyIntegrityTag, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
@@ -87,6 +85,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(SignUseragentPubkeyIntegrityTag {
public_key,
key_type,
})
.await
.map_err(|err| {
error!(?err, "Failed to sign user-agent pubkey integrity tag");
Error::internal("Failed to sign user-agent pubkey 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 {
@@ -174,6 +222,8 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_missing_useragent_pubkey_integrity_tags()
.await?;
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -235,6 +285,8 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_missing_useragent_pubkey_integrity_tags()
.await?;
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())

View File

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

View File

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

View File

@@ -3,9 +3,11 @@ use arbiter_server::{
actors::{
GlobalActors,
bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, auth},
},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl;
@@ -165,3 +167,120 @@ pub async fn test_challenge_auth() {
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();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution)
));
}

View File

@@ -2,14 +2,17 @@ use arbiter_server::{
actors::{
GlobalActors,
keyholder::{Bootstrap, Seal},
user_agent::{UserAgentSession, session::connection::{
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
}},
user_agent::{
UserAgentSession,
session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
},
db,
safe_cell::{SafeCell, SafeCellHandle as _},
};
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 x25519_dalek::{EphemeralSecret, PublicKey};
@@ -149,3 +152,42 @@ pub async fn test_unseal_retry_after_invalid_key() {
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))
);
}
}