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:
Jordan Mecom
2026-09-28 12:46:43 -07:00
committed by GitHub
parent 548718e042
commit cc36d25bec
4 changed files with 825 additions and 0 deletions

View 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;

View 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
);
}

View File

@@ -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;

View 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"
}
}