3 Commits

Author SHA1 Message Date
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
4 changed files with 100 additions and 65 deletions

View File

@@ -172,15 +172,15 @@ 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. /// Derives a dedicated key used for integrity tags within a specific domain.
pub fn derive_useragent_integrity_key(seal_key: &mut KeyCell) -> KeyCell { pub fn derive_integrity_key(seal_key: &mut KeyCell, derive_tag: &[u8]) -> KeyCell {
type HmacSha256 = hmac::Hmac<sha2::Sha256>; type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut derived = SafeCell::new(Key::default()); let mut derived = SafeCell::new(Key::default());
seal_key.0.read_inline(|seal_key_bytes| { seal_key.0.read_inline(|seal_key_bytes| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(seal_key_bytes.as_ref()) 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"); .expect("HMAC key initialization must not fail for 32-byte key");
mac.update(USERAGENT_INTEGRITY_DERIVE_TAG); mac.update(derive_tag);
let output = mac.finalize().into_bytes(); let output = mac.finalize().into_bytes();
let mut writer = derived.write(); let mut writer = derived.write();
@@ -191,25 +191,29 @@ pub fn derive_useragent_integrity_key(seal_key: &mut KeyCell) -> KeyCell {
derived.into() derived.into()
} }
/// Computes an integrity tag for a user-agent pubkey DB entry. /// Computes an integrity tag for a specific domain and payload shape.
pub fn compute_useragent_pubkey_integrity_tag( pub fn compute_integrity_tag<'a, I>(
integrity_key: &mut KeyCell, integrity_key: &mut KeyCell,
key_type_discriminant: i32, purpose_tag: &[u8],
public_key: &[u8], data_parts: I,
) -> [u8; 32] { ) -> [u8; 32]
where
I: IntoIterator<Item = &'a [u8]>,
{
type HmacSha256 = hmac::Hmac<sha2::Sha256>; type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut tag = [0u8; 32]; let mut output_tag = [0u8; 32];
integrity_key.0.read_inline(|integrity_key_bytes| { integrity_key.0.read_inline(|integrity_key_bytes| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref()) 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"); .expect("HMAC key initialization must not fail for 32-byte key");
mac.update(USERAGENT_INTEGRITY_TAG); mac.update(purpose_tag);
mac.update(&key_type_discriminant.to_be_bytes()); for data_part in data_parts {
mac.update(public_key); mac.update(data_part);
tag.copy_from_slice(&mac.finalize().into_bytes()); }
output_tag.copy_from_slice(&mac.finalize().into_bytes());
}); });
tag output_tag
} }
#[cfg(test)] #[cfg(test)]
@@ -285,22 +289,41 @@ mod tests {
} }
#[test] #[test]
pub fn useragent_integrity_tag_deterministic() { pub fn integrity_tag_deterministic() {
let salt = generate_salt(); let salt = generate_salt();
let mut seal_key = derive_seal_key(SafeCell::new(b"password".to_vec()), &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 mut integrity_key = derive_integrity_key(&mut seal_key, USERAGENT_INTEGRITY_DERIVE_TAG);
let t1 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 1, b"pubkey"); let key_type = 1i32.to_be_bytes();
let t2 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 1, b"pubkey"); 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); assert_eq!(t1, t2);
} }
#[test] #[test]
pub fn useragent_integrity_tag_changes_with_key_type() { pub fn integrity_tag_changes_with_payload() {
let salt = generate_salt(); let salt = generate_salt();
let mut seal_key = derive_seal_key(SafeCell::new(b"password".to_vec()), &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 mut integrity_key = derive_integrity_key(&mut seal_key, USERAGENT_INTEGRITY_DERIVE_TAG);
let t1 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 1, b"pubkey"); let key_type_1 = 1i32.to_be_bytes();
let t2 = compute_useragent_pubkey_integrity_tag(&mut integrity_key, 2, b"pubkey"); 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); assert_ne!(t1, t2);
} }
} }

View File

