use blake2::{
digest::{consts::U32, Mac},
Blake2sMac,
};
use bytes::{Buf, BufMut};
use chacha20poly1305::{
aead::{Aead, KeyInit, Payload},
XChaCha20Poly1305,
};
use ed25519_dalek::{Signature, VerifyingKey};
use x25519_dalek::{PublicKey as X25519Pub, StaticSecret as X25519Secret};
use super::entity::{EntityError, EntityKeypair};
use crate::adapter::net::state::causal::{CausalLink, CAUSAL_LINK_SIZE};
pub const IDENTITY_ENVELOPE_VERSION: u8 = 1;
pub const IDENTITY_ENVELOPE_SIZE: usize = 1 + 32 + 80 + 32 + 64;
const KDF_DOMAIN_KEY: &[u8] = b"net-identity-envelope";
const KDF_DOMAIN_NONCE: &[u8] = b"net-identity-nonce";
const SEED_LEN: usize = 32;
const TAG_LEN: usize = 16;
const EPH_PK_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvelopeError {
InvalidAttestation,
SealOpenFailed,
OriginHashMismatch,
InvalidSignerKey,
SourceReadOnly,
UnknownVersion {
got: u8,
expected: u8,
},
}
impl std::fmt::Display for EnvelopeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidAttestation => {
write!(f, "identity envelope: attestation signature invalid")
}
Self::SealOpenFailed => write!(
f,
"identity envelope: seal_open failed (wrong target key or tampered ciphertext)"
),
Self::OriginHashMismatch => write!(
f,
"identity envelope: decrypted seed's origin_hash does not match expected"
),
Self::InvalidSignerKey => {
write!(
f,
"identity envelope: signer_pub is not a valid ed25519 point"
)
}
Self::SourceReadOnly => write!(
f,
"identity envelope: source keypair is public-only; cannot attest"
),
Self::UnknownVersion { got, expected } => write!(
f,
"identity envelope: unknown wire version {got:#04x} (expected {expected:#04x})"
),
}
}
}
impl std::error::Error for EnvelopeError {}
impl From<EntityError> for EnvelopeError {
fn from(e: EntityError) -> Self {
match e {
EntityError::InvalidPublicKey => Self::InvalidSignerKey,
EntityError::InvalidSignature => Self::InvalidAttestation,
EntityError::ReadOnly => Self::SourceReadOnly,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityEnvelope {
pub target_static_pub: [u8; 32],
pub sealed_seed: [u8; 80],
pub signer_pub: [u8; 32],
pub signature: [u8; 64],
}
impl IdentityEnvelope {
#[expect(
clippy::expect_used,
reason = "XChaCha20Poly1305 with a freshly-derived key+nonce cannot fail on a 32-byte msg, and try_sign on a full keypair (checked above via source_kp.try_sign requirement) cannot fail"
)]
pub fn new(
source_kp: &EntityKeypair,
target_static_pub: [u8; 32],
chain_link: &CausalLink,
) -> Result<Self, EnvelopeError> {
let mut seed = source_kp
.try_secret_bytes()
.map_err(EnvelopeError::from)?
.to_owned();
let mut rng_bytes = [0u8; 32];
if let Err(e) = getrandom::fill(&mut rng_bytes) {
eprintln!(
"FATAL: IdentityEnvelope::seal getrandom failure ({e:?}); aborting to avoid weak X25519 ephemeral"
);
std::process::abort();
}
let eph_sk = X25519Secret::from(rng_bytes);
volatile_zero(&mut rng_bytes);
let eph_pk = X25519Pub::from(&eph_sk);
let target_pk = X25519Pub::from(target_static_pub);
let shared = eph_sk.diffie_hellman(&target_pk);
let mut key = derive_key(shared.as_bytes(), KDF_DOMAIN_KEY);
let nonce = derive_nonce(eph_pk.as_bytes(), &target_static_pub);
let aad_bytes = chain_link.to_bytes();
let aead = XChaCha20Poly1305::new((&key).into());
let ciphertext = aead
.encrypt(
(&nonce).into(),
Payload {
msg: &seed,
aad: &aad_bytes,
},
)
.expect("XChaCha20Poly1305 encrypt with fresh key+nonce cannot fail on 32-byte msg");
debug_assert_eq!(ciphertext.len(), SEED_LEN + TAG_LEN);
volatile_zero(&mut seed);
volatile_zero(&mut key);
let mut sealed_seed = [0u8; 80];
sealed_seed[..EPH_PK_LEN].copy_from_slice(eph_pk.as_bytes());
sealed_seed[EPH_PK_LEN..].copy_from_slice(&ciphertext);
let transcript = attestation_transcript(&target_static_pub, chain_link);
let sig = source_kp
.try_sign(&transcript)
.expect("try_sign on a full keypair produced above must not fail");
Ok(Self {
target_static_pub,
sealed_seed,
signer_pub: *source_kp.entity_id().as_bytes(),
signature: sig.to_bytes(),
})
}
pub fn open(
&self,
target_static_priv: &X25519Secret,
chain_link: &CausalLink,
expected_signer_pub: Option<&[u8; 32]>,
) -> Result<EntityKeypair, EnvelopeError> {
if let Some(expected) = expected_signer_pub {
if &self.signer_pub != expected {
return Err(EnvelopeError::InvalidSignerKey);
}
}
let transcript = attestation_transcript(&self.target_static_pub, chain_link);
let verifying_key = VerifyingKey::from_bytes(&self.signer_pub)
.map_err(|_| EnvelopeError::InvalidSignerKey)?;
let sig = Signature::try_from(&self.signature[..])
.map_err(|_| EnvelopeError::InvalidAttestation)?;
verifying_key
.verify_strict(&transcript, &sig)
.map_err(|_| EnvelopeError::InvalidAttestation)?;
let derived_target_pub = X25519Pub::from(target_static_priv);
if derived_target_pub.as_bytes() != &self.target_static_pub {
return Err(EnvelopeError::SealOpenFailed);
}
let (eph_pk_bytes, ct) = self.sealed_seed.split_at(EPH_PK_LEN);
#[expect(
clippy::unwrap_used,
reason = "split_at(EPH_PK_LEN) where EPH_PK_LEN == 32; <[u8; 32]>::try_from(&[u8] of length 32) is infallible"
)]
let eph_pk = X25519Pub::from(<[u8; 32]>::try_from(eph_pk_bytes).unwrap());
let shared = target_static_priv.diffie_hellman(&eph_pk);
let mut key = derive_key(shared.as_bytes(), KDF_DOMAIN_KEY);
let nonce = derive_nonce(eph_pk.as_bytes(), &self.target_static_pub);
let aad = chain_link.to_bytes();
let aead = XChaCha20Poly1305::new((&key).into());
let mut seed_vec = match aead.decrypt((&nonce).into(), Payload { msg: ct, aad: &aad }) {
Ok(v) => v,
Err(_) => {
volatile_zero(&mut key);
return Err(EnvelopeError::SealOpenFailed);
}
};
if seed_vec.len() != SEED_LEN {
volatile_zero(&mut seed_vec);
volatile_zero(&mut key);
return Err(EnvelopeError::SealOpenFailed);
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&seed_vec);
volatile_zero(&mut seed_vec);
let kp = EntityKeypair::from_bytes(seed);
volatile_zero(&mut seed);
volatile_zero(&mut key);
if kp.entity_id().as_bytes() != &self.signer_pub {
return Err(EnvelopeError::InvalidAttestation);
}
Ok(kp)
}
pub fn to_bytes(&self) -> [u8; IDENTITY_ENVELOPE_SIZE] {
let mut buf = [0u8; IDENTITY_ENVELOPE_SIZE];
let mut cursor = &mut buf[..];
cursor.put_u8(IDENTITY_ENVELOPE_VERSION);
cursor.put_slice(&self.target_static_pub);
cursor.put_slice(&self.sealed_seed);
cursor.put_slice(&self.signer_pub);
cursor.put_slice(&self.signature);
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() != IDENTITY_ENVELOPE_SIZE {
return None;
}
if data[0] != IDENTITY_ENVELOPE_VERSION {
return None;
}
let mut cursor = &data[1..];
let mut target_static_pub = [0u8; 32];
cursor.copy_to_slice(&mut target_static_pub);
let mut sealed_seed = [0u8; 80];
cursor.copy_to_slice(&mut sealed_seed);
let mut signer_pub = [0u8; 32];
cursor.copy_to_slice(&mut signer_pub);
let mut signature = [0u8; 64];
cursor.copy_to_slice(&mut signature);
Some(Self {
target_static_pub,
sealed_seed,
signer_pub,
signature,
})
}
}
fn attestation_transcript(
target_static_pub: &[u8; 32],
chain_link: &CausalLink,
) -> [u8; 32 + CAUSAL_LINK_SIZE] {
let mut out = [0u8; 32 + CAUSAL_LINK_SIZE];
out[..32].copy_from_slice(target_static_pub);
out[32..].copy_from_slice(&chain_link.to_bytes());
out
}
#[expect(
clippy::expect_used,
reason = "Blake2sMac::new_from_slice rejects only keys longer than 32 bytes; label slices are always short labels"
)]
fn derive_key(shared: &[u8; 32], label: &[u8]) -> [u8; 32] {
let mut mac = <Blake2sMac<U32> as Mac>::new_from_slice(label)
.expect("BLAKE2s accepts variable-length keys");
Mac::update(&mut mac, shared);
let result = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
fn volatile_zero(buf: &mut [u8]) {
for byte in buf.iter_mut() {
unsafe { std::ptr::write_volatile(byte, 0) };
}
}
#[expect(
clippy::expect_used,
reason = "Blake2sMac::new_from_slice rejects only keys longer than 32 bytes; KDF_DOMAIN_NONCE is a short compile-time-constant label"
)]
fn derive_nonce(eph_pk: &[u8; 32], target_pk: &[u8; 32]) -> [u8; 24] {
let mut mac = <Blake2sMac<U32> as Mac>::new_from_slice(KDF_DOMAIN_NONCE)
.expect("BLAKE2s accepts variable-length keys");
Mac::update(&mut mac, eph_pk);
Mac::update(&mut mac, target_pk);
let result = mac.finalize().into_bytes();
let mut nonce = [0u8; 24];
nonce.copy_from_slice(&result[..24]);
nonce
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::net::identity::EntityKeypair;
use crate::adapter::net::state::causal::CausalLink;
fn fresh_x25519() -> (X25519Secret, [u8; 32]) {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).unwrap();
let sk = X25519Secret::from(seed);
let pk = X25519Pub::from(&sk);
(sk, *pk.as_bytes())
}
fn chain_link_at(seq: u64) -> CausalLink {
CausalLink {
origin_hash: 0xDEAD_BEEF,
horizon_encoded: 0,
sequence: seq,
parent_hash: 0,
}
}
fn raw_fixture() -> IdentityEnvelope {
IdentityEnvelope {
target_static_pub: [0xAA; 32],
sealed_seed: [0xBB; 80],
signer_pub: [0xCC; 32],
signature: [0xDD; 64],
}
}
#[test]
fn wire_size_is_209_bytes() {
assert_eq!(IDENTITY_ENVELOPE_SIZE, 209);
assert_eq!(raw_fixture().to_bytes().len(), 209);
}
#[test]
fn first_byte_is_version_marker() {
let bytes = raw_fixture().to_bytes();
assert_eq!(bytes[0], IDENTITY_ENVELOPE_VERSION);
assert_eq!(IDENTITY_ENVELOPE_VERSION, 1);
}
#[test]
fn from_bytes_rejects_unknown_version() {
let mut bytes = raw_fixture().to_bytes();
bytes[0] = 0; assert!(
IdentityEnvelope::from_bytes(&bytes).is_none(),
"post-#102 reader must reject the v0 shape; rolling-upgrade compat is gone"
);
bytes[0] = 0xFF;
assert!(IdentityEnvelope::from_bytes(&bytes).is_none());
}
#[test]
fn roundtrip_preserves_all_fields() {
let env = raw_fixture();
let bytes = env.to_bytes();
let decoded = IdentityEnvelope::from_bytes(&bytes).expect("roundtrip");
assert_eq!(decoded, env);
}
#[test]
fn from_bytes_rejects_truncated() {
let env = raw_fixture();
let bytes = env.to_bytes();
assert!(IdentityEnvelope::from_bytes(&bytes[..208]).is_none());
assert!(IdentityEnvelope::from_bytes(&[]).is_none());
}
#[test]
fn from_bytes_rejects_trailing_garbage() {
let env = raw_fixture();
let mut bytes = env.to_bytes().to_vec();
bytes.push(0xFF);
assert!(IdentityEnvelope::from_bytes(&bytes).is_none());
}
#[test]
fn seal_open_roundtrip() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(7);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
let opened = env.open(&target_sk, &link, None).expect("open");
assert_eq!(opened.entity_id(), source.entity_id());
assert_eq!(opened.origin_hash(), source.origin_hash());
let sig = opened.sign(b"post-open");
assert!(source.entity_id().verify(b"post-open", &sig).is_ok());
}
#[test]
fn seal_open_rejects_tampered_signature() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(1);
let mut env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
env.signature[0] ^= 0xFF;
assert_eq!(
env.open(&target_sk, &link, None).expect_err("must reject"),
EnvelopeError::InvalidAttestation,
);
}
#[test]
fn seal_open_rejects_tampered_ciphertext() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(1);
let mut env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
env.sealed_seed[40] ^= 0xFF;
assert_eq!(
env.open(&target_sk, &link, None).expect_err("must reject"),
EnvelopeError::SealOpenFailed,
);
}
#[test]
fn seal_open_rejects_wrong_target_key() {
let source = EntityKeypair::generate();
let (_, target_pk) = fresh_x25519();
let (different_sk, _) = fresh_x25519();
let link = chain_link_at(1);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
assert_eq!(
env.open(&different_sk, &link, None)
.expect_err("must reject"),
EnvelopeError::SealOpenFailed,
);
}
#[test]
fn seal_open_rejects_replay_at_different_chain_link() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(7);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
let later_link = chain_link_at(8);
assert_eq!(
env.open(&target_sk, &later_link, None)
.expect_err("replay at later link must reject"),
EnvelopeError::InvalidAttestation,
);
}
#[test]
fn seal_open_with_expected_signer_pub_rejects_substituted_envelope() {
let attacker = EntityKeypair::generate();
let expected = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(1);
let env = IdentityEnvelope::new(&attacker, target_pk, &link).expect("seal");
let err = env
.open(&target_sk, &link, Some(expected.entity_id().as_bytes()))
.expect_err("substituted envelope must be rejected with expected_signer_pub");
assert_eq!(err, EnvelopeError::InvalidSignerKey);
}
#[test]
fn seal_open_with_none_expected_preserves_legacy_behavior() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(1);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
let opened = env
.open(&target_sk, &link, None)
.expect("legacy None path must still succeed");
assert_eq!(opened.entity_id(), source.entity_id());
}
#[test]
fn seal_open_with_matching_expected_signer_pub_succeeds() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(1);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
let opened = env
.open(&target_sk, &link, Some(source.entity_id().as_bytes()))
.expect("matching expected_signer_pub must succeed");
assert_eq!(opened.entity_id(), source.entity_id());
}
#[test]
fn seal_open_rejects_retargeted_envelope() {
let source = EntityKeypair::generate();
let (_target_sk_a, target_pk_a) = fresh_x25519();
let (target_sk_b, target_pk_b) = fresh_x25519();
let link = chain_link_at(1);
let mut env = IdentityEnvelope::new(&source, target_pk_a, &link).expect("seal");
env.target_static_pub = target_pk_b;
assert_eq!(
env.open(&target_sk_b, &link, None)
.expect_err("must reject"),
EnvelopeError::InvalidAttestation,
);
}
#[test]
fn new_refuses_public_only_source() {
let source = EntityKeypair::public_only(EntityKeypair::generate().entity_id().clone());
let (_, target_pk) = fresh_x25519();
let link = chain_link_at(1);
let err = IdentityEnvelope::new(&source, target_pk, &link).expect_err("must refuse");
assert_eq!(err, EnvelopeError::SourceReadOnly);
}
#[test]
fn open_rejects_pre_wire_bump_v0_envelope() {
let env = raw_fixture();
let v1_bytes = env.to_bytes();
assert!(IdentityEnvelope::from_bytes(&v1_bytes[1..]).is_none());
let mut v0_shape = v1_bytes;
v0_shape[0] = 0;
assert!(IdentityEnvelope::from_bytes(&v0_shape).is_none());
}
#[test]
fn open_accepts_v1_envelope_on_first_try() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(7);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal v1");
let opened = env
.open(&target_sk, &link, None)
.expect("v1 envelope must open without fallback");
assert_eq!(opened.entity_id(), source.entity_id());
}
#[test]
fn open_rejects_tampered_chain_link_under_v0_fallback() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(7);
let tampered_link = chain_link_at(8);
let env = IdentityEnvelope::new(&source, target_pk, &link).expect("seal");
let err = env
.open(&target_sk, &tampered_link, None)
.expect_err("tampered chain_link must reject regardless of AEAD path");
assert_eq!(err, EnvelopeError::InvalidAttestation);
}
#[test]
fn opened_keypair_matches_signer_pub() {
let source = EntityKeypair::generate();
let (target_sk, target_pk) = fresh_x25519();
let link = chain_link_at(42);
let env = IdentityEnvelope::new(&source, target_pk, &link).unwrap();
let opened = env.open(&target_sk, &link, None).unwrap();
assert_eq!(opened.entity_id().as_bytes(), &env.signer_pub);
}
}