mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Add native HPKE encryption for nsec backups (#7849)
## Summary Add a Rust API that encrypts an nsec into a backup envelope using RFC 9180 Base mode: DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, and AES-256-GCM. --------- Signed-off-by: jm <jm@squareup.com> Signed-off-by: Jordan Mecom <jm@squareup.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
393
desktop/src-tauri/src/hpke_key_backup.rs
Normal file
393
desktop/src-tauri/src/hpke_key_backup.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
//! HPKE encryption for a future enterprise identity-backup service.
|
||||
//!
|
||||
//! This module only creates the opaque client envelope. Recipient enrollment,
|
||||
//! upload, authorization, storage, and recovery are deliberately outside its
|
||||
//! scope. In particular, this is a native Rust API and is not registered as a
|
||||
//! Tauri command: renderer-controlled recipient keys would turn encryption into
|
||||
//! an nsec export path.
|
||||
//!
|
||||
//! This is a pure sealing component, not an active [`crate::app_state::AppState`]
|
||||
//! backup operation. A future enrollment/upload path must serialize identity
|
||||
//! selection and sealing with `AppState::identity_mutation`, obtain the live
|
||||
//! identity through `AppState::signing_keys()`, and bind its public key to the
|
||||
//! authenticated enrollment before upload. This API alone does not protect
|
||||
//! against identity lock, loss, or concurrent rotation, and its tests do not
|
||||
//! claim that runtime integration.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{PublicKey, SecretKey};
|
||||
use rustls::crypto::{
|
||||
aws_lc_rs::hpke::DH_KEM_P256_HKDF_SHA256_AES_256,
|
||||
hpke::{Hpke, HpkePublicKey},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// HPKE `info` bytes for every v1 nsec-backup context.
|
||||
pub const HPKE_INFO_V1: &[u8] = b"buzz/nsec-backup/v1";
|
||||
|
||||
/// Version of the envelope and associated-data contract.
|
||||
pub const ENVELOPE_VERSION_V1: u8 = 1;
|
||||
|
||||
/// RFC 9180 KEM ID for DHKEM(P-256, HKDF-SHA256).
|
||||
pub const KEM_ID_P256_HKDF_SHA256: u16 = 0x0010;
|
||||
|
||||
/// RFC 9180 KDF ID for HKDF-SHA256.
|
||||
pub const KDF_ID_HKDF_SHA256: u16 = 0x0001;
|
||||
|
||||
/// RFC 9180 AEAD ID for AES-256-GCM.
|
||||
pub const AEAD_ID_AES_256_GCM: u16 = 0x0002;
|
||||
|
||||
/// Encoded length of an uncompressed SEC1 P-256 public key.
|
||||
pub const P256_PUBLIC_KEY_LEN: usize = 65;
|
||||
|
||||
/// Encoded length of a v1 ciphertext: 32 secret bytes plus a 16-byte GCM tag.
|
||||
pub const CIPHERTEXT_LEN: usize = 48;
|
||||
|
||||
const AAD_DOMAIN_V1: &[u8] = b"buzz/nsec-backup/aad/v1";
|
||||
const MAX_CONTEXT_FIELD_LEN: usize = 255;
|
||||
const P256_PUBLIC_KEY_BASE64URL_LEN: usize = 87;
|
||||
const CIPHERTEXT_BASE64URL_LEN: usize = 64;
|
||||
|
||||
/// Errors produced while validating or sealing an HPKE nsec-backup envelope.
|
||||
///
|
||||
/// Error values never contain secret-key bytes or plaintext.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HpkeBackupError {
|
||||
/// An enrollment or envelope field did not satisfy the v1 wire contract.
|
||||
InvalidField {
|
||||
/// Name of the invalid field.
|
||||
field: &'static str,
|
||||
/// Non-sensitive validation failure.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// The recipient key is not an uncompressed P-256 SEC1 point.
|
||||
InvalidRecipientKey(&'static str),
|
||||
/// An envelope is inconsistent with the v1 wire contract.
|
||||
InvalidEnvelope(&'static str),
|
||||
/// HPKE rejected the recipient key or failed to seal the plaintext.
|
||||
EncryptionFailed,
|
||||
}
|
||||
|
||||
impl fmt::Display for HpkeBackupError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidField { field, reason } => {
|
||||
write!(formatter, "invalid backup {field}: {reason}")
|
||||
}
|
||||
Self::InvalidRecipientKey(reason) => {
|
||||
write!(formatter, "invalid HPKE recipient public key: {reason}")
|
||||
}
|
||||
Self::InvalidEnvelope(reason) => write!(formatter, "invalid backup envelope: {reason}"),
|
||||
Self::EncryptionFailed => {
|
||||
formatter.write_str("HPKE backup encryption failed; recipient key may be invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HpkeBackupError {}
|
||||
|
||||
/// Fixed algorithm identifiers carried by a v1 envelope.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HpkeSuiteIds {
|
||||
kem_id: u16,
|
||||
kdf_id: u16,
|
||||
aead_id: u16,
|
||||
}
|
||||
|
||||
impl HpkeSuiteIds {
|
||||
const V1: Self = Self {
|
||||
kem_id: KEM_ID_P256_HKDF_SHA256,
|
||||
kdf_id: KDF_ID_HKDF_SHA256,
|
||||
aead_id: AEAD_ID_AES_256_GCM,
|
||||
};
|
||||
|
||||
fn is_v1(self) -> bool {
|
||||
self == Self::V1
|
||||
}
|
||||
}
|
||||
|
||||
/// Enrollment values authenticated into one backup envelope.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HpkeBackupEnrollment {
|
||||
recipient_key_id: String,
|
||||
service_namespace: String,
|
||||
owner_id: String,
|
||||
backup_id: Uuid,
|
||||
recipient_public_key: [u8; P256_PUBLIC_KEY_LEN],
|
||||
}
|
||||
|
||||
impl HpkeBackupEnrollment {
|
||||
/// Validate enrollment metadata supplied by a trusted native integration.
|
||||
///
|
||||
/// Identifier strings are encoded as UTF-8 bytes without normalization in
|
||||
/// AAD. Each must be 1 through 255 bytes and must not contain control
|
||||
/// characters. The backup ID is encoded as its 16 raw UUID bytes.
|
||||
pub fn new(
|
||||
recipient_key_id: impl Into<String>,
|
||||
service_namespace: impl Into<String>,
|
||||
owner_id: impl Into<String>,
|
||||
backup_id: Uuid,
|
||||
recipient_public_key_sec1: &[u8],
|
||||
) -> Result<Self, HpkeBackupError> {
|
||||
let recipient_key_id = recipient_key_id.into();
|
||||
let service_namespace = service_namespace.into();
|
||||
let owner_id = owner_id.into();
|
||||
validate_context_field("recipient key ID", &recipient_key_id)?;
|
||||
validate_context_field("service namespace", &service_namespace)?;
|
||||
validate_context_field("owner ID", &owner_id)?;
|
||||
|
||||
let recipient_public_key: [u8; P256_PUBLIC_KEY_LEN] = recipient_public_key_sec1
|
||||
.try_into()
|
||||
.map_err(|_| HpkeBackupError::InvalidRecipientKey("expected exactly 65 SEC1 bytes"))?;
|
||||
if recipient_public_key[0] != 0x04 {
|
||||
return Err(HpkeBackupError::InvalidRecipientKey(
|
||||
"expected uncompressed SEC1 prefix 0x04",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
recipient_key_id,
|
||||
service_namespace,
|
||||
owner_id,
|
||||
backup_id,
|
||||
recipient_public_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque v1 backup envelope produced by [`seal_nostr_secret`].
|
||||
///
|
||||
/// JSON serialization is the wire representation. Binary HPKE values use
|
||||
/// URL-safe base64 with no padding. This type contains no plaintext or raw nsec
|
||||
/// material.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HpkeBackupEnvelope {
|
||||
version: u8,
|
||||
suite: HpkeSuiteIds,
|
||||
recipient_key_id: String,
|
||||
service_namespace: String,
|
||||
owner_id: String,
|
||||
backup_id: String,
|
||||
nostr_pubkey: String,
|
||||
enc: String,
|
||||
ciphertext: String,
|
||||
}
|
||||
|
||||
impl HpkeBackupEnvelope {
|
||||
/// Reconstruct the v1 associated-data bytes from envelope metadata.
|
||||
///
|
||||
/// This validates fixed suite/version values and canonical metadata
|
||||
/// encodings before returning bytes. Receivers must separately validate
|
||||
/// `enc` and ciphertext through the byte accessors before HPKE open.
|
||||
pub fn associated_data(&self) -> Result<Vec<u8>, HpkeBackupError> {
|
||||
if self.version != ENVELOPE_VERSION_V1 {
|
||||
return Err(HpkeBackupError::InvalidEnvelope("unsupported version"));
|
||||
}
|
||||
if !self.suite.is_v1() {
|
||||
return Err(HpkeBackupError::InvalidEnvelope("unsupported HPKE suite"));
|
||||
}
|
||||
validate_context_field("recipient key ID", &self.recipient_key_id)?;
|
||||
validate_context_field("service namespace", &self.service_namespace)?;
|
||||
validate_context_field("owner ID", &self.owner_id)?;
|
||||
|
||||
let backup_id = Uuid::parse_str(&self.backup_id)
|
||||
.map_err(|_| HpkeBackupError::InvalidEnvelope("backup ID is not a UUID"))?;
|
||||
if backup_id.hyphenated().to_string() != self.backup_id {
|
||||
return Err(HpkeBackupError::InvalidEnvelope(
|
||||
"backup ID is not canonical lowercase hyphenated UUID",
|
||||
));
|
||||
}
|
||||
|
||||
let nostr_pubkey = PublicKey::from_hex(&self.nostr_pubkey)
|
||||
.map_err(|_| HpkeBackupError::InvalidEnvelope("invalid Nostr public key"))?;
|
||||
if nostr_pubkey.to_hex() != self.nostr_pubkey {
|
||||
return Err(HpkeBackupError::InvalidEnvelope(
|
||||
"Nostr public key is not canonical lowercase hex",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(build_aad(
|
||||
&self.recipient_key_id,
|
||||
&self.service_namespace,
|
||||
&self.owner_id,
|
||||
backup_id.as_bytes(),
|
||||
&nostr_pubkey.to_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Decode and validate the RFC 9180 encapsulated P-256 public-key shape.
|
||||
///
|
||||
/// This enforces the 65-byte uncompressed SEC1 representation and its
|
||||
/// `0x04` prefix. The HPKE provider validates P-256 curve membership when
|
||||
/// a recipient uses these bytes to open the envelope.
|
||||
pub fn encapsulated_key_bytes(&self) -> Result<Vec<u8>, HpkeBackupError> {
|
||||
let decoded = decode_canonical_base64(
|
||||
&self.enc,
|
||||
P256_PUBLIC_KEY_BASE64URL_LEN,
|
||||
P256_PUBLIC_KEY_LEN,
|
||||
"enc",
|
||||
)?;
|
||||
if decoded.first() != Some(&0x04) {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field: "enc",
|
||||
reason: "expected uncompressed SEC1 prefix 0x04",
|
||||
});
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Decode and validate the 32-byte-secret AES-256-GCM ciphertext.
|
||||
pub fn ciphertext_bytes(&self) -> Result<Vec<u8>, HpkeBackupError> {
|
||||
decode_canonical_base64(
|
||||
&self.ciphertext,
|
||||
CIPHERTEXT_BASE64URL_LEN,
|
||||
CIPHERTEXT_LEN,
|
||||
"ciphertext",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seal one native Nostr secret key to a trusted HPKE backup enrollment.
|
||||
///
|
||||
/// The function derives the Nostr public key from `secret_key` and encrypts its
|
||||
/// raw 32-byte secret representation in RFC 9180 Base mode. `rustls` creates a
|
||||
/// fresh ephemeral P-256 key for every call. HPKE Base mode does not
|
||||
/// authenticate the sender; future upload authorization or a separate signature
|
||||
/// must provide that property.
|
||||
pub fn seal_nostr_secret(
|
||||
secret_key: &SecretKey,
|
||||
enrollment: &HpkeBackupEnrollment,
|
||||
) -> Result<HpkeBackupEnvelope, HpkeBackupError> {
|
||||
let nostr_pubkey = PublicKey::from(secret_key.x_only_public_key(nostr::SECP256K1).0).to_hex();
|
||||
let mut envelope = HpkeBackupEnvelope {
|
||||
version: ENVELOPE_VERSION_V1,
|
||||
suite: HpkeSuiteIds::V1,
|
||||
recipient_key_id: enrollment.recipient_key_id.clone(),
|
||||
service_namespace: enrollment.service_namespace.clone(),
|
||||
owner_id: enrollment.owner_id.clone(),
|
||||
backup_id: enrollment.backup_id.hyphenated().to_string(),
|
||||
nostr_pubkey,
|
||||
enc: String::new(),
|
||||
ciphertext: String::new(),
|
||||
};
|
||||
let aad = envelope.associated_data()?;
|
||||
// This clears our buffer on drop, but not the HPKE provider's internal
|
||||
// plaintext copy, which can be freed without zeroization.
|
||||
let plaintext = Zeroizing::new(secret_key.to_secret_bytes());
|
||||
|
||||
let recipient = HpkePublicKey(enrollment.recipient_public_key.to_vec());
|
||||
let (enc, ciphertext) = DH_KEM_P256_HKDF_SHA256_AES_256
|
||||
.seal(HPKE_INFO_V1, &aad, plaintext.as_ref(), &recipient)
|
||||
.map_err(|_| HpkeBackupError::EncryptionFailed)?;
|
||||
|
||||
if enc.0.len() != P256_PUBLIC_KEY_LEN || ciphertext.len() != CIPHERTEXT_LEN {
|
||||
return Err(HpkeBackupError::EncryptionFailed);
|
||||
}
|
||||
envelope.enc = URL_SAFE_NO_PAD.encode(enc.0);
|
||||
envelope.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext);
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
fn validate_context_field(field: &'static str, value: &str) -> Result<(), HpkeBackupError> {
|
||||
if value.is_empty() {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "must not be empty",
|
||||
});
|
||||
}
|
||||
if value.len() > MAX_CONTEXT_FIELD_LEN {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "must be at most 255 UTF-8 bytes",
|
||||
});
|
||||
}
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "must not contain control characters",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_aad(
|
||||
recipient_key_id: &str,
|
||||
service_namespace: &str,
|
||||
owner_id: &str,
|
||||
backup_id: &[u8; 16],
|
||||
nostr_pubkey: &[u8; 32],
|
||||
) -> Vec<u8> {
|
||||
let mut aad = Vec::with_capacity(
|
||||
AAD_DOMAIN_V1.len()
|
||||
+ 7
|
||||
+ 6
|
||||
+ recipient_key_id.len()
|
||||
+ service_namespace.len()
|
||||
+ owner_id.len()
|
||||
+ backup_id.len()
|
||||
+ nostr_pubkey.len(),
|
||||
);
|
||||
aad.extend_from_slice(AAD_DOMAIN_V1);
|
||||
aad.push(ENVELOPE_VERSION_V1);
|
||||
aad.extend_from_slice(&KEM_ID_P256_HKDF_SHA256.to_be_bytes());
|
||||
aad.extend_from_slice(&KDF_ID_HKDF_SHA256.to_be_bytes());
|
||||
aad.extend_from_slice(&AEAD_ID_AES_256_GCM.to_be_bytes());
|
||||
push_framed(&mut aad, recipient_key_id.as_bytes());
|
||||
push_framed(&mut aad, service_namespace.as_bytes());
|
||||
push_framed(&mut aad, owner_id.as_bytes());
|
||||
aad.extend_from_slice(backup_id);
|
||||
aad.extend_from_slice(nostr_pubkey);
|
||||
aad
|
||||
}
|
||||
|
||||
fn push_framed(output: &mut Vec<u8>, value: &[u8]) {
|
||||
// All callers first apply MAX_CONTEXT_FIELD_LEN, which is below u16::MAX.
|
||||
output.extend_from_slice(&(value.len() as u16).to_be_bytes());
|
||||
output.extend_from_slice(value);
|
||||
}
|
||||
|
||||
fn decode_canonical_base64(
|
||||
value: &str,
|
||||
expected_encoded_len: usize,
|
||||
expected_decoded_len: usize,
|
||||
field: &'static str,
|
||||
) -> Result<Vec<u8>, HpkeBackupError> {
|
||||
// `str::len` is the UTF-8 byte length. Check it before decoding so an
|
||||
// untrusted JSON string cannot cause an allocation proportional to input.
|
||||
if value.len() != expected_encoded_len {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "unexpected encoded length",
|
||||
});
|
||||
}
|
||||
let decoded = URL_SAFE_NO_PAD
|
||||
.decode(value)
|
||||
.map_err(|_| HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "invalid base64url",
|
||||
})?;
|
||||
if decoded.len() != expected_decoded_len {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "unexpected decoded length",
|
||||
});
|
||||
}
|
||||
if URL_SAFE_NO_PAD.encode(&decoded) != value {
|
||||
return Err(HpkeBackupError::InvalidField {
|
||||
field,
|
||||
reason: "base64url is not canonical and unpadded",
|
||||
});
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hpke_key_backup_tests.rs"]
|
||||
mod tests;
|
||||
408
desktop/src-tauri/src/hpke_key_backup_tests.rs
Normal file
408
desktop/src-tauri/src/hpke_key_backup_tests.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{Keys, SecretKey, ToBech32};
|
||||
use rustls::crypto::hpke::{EncapsulatedSecret, Hpke, HpkePrivateKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_secret() -> SecretKey {
|
||||
SecretKey::from_slice(&[0x42; 32]).expect("fixed test secret is valid")
|
||||
}
|
||||
|
||||
fn test_backup_id() -> Uuid {
|
||||
Uuid::parse_str("018f6f4e-83d1-7a4e-8c8e-4f914f7d31a2").expect("fixed UUID is valid")
|
||||
}
|
||||
|
||||
fn test_recipient() -> (rustls::crypto::hpke::HpkePublicKey, HpkePrivateKey) {
|
||||
DH_KEM_P256_HKDF_SHA256_AES_256
|
||||
.generate_key_pair()
|
||||
.expect("generate test HPKE recipient")
|
||||
}
|
||||
|
||||
fn enrollment(public_key: &[u8]) -> HpkeBackupEnrollment {
|
||||
HpkeBackupEnrollment::new(
|
||||
"backup-key-2026-09",
|
||||
"backup/staging",
|
||||
"subject:01J8A4M7Q1G4X8S8M51FS7K2HX",
|
||||
test_backup_id(),
|
||||
public_key,
|
||||
)
|
||||
.expect("valid test enrollment")
|
||||
}
|
||||
|
||||
fn open_envelope(
|
||||
envelope: &HpkeBackupEnvelope,
|
||||
private_key: &HpkePrivateKey,
|
||||
) -> Result<Vec<u8>, rustls::Error> {
|
||||
let aad = envelope
|
||||
.associated_data()
|
||||
.map_err(|error| rustls::Error::General(error.to_string()))?;
|
||||
let enc = envelope
|
||||
.encapsulated_key_bytes()
|
||||
.map_err(|error| rustls::Error::General(error.to_string()))?;
|
||||
let ciphertext = envelope
|
||||
.ciphertext_bytes()
|
||||
.map_err(|error| rustls::Error::General(error.to_string()))?;
|
||||
DH_KEM_P256_HKDF_SHA256_AES_256.open(
|
||||
&EncapsulatedSecret(enc),
|
||||
HPKE_INFO_V1,
|
||||
&aad,
|
||||
&ciphertext,
|
||||
private_key,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_recovers_exact_secret_and_matching_pubkey() {
|
||||
let (public_key, private_key) = test_recipient();
|
||||
let secret = test_secret();
|
||||
let envelope = seal_nostr_secret(&secret, &enrollment(&public_key.0)).expect("seal backup");
|
||||
|
||||
let opened = open_envelope(&envelope, &private_key).expect("open backup");
|
||||
assert_eq!(opened, secret.as_secret_bytes());
|
||||
assert_eq!(
|
||||
envelope.nostr_pubkey,
|
||||
Keys::new(secret).public_key().to_hex()
|
||||
);
|
||||
assert_eq!(envelope.enc.len(), 87);
|
||||
assert_eq!(envelope.ciphertext.len(), 64);
|
||||
assert_eq!(envelope.encapsulated_key_bytes().unwrap().len(), 65);
|
||||
assert_eq!(envelope.ciphertext_bytes().unwrap().len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_backup_uses_a_fresh_hpke_context() {
|
||||
let (public_key, private_key) = test_recipient();
|
||||
let enrollment = enrollment(&public_key.0);
|
||||
let secret = test_secret();
|
||||
|
||||
let first = seal_nostr_secret(&secret, &enrollment).expect("first seal");
|
||||
let second = seal_nostr_secret(&secret, &enrollment).expect("second seal");
|
||||
|
||||
assert_ne!(first.enc, second.enc);
|
||||
assert_ne!(first.ciphertext, second.ciphertext);
|
||||
assert_eq!(open_envelope(&first, &private_key).unwrap(), [0x42; 32]);
|
||||
assert_eq!(open_envelope(&second, &private_key).unwrap(), [0x42; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_invalid_recipient_keys_are_rejected() {
|
||||
let short = [0x04; 64];
|
||||
assert!(matches!(
|
||||
HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &short),
|
||||
Err(HpkeBackupError::InvalidRecipientKey(_))
|
||||
));
|
||||
|
||||
let compressed = [0x02; 33];
|
||||
assert!(
|
||||
HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &compressed)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut wrong_prefix = [0u8; P256_PUBLIC_KEY_LEN];
|
||||
wrong_prefix[0] = 0x03;
|
||||
assert!(
|
||||
HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &wrong_prefix)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut off_curve = [0u8; P256_PUBLIC_KEY_LEN];
|
||||
off_curve[0] = 0x04;
|
||||
let enrollment =
|
||||
HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &off_curve)
|
||||
.expect("structurally encoded point reaches provider validation");
|
||||
assert_eq!(
|
||||
seal_nostr_secret(&test_secret(), &enrollment),
|
||||
Err(HpkeBackupError::EncryptionFailed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_ciphertext_and_encapsulation_tampering_fail_closed() {
|
||||
let (public_key, private_key) = test_recipient();
|
||||
let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap();
|
||||
|
||||
let mut recipient_key_id_tampered = envelope.clone();
|
||||
recipient_key_id_tampered
|
||||
.recipient_key_id
|
||||
.push_str("-different");
|
||||
let mut namespace_tampered = envelope.clone();
|
||||
namespace_tampered.service_namespace.push_str("-different");
|
||||
let mut owner_tampered = envelope.clone();
|
||||
owner_tampered.owner_id.push_str("-different");
|
||||
let mut backup_id_tampered = envelope.clone();
|
||||
backup_id_tampered.backup_id = Uuid::new_v4().hyphenated().to_string();
|
||||
let mut pubkey_tampered = envelope.clone();
|
||||
pubkey_tampered.nostr_pubkey = Keys::generate().public_key().to_hex();
|
||||
for tampered in [
|
||||
recipient_key_id_tampered,
|
||||
namespace_tampered,
|
||||
owner_tampered,
|
||||
backup_id_tampered,
|
||||
pubkey_tampered,
|
||||
] {
|
||||
assert!(open_envelope(&tampered, &private_key).is_err());
|
||||
}
|
||||
|
||||
let aad = envelope.associated_data().unwrap();
|
||||
let enc = envelope.encapsulated_key_bytes().unwrap();
|
||||
let ciphertext = envelope.ciphertext_bytes().unwrap();
|
||||
assert!(DH_KEM_P256_HKDF_SHA256_AES_256
|
||||
.open(
|
||||
&EncapsulatedSecret(enc),
|
||||
b"buzz/nsec-backup/v2",
|
||||
&aad,
|
||||
&ciphertext,
|
||||
&private_key,
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let mut ciphertext_tampered = envelope.clone();
|
||||
let mut ciphertext = ciphertext_tampered.ciphertext_bytes().unwrap();
|
||||
ciphertext[0] ^= 1;
|
||||
ciphertext_tampered.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext);
|
||||
assert!(open_envelope(&ciphertext_tampered, &private_key).is_err());
|
||||
|
||||
let mut enc_tampered = envelope.clone();
|
||||
let mut enc = enc_tampered.encapsulated_key_bytes().unwrap();
|
||||
let last = enc.len() - 1;
|
||||
enc[last] ^= 1;
|
||||
enc_tampered.enc = URL_SAFE_NO_PAD.encode(enc);
|
||||
assert!(open_envelope(&enc_tampered, &private_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_recipient_cannot_open() {
|
||||
let (public_key, _) = test_recipient();
|
||||
let (_, wrong_private_key) = test_recipient();
|
||||
let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap();
|
||||
assert!(open_envelope(&envelope, &wrong_private_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrollment_rejects_invalid_context_fields() {
|
||||
let (public_key, _) = test_recipient();
|
||||
assert!(
|
||||
HpkeBackupEnrollment::new("", "service", "owner", test_backup_id(), &public_key.0).is_err()
|
||||
);
|
||||
assert!(HpkeBackupEnrollment::new(
|
||||
"key",
|
||||
"x".repeat(256),
|
||||
"owner",
|
||||
test_backup_id(),
|
||||
&public_key.0
|
||||
)
|
||||
.is_err());
|
||||
assert!(HpkeBackupEnrollment::new(
|
||||
"key",
|
||||
"service",
|
||||
"owner\nvalue",
|
||||
test_backup_id(),
|
||||
&public_key.0
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aad_length_framing_prevents_field_boundary_collisions() {
|
||||
let (public_key, private_key) = test_recipient();
|
||||
let secret = test_secret();
|
||||
|
||||
for (left, right) in [
|
||||
(["ab", "c", "owner"], ["a", "bc", "owner"]),
|
||||
(["key", "ab", "c"], ["key", "a", "bc"]),
|
||||
] {
|
||||
assert_eq!(left.concat(), right.concat());
|
||||
let enrollment =
|
||||
HpkeBackupEnrollment::new(left[0], left[1], left[2], test_backup_id(), &public_key.0)
|
||||
.unwrap();
|
||||
let envelope = seal_nostr_secret(&secret, &enrollment).unwrap();
|
||||
assert_eq!(
|
||||
open_envelope(&envelope, &private_key).unwrap(),
|
||||
secret.as_secret_bytes()
|
||||
);
|
||||
|
||||
let mut reassigned = envelope.clone();
|
||||
reassigned.recipient_key_id = right[0].into();
|
||||
reassigned.service_namespace = right[1].into();
|
||||
reassigned.owner_id = right[2].into();
|
||||
assert_ne!(
|
||||
envelope.associated_data().unwrap(),
|
||||
reassigned.associated_data().unwrap()
|
||||
);
|
||||
assert!(open_envelope(&reassigned, &private_key).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_noncanonical_or_conflicting_metadata() {
|
||||
let (public_key, _) = test_recipient();
|
||||
let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap();
|
||||
|
||||
let mut wrong_version = envelope.clone();
|
||||
wrong_version.version = 2;
|
||||
assert!(wrong_version.associated_data().is_err());
|
||||
|
||||
let mut wrong_suite = envelope.clone();
|
||||
wrong_suite.suite.aead_id = 1;
|
||||
assert!(wrong_suite.associated_data().is_err());
|
||||
|
||||
let mut uppercase_pubkey = envelope.clone();
|
||||
uppercase_pubkey.nostr_pubkey.make_ascii_uppercase();
|
||||
assert!(uppercase_pubkey.associated_data().is_err());
|
||||
|
||||
let mut padded_base64 = envelope.clone();
|
||||
padded_base64.enc.push('=');
|
||||
assert!(padded_base64.encapsulated_key_bytes().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_binary_fields_with_wrong_byte_lengths_before_decode() {
|
||||
let (public_key, _) = test_recipient();
|
||||
let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap();
|
||||
|
||||
let mut oversized_enc = envelope.clone();
|
||||
oversized_enc.enc.push_str(&"A".repeat(1_000_000));
|
||||
assert_eq!(
|
||||
oversized_enc.encapsulated_key_bytes(),
|
||||
Err(HpkeBackupError::InvalidField {
|
||||
field: "enc",
|
||||
reason: "unexpected encoded length",
|
||||
})
|
||||
);
|
||||
|
||||
let mut short_enc = envelope.clone();
|
||||
short_enc.enc.pop();
|
||||
assert_eq!(
|
||||
short_enc.encapsulated_key_bytes(),
|
||||
Err(HpkeBackupError::InvalidField {
|
||||
field: "enc",
|
||||
reason: "unexpected encoded length",
|
||||
})
|
||||
);
|
||||
|
||||
let mut multibyte_ciphertext = envelope.clone();
|
||||
multibyte_ciphertext.ciphertext = "é".repeat(envelope.ciphertext.len());
|
||||
assert_eq!(
|
||||
multibyte_ciphertext.ciphertext_bytes(),
|
||||
Err(HpkeBackupError::InvalidField {
|
||||
field: "ciphertext",
|
||||
reason: "unexpected encoded length",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_malformed_base64_and_wrong_encapsulation_prefix() {
|
||||
let (public_key, _) = test_recipient();
|
||||
let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap();
|
||||
|
||||
let mut malformed_enc = envelope.clone();
|
||||
malformed_enc.enc.replace_range(..1, "!");
|
||||
assert_eq!(
|
||||
malformed_enc.encapsulated_key_bytes(),
|
||||
Err(HpkeBackupError::InvalidField {
|
||||
field: "enc",
|
||||
reason: "invalid base64url",
|
||||
})
|
||||
);
|
||||
|
||||
let mut malformed_ciphertext = envelope.clone();
|
||||
malformed_ciphertext.ciphertext.replace_range(..1, "!");
|
||||
assert_eq!(
|
||||
malformed_ciphertext.ciphertext_bytes(),
|
||||
Err(HpkeBackupError::InvalidField {
|
||||
field: "ciphertext",
|
||||
reason: "invalid base64url",
|
||||
})
|
||||
);
|
||||
|
||||
let mut wrong_prefix = envelope.clone();
|
||||
let mut enc = URL_SAFE_NO_PAD.decode(&wrong_prefix.enc).unwrap();
|
||||
enc[0] = 0x03;
|
||||
wrong_prefix.enc = URL_SAFE_NO_PAD.encode(enc);
|
||||
assert_eq!(
|
||||
wrong_prefix.encapsulated_key_bytes(),
|
||||
Err(HpkeBackupError::InvalidField {
|
||||
field: "enc",
|
||||
reason: "expected uncompressed SEC1 prefix 0x04",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_envelope_contains_no_plaintext_or_nsec() {
|
||||
let (public_key, _) = test_recipient();
|
||||
let secret = test_secret();
|
||||
let envelope = seal_nostr_secret(&secret, &enrollment(&public_key.0)).unwrap();
|
||||
let json = serde_json::to_string(&envelope).unwrap();
|
||||
let raw_hex = hex::encode(secret.as_secret_bytes());
|
||||
let nsec = secret.to_bech32().unwrap();
|
||||
|
||||
assert!(!json.contains(&raw_hex));
|
||||
assert!(!json.contains(&nsec));
|
||||
assert!(!json.contains("nsec1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_rfc9180_exact_suite_vector_opens() {
|
||||
// RFC 9180's JSON vector for the KEM/KDF/AEAD tuple used here.
|
||||
// Source pinned by the RFC itself:
|
||||
// https://github.com/cfrg/draft-irtf-cfrg-hpke/blob/
|
||||
// 5f503c564da00b0687b3de75f1dfbdfc4079ad31/test-vectors.json
|
||||
let info = hex::decode("4f6465206f6e2061204772656369616e2055726e").unwrap();
|
||||
let aad = hex::decode("436f756e742d30").unwrap();
|
||||
let plaintext =
|
||||
hex::decode("4265617574792069732074727574682c20747275746820626561757479").unwrap();
|
||||
let enc = hex::decode(
|
||||
"04c06b4f6bebc7bb495cb797ab753f911aff80aefb86fd8b6fcc35525f3ab5f03e0b21bd31a86c6048af3cb2d98e0d3bf01da5cc4c39ff5370d331a4f1f7d5a4e0",
|
||||
)
|
||||
.unwrap();
|
||||
let ciphertext = hex::decode(
|
||||
"58c61a45059d0c5704560e9d88b564a8b63f1364b8d1fcb3c4c6ddc1d291742465e902cd216f8908da49f8f96f",
|
||||
)
|
||||
.unwrap();
|
||||
let recipient_private = HpkePrivateKey::from(
|
||||
hex::decode("317f915db7bc629c48fe765587897e01e282d3e8445f79f27f65d031a88082b2").unwrap(),
|
||||
);
|
||||
|
||||
let opened = DH_KEM_P256_HKDF_SHA256_AES_256
|
||||
.open(
|
||||
&EncapsulatedSecret(enc),
|
||||
&info,
|
||||
&aad,
|
||||
&ciphertext,
|
||||
&recipient_private,
|
||||
)
|
||||
.expect("open official CFRG vector");
|
||||
assert_eq!(opened, plaintext);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct InteropFixture {
|
||||
recipient_public_key_sec1_hex: String,
|
||||
recipient_private_key_hex: String,
|
||||
nostr_secret_key_hex: String,
|
||||
aad_hex: String,
|
||||
envelope: HpkeBackupEnvelope,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_in_v1_fixture_opens_and_reconstructs_aad() {
|
||||
let fixture: InteropFixture =
|
||||
serde_json::from_str(include_str!("testdata/hpke_nsec_backup_v1.json"))
|
||||
.expect("parse checked-in interop fixture");
|
||||
let recipient_public = hex::decode(&fixture.recipient_public_key_sec1_hex).unwrap();
|
||||
let recipient_private =
|
||||
HpkePrivateKey::from(hex::decode(&fixture.recipient_private_key_hex).unwrap());
|
||||
let secret = hex::decode(&fixture.nostr_secret_key_hex).unwrap();
|
||||
|
||||
assert_eq!(recipient_public.len(), P256_PUBLIC_KEY_LEN);
|
||||
assert_eq!(
|
||||
hex::encode(fixture.envelope.associated_data().unwrap()),
|
||||
fixture.aad_hex
|
||||
);
|
||||
assert_eq!(
|
||||
open_envelope(&fixture.envelope, &recipient_private).unwrap(),
|
||||
secret
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ mod deep_link;
|
||||
mod egress_guard;
|
||||
mod event_sync;
|
||||
mod events;
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
mod hpke_key_backup;
|
||||
mod huddle;
|
||||
mod identity_storage;
|
||||
mod initial_window;
|
||||
|
||||
22
desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json
vendored
Normal file
22
desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"description": "Buzz HPKE nsec backup v1 Kotlin interop fixture; keys are test-only",
|
||||
"recipient_public_key_sec1_hex": "04c5b6519400186a8e10abbdc05d3e450c8612e5dba06927091527110324acfb0371d98d662395d872fae967fe75ef24db20a7c2c1af19c34422d94f629b1b1dad",
|
||||
"recipient_private_key_hex": "7ad00e296b224923f735144f3cc7e49746c8e3ebf20b8002d0a73998467d8f78",
|
||||
"nostr_secret_key_hex": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"aad_hex": "62757a7a2f6e7365632d6261636b75702f6161642f76310100100001000200126261636b75702d6b65792d323032362d3039000e6261636b75702f73746167696e6700227375626a6563743a30314a3841344d3751314734583853384d35314653374b324858018f6f4e83d17a4e8c8e4f914f7d31a224653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"envelope": {
|
||||
"version": 1,
|
||||
"suite": {
|
||||
"aead_id": 2,
|
||||
"kdf_id": 1,
|
||||
"kem_id": 16
|
||||
},
|
||||
"recipient_key_id": "backup-key-2026-09",
|
||||
"service_namespace": "backup/staging",
|
||||
"owner_id": "subject:01J8A4M7Q1G4X8S8M51FS7K2HX",
|
||||
"backup_id": "018f6f4e-83d1-7a4e-8c8e-4f914f7d31a2",
|
||||
"nostr_pubkey": "24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"enc": "BHCSkhHMDDKXgK5vWuP4Gw3NNaMMdrN-1OPUISh_jczrjoZK7HBmMkZE2iogEKle5syyp3-wgGo69cPeHVNEuWA",
|
||||
"ciphertext": "tkBhVlv3rco5mZOIEbESB2Hx_etTIvWQHJJP7hCOIgEysJjwQqnq7iCC0A516Du8"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user