feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
This commit is contained in:
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -737,6 +737,7 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"fatality",
|
"fatality",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hmac",
|
||||||
"insta",
|
"insta",
|
||||||
"k256",
|
"k256",
|
||||||
"kameo",
|
"kameo",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use chacha20poly1305::{
|
|||||||
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
|
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
|
||||||
aead::{AeadMut, Error, Payload},
|
aead::{AeadMut, Error, Payload},
|
||||||
};
|
};
|
||||||
|
use hmac::Mac as _;
|
||||||
use rand::{
|
use rand::{
|
||||||
Rng as _, SeedableRng,
|
Rng as _, SeedableRng,
|
||||||
rngs::{StdRng, SysRng},
|
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 ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
|
||||||
pub const TAG: &[u8] = "arbiter/private-key/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;
|
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()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ enum State {
|
|||||||
Unsealed {
|
Unsealed {
|
||||||
root_key_history_id: i32,
|
root_key_history_id: i32,
|
||||||
root_key: KeyCell,
|
root_key: KeyCell,
|
||||||
|
useragent_integrity_key: KeyCell,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@ impl KeyHolder {
|
|||||||
}
|
}
|
||||||
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 = 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();
|
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
|
||||||
@@ -193,6 +195,7 @@ impl KeyHolder {
|
|||||||
self.state = State::Unsealed {
|
self.state = State::Unsealed {
|
||||||
root_key,
|
root_key,
|
||||||
root_key_history_id,
|
root_key_history_id,
|
||||||
|
useragent_integrity_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Keyholder bootstrapped successfully");
|
info!("Keyholder bootstrapped successfully");
|
||||||
@@ -226,6 +229,7 @@ impl KeyHolder {
|
|||||||
Error::BrokenDatabase
|
Error::BrokenDatabase
|
||||||
})?;
|
})?;
|
||||||
let mut seal_key = v1::derive_seal_key(seal_key_raw, &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 = SafeCell::new(current_key.ciphertext.clone());
|
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!(?err, "Broken database: invalid encryption key size");
|
||||||
Error::BrokenDatabase
|
Error::BrokenDatabase
|
||||||
})?,
|
})?,
|
||||||
|
useragent_integrity_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Keyholder unsealed successfully");
|
info!("Keyholder unsealed successfully");
|
||||||
@@ -257,6 +262,28 @@ impl KeyHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
|
// 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]
|
#[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 +319,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);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
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, SignUseragentPubkeyIntegrityTag},
|
||||||
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
|
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
|
||||||
},
|
},
|
||||||
db::schema,
|
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| {
|
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 +56,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 +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 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 +100,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 +133,11 @@ where
|
|||||||
&mut self,
|
&mut self,
|
||||||
ChallengeRequest { pubkey }: ChallengeRequest,
|
ChallengeRequest { pubkey }: ChallengeRequest,
|
||||||
) -> Result<ChallengeContext, Self::Error> {
|
) -> Result<ChallengeContext, Self::Error> {
|
||||||
|
self.verify_pubkey_integrity_before_challenge(&pubkey)
|
||||||
|
.await?;
|
||||||
|
|
||||||
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 +177,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))
|
||||||
@@ -220,3 +244,91 @@ where
|
|||||||
Ok(key.clone())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ 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};
|
||||||
|
|
||||||
@@ -15,9 +14,8 @@ use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnsw
|
|||||||
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::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 +23,7 @@ use crate::{
|
|||||||
evm::{
|
evm::{
|
||||||
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
|
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
|
||||||
},
|
},
|
||||||
keyholder::{self, Bootstrap, TryUnseal},
|
keyholder::{self, Bootstrap, SignUseragentPubkeyIntegrityTag, TryUnseal},
|
||||||
user_agent::session::{
|
user_agent::session::{
|
||||||
UserAgentSession,
|
UserAgentSession,
|
||||||
state::{UnsealContext, UserAgentEvents, UserAgentStates},
|
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 {
|
pub struct UnsealStartResponse {
|
||||||
@@ -174,6 +222,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 +285,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(())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,120 @@ 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();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
task.await.unwrap(),
|
||||||
|
Err(auth::Error::InvalidChallengeSolution)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user