use hpke::{
Deserializable, Kem, OpModeR, OpModeS, Serializable, aead::ChaCha20Poly1305, kdf::HkdfSha256,
kem::X25519HkdfSha256, single_shot_open, single_shot_seal,
};
use pubky_common::crypto::random_bytes;
use zeroize::Zeroizing;
use crate::errors::{AuthError, Result};
type ApprovalKem = X25519HkdfSha256;
const INFO: &[u8] = b"pubky-grant-approval-hpke-v1";
const WIRE_PREFIX: &[u8] = b"pubky-hpke-v1\0";
#[derive(Clone)]
pub(crate) struct ApprovalRecipientSecret(Zeroizing<[u8; 32]>);
impl std::fmt::Debug for ApprovalRecipientSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
impl ApprovalRecipientSecret {
pub(crate) fn generate() -> (Self, [u8; 32]) {
let ikm = Zeroizing::new(random_bytes::<32>());
let (private_key, public_key) = ApprovalKem::derive_keypair(&*ikm);
let mut secret = Zeroizing::new([0; 32]);
private_key.write_exact(&mut secret[..]);
let public: [u8; 32] = public_key.to_bytes().into();
(Self(secret), public)
}
pub(crate) fn from_bytes(secret: [u8; 32]) -> Self {
Self(Zeroizing::new(secret))
}
pub(crate) fn to_bytes(&self) -> [u8; 32] {
*self.0
}
pub(crate) fn public_key(&self) -> [u8; 32] {
let private_key = <ApprovalKem as Kem>::PrivateKey::from_bytes(&*self.0)
.expect("stored approval secret is a valid X25519 private key");
<ApprovalKem as Kem>::sk_to_pk(&private_key)
.to_bytes()
.into()
}
pub(crate) fn matches_public_key(&self, expected: &[u8; 32]) -> bool {
self.public_key() == *expected
}
pub(crate) fn open(&self, wire: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
let invalid = || AuthError::Validation("invalid HPKE grant approval".into());
if !wire.starts_with(WIRE_PREFIX) {
return Err(invalid().into());
}
let body = &wire[WIRE_PREFIX.len()..];
let encapped_len = <ApprovalKem as Kem>::EncappedKey::size();
if body.len() <= encapped_len {
return Err(invalid().into());
}
let private_key =
<ApprovalKem as Kem>::PrivateKey::from_bytes(&*self.0).map_err(|_error| invalid())?;
let encapped_key = <ApprovalKem as Kem>::EncappedKey::from_bytes(&body[..encapped_len])
.map_err(|_error| invalid())?;
let plaintext = single_shot_open::<ChaCha20Poly1305, HkdfSha256, ApprovalKem>(
&OpModeR::Base,
&private_key,
&encapped_key,
INFO,
&body[encapped_len..],
b"",
)
.map_err(|_error| invalid())?;
Ok(Zeroizing::new(plaintext))
}
}
pub(crate) fn seal(public_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let public_key = <ApprovalKem as Kem>::PublicKey::from_bytes(public_key)
.map_err(|_error| AuthError::Validation("invalid HPKE approval public key".into()))?;
let (encapped_key, ciphertext) = single_shot_seal::<ChaCha20Poly1305, HkdfSha256, ApprovalKem>(
&OpModeS::Base,
&public_key,
INFO,
plaintext,
b"",
)
.map_err(|_error| AuthError::Validation("failed to encrypt HPKE grant approval".into()))?;
let encapped_key = encapped_key.to_bytes();
let mut wire = Vec::with_capacity(WIRE_PREFIX.len() + encapped_key.len() + ciphertext.len());
wire.extend_from_slice(WIRE_PREFIX);
wire.extend_from_slice(&encapped_key);
wire.extend_from_slice(&ciphertext);
Ok(wire)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approval_round_trips_and_rejects_wrong_keys_and_unmarked_payloads() {
let (recipient, public_key) = ApprovalRecipientSecret::generate();
let plaintext = b"signed approval";
let ciphertext = seal(&public_key, plaintext).unwrap();
assert_eq!(&*recipient.open(&ciphertext).unwrap(), plaintext);
ApprovalRecipientSecret::generate()
.0
.open(&ciphertext)
.unwrap_err();
recipient.open(plaintext).unwrap_err();
}
#[test]
fn relay_cannot_encrypt_approval_with_its_channel_id() {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let (recipient, public_key) = ApprovalRecipientSecret::generate();
let channel = crate::actors::auth::deep_links::GrantRelayChannel::Hpke {
ephemeral_public_key: public_key,
};
let channel_id = channel.http_channel_id();
let mut relay_visible_key = [0; 32];
URL_SAFE_NO_PAD
.decode_slice(channel_id, &mut relay_visible_key)
.unwrap();
let forged_message = seal(&relay_visible_key, b"forged approval").unwrap();
recipient.open(&forged_message).unwrap_err();
}
}