@@ -32,7 +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, integrity_key: KeyCell,
}, },
} }
@@ -146,7 +146,8 @@ 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 integrity_key =
v1::derive_integrity_key(&mut seal_key, v1::USERAGENT_INTEGRITY_DERIVE_TAG);
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
@@ -195,7 +196,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, integrity_key,
}; };
info!("Keyholder bootstrapped successfully"); info!("Keyholder bootstrapped successfully");
@@ -229,7 +230,8 @@ 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 integrity_key =
v1::derive_integrity_key(&mut seal_key, v1::USERAGENT_INTEGRITY_DERIVE_TAG);
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -253,7 +255,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, integrity_key,
}; };
info!("Keyholder unsealed successfully"); info!("Keyholder unsealed successfully");
@@ -261,25 +263,21 @@ 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] #[message]
pub fn sign_useragent_pubkey_integrity_tag( pub fn sign_integrity_tag(
&mut self, &mut self,
public_key: Vec<u8>, purpose_tag: Vec<u8>,
key_type: models::KeyType, data_parts: Vec<Vec<u8>>,
) -> Result<Vec<u8>, Error> { ) -> Result<Vec<u8>, Error> {
let State::Unsealed { let State::Unsealed { integrity_key, .. } = &mut self.state else {
useragent_integrity_key,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapped); return Err(Error::NotBootstrapped);
}; };
let tag = v1::compute_useragent_pubkey_integrity_tag( let tag = v1::compute_integrity_tag(
useragent_integrity_key, integrity_key,
key_type as i32, &purpose_tag,
&public_key, data_parts.iter().map(Vec::as_slice),
); );
Ok(tag.to_vec()) Ok(tag.to_vec())
} }

View File

@@ -8,12 +8,19 @@ use super::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::ConsumeToken, bootstrap::ConsumeToken,
keyholder::{self, SignUseragentPubkeyIntegrityTag}, keyholder::{self, SignIntegrityTag},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
}, },
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,
} }
@@ -133,8 +140,12 @@ 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) match self.verify_pubkey_attestation_status(&pubkey).await? {
.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, pubkey.key_type()).await?; let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
@@ -257,9 +268,12 @@ where
.conn .conn
.actors .actors
.key_holder .key_holder
.ask(SignUseragentPubkeyIntegrityTag { .ask(SignIntegrityTag {
public_key: pubkey.to_stored_bytes(), purpose_tag: keyholder::encryption::v1::USERAGENT_INTEGRITY_TAG.to_vec(),
key_type: pubkey.key_type(), data_parts: vec![
(pubkey.key_type() as i32).to_be_bytes().to_vec(),
pubkey.to_stored_bytes(),
],
}) })
.await; .await;
@@ -287,10 +301,10 @@ where
} }
} }
async fn verify_pubkey_integrity_before_challenge( async fn verify_pubkey_attestation_status(
&self, &self,
pubkey: &AuthPublicKey, pubkey: &AuthPublicKey,
) -> Result<(), Error> { ) -> Result<AttestationStatus, Error> {
let stored_tag: Option<Option<Vec<u8>>> = { let stored_tag: Option<Option<Vec<u8>>> = {
let mut conn = self.conn.db.get().await.map_err(|e| { let mut conn = self.conn.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
@@ -316,19 +330,19 @@ where
let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else { let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else {
// Vault sealed/unbootstrapped: cannot verify integrity yet. // Vault sealed/unbootstrapped: cannot verify integrity yet.
return Ok(()); return Ok(AttestationStatus::Unavailable);
}; };
let Some(stored_tag) = stored_tag else { match stored_tag {
error!("Missing pubkey integrity tag for registered key while vault is unsealed"); Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested),
return Err(Error::InvalidChallengeSolution); Some(_) => {
};
if stored_tag != expected_tag {
error!("User-agent pubkey integrity tag mismatch"); error!("User-agent pubkey integrity tag mismatch");
return Err(Error::InvalidChallengeSolution); Ok(AttestationStatus::NotAttested)
}
None => {
error!("Missing pubkey integrity tag for registered key while vault is unsealed");
Ok(AttestationStatus::NotAttested)
}
} }
Ok(())
} }
} }

View File

@@ -23,7 +23,7 @@ use crate::{
evm::{ evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants, Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
}, },
keyholder::{self, Bootstrap, SignUseragentPubkeyIntegrityTag, TryUnseal}, keyholder::{self, Bootstrap, SignIntegrityTag, TryUnseal},
user_agent::session::{ user_agent::session::{
UserAgentSession, UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates}, state::{UnsealContext, UserAgentEvents, UserAgentStates},
@@ -111,14 +111,14 @@ impl UserAgentSession {
.props .props
.actors .actors
.key_holder .key_holder
.ask(SignUseragentPubkeyIntegrityTag { .ask(SignIntegrityTag {
public_key, purpose_tag: keyholder::encryption::v1::USERAGENT_INTEGRITY_TAG.to_vec(),
key_type, data_parts: vec![(key_type as i32).to_be_bytes().to_vec(), public_key],
}) })
.await .await
.map_err(|err| { .map_err(|err| {
error!(?err, "Failed to sign user-agent pubkey integrity tag"); error!(?err, "Failed to sign integrity tag");
Error::internal("Failed to sign user-agent pubkey integrity tag") Error::internal("Failed to sign integrity tag")
})?; })?;
updates.push((id, tag)); updates.push((id, tag));
} }