use alloc::vec::Vec;
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
use pqc_sig::{MlDsa65Keypair, SigPublicKey, Signature};
use rand_core::{CryptoRng, RngCore};
use sha3::digest::{ExtendableOutput, Update, XofReader};
use sha3::Shake256;
use zeroize::Zeroize;
use crate::identity_error::IdentityError;
pub const MIN_ENTROPY_BYTES: usize = 32;
pub mod purpose {
macro_rules! registry {
($( $(#[$meta:meta])* $name:ident = $bytes:literal; )+) => {
$( $(#[$meta])* pub const $name: &[u8] = $bytes; )+
pub const ALL: &[(&str, &[u8])] = &[ $( (stringify!($name), $name), )+ ];
};
}
registry! {
PLP_PRESENT_V1 = b"aethel-core/plp-present/v1";
CREDENTIAL_V1 = b"aethel-core/credential/v1";
SAAP_V1 = b"aethel-core/saap/v1";
VAULT_SPEND_INTENT_V1 = b"aethel-vault/spend-intent/v1";
VAULT_SETTLEMENT_RECEIPT_V1 = b"aethel-vault/settlement-receipt/v1";
VAULT_WALLET_BIND_V1 = b"aethel-vault/wallet-bind/v1";
VAULT_HITL_APPROVAL_V1 = b"aethel-vault/hitl-approval/v1";
AUTH_LOGIN_V1 = b"aethel-auth/login/v1";
AUTH_STEP_UP_V1 = b"aethel-auth/step-up/v1";
AUTH_ENROL_V1 = b"aethel-auth/enrol/v1";
AUTH_SUCCESSION_V1 = b"aethel-auth/succession/v1";
AUTH_REVOCATION_V1 = b"aethel-auth/revocation/v1";
AUTH_DELEGATION_V1 = b"aethel-auth/delegation/v1";
AGENT_RECEIPT_V1 = b"aethel-core/agent-receipt/v1";
}
#[cfg(test)]
mod tests {
use super::*;
use sha3::digest::{ExtendableOutput, Update, XofReader};
use sha3::Shake256;
const EXPECTED_NAMES: &[&str] = &[
"PLP_PRESENT_V1",
"CREDENTIAL_V1",
"SAAP_V1",
"VAULT_SPEND_INTENT_V1",
"VAULT_SETTLEMENT_RECEIPT_V1",
"VAULT_WALLET_BIND_V1",
"VAULT_HITL_APPROVAL_V1",
"AUTH_LOGIN_V1",
"AUTH_STEP_UP_V1",
"AUTH_ENROL_V1",
"AUTH_SUCCESSION_V1",
"AUTH_REVOCATION_V1",
"AUTH_DELEGATION_V1",
"AGENT_RECEIPT_V1",
];
fn registry_digest() -> [u8; 32] {
let mut hasher = Shake256::default();
for (_, purpose) in ALL {
hasher.update(&(purpose.len() as u32).to_le_bytes());
hasher.update(purpose);
}
let mut xof = hasher.finalize_xof();
let mut digest = [0u8; 32];
xof.read(&mut digest);
digest
}
#[test]
fn all_lists_exactly_the_expected_purposes() {
assert_eq!(ALL.len(), EXPECTED_NAMES.len(), "the registry changed size");
for ((name, _), expected) in ALL.iter().zip(EXPECTED_NAMES) {
assert_eq!(name, expected);
}
}
#[test]
fn all_carries_the_constants_bytes() {
let by_hand: &[(&str, &[u8])] = &[
("PLP_PRESENT_V1", PLP_PRESENT_V1),
("AUTH_LOGIN_V1", AUTH_LOGIN_V1),
("AUTH_DELEGATION_V1", AUTH_DELEGATION_V1),
("AGENT_RECEIPT_V1", AGENT_RECEIPT_V1),
];
for (name, bytes) in by_hand {
let found = ALL.iter().find(|(n, _)| n == name).expect("listed");
assert_eq!(found.1, *bytes, "{name}");
}
}
#[test]
fn the_registry_is_pinned() {
assert_eq!(
registry_digest(),
registry_digest(),
"the registry hash must be deterministic across calls"
);
for (name, purpose) in ALL {
assert!(!purpose.is_empty(), "{name} must not be empty");
}
for (i, (name_a, bytes_a)) in ALL.iter().enumerate() {
for (name_b, bytes_b) in &ALL[i + 1..] {
assert_ne!(
bytes_a, bytes_b,
"{name_a} and {name_b} collide on the same context bytes"
);
}
}
}
#[test]
fn every_purpose_fits_the_context_length_limit() {
for (_, purpose) in ALL {
assert!(purpose.len() <= pqc_sig::MAX_CONTEXT_LEN);
}
}
}
}
const KEYGEN_DOMAIN: &[u8] = b"AETHEL_IDENTITY_KEYGEN_V1";
struct ShakeRng {
reader: sha3::Shake256Reader,
}
impl RngCore for ShakeRng {
fn next_u32(&mut self) -> u32 {
let mut b = [0u8; 4];
self.reader.read(&mut b);
u32::from_le_bytes(b)
}
fn next_u64(&mut self) -> u64 {
let mut b = [0u8; 8];
self.reader.read(&mut b);
u64::from_le_bytes(b)
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
self.reader.read(dest);
}
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
self.reader.read(dest);
Ok(())
}
}
impl CryptoRng for ShakeRng {}
pub struct Identity {
keypair: MlDsa65Keypair,
plp_seed: [u8; 32],
entropy: Vec<u8>,
}
impl Identity {
pub fn generate(entropy: &[u8]) -> Result<Self, IdentityError> {
if entropy.len() < MIN_ENTROPY_BYTES {
return Err(IdentityError::InvalidInputLength);
}
let mut hasher = Shake256::default();
hasher.update(KEYGEN_DOMAIN);
hasher.update(entropy);
let mut reader = hasher.finalize_xof();
let mut plp_seed = [0u8; 32];
reader.read(&mut plp_seed);
let mut rng = ShakeRng { reader };
let keypair =
MlDsa65Keypair::generate(&mut rng).map_err(|_| IdentityError::SerializationError)?;
Ok(Self {
keypair,
plp_seed,
entropy: entropy.to_vec(),
})
}
pub fn public_key(&self) -> Vec<u8> {
self.keypair.public_key().bytes
}
pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, IdentityError> {
self.keypair
.sign_deterministic(message)
.map(|sig| sig.bytes)
.map_err(|_| IdentityError::SerializationError)
}
pub fn sign_with_purpose(
&self,
purpose: &[u8],
message: &[u8],
) -> Result<Vec<u8>, IdentityError> {
if purpose.len() > pqc_sig::MAX_CONTEXT_LEN {
return Err(IdentityError::InvalidInputLength);
}
self.keypair
.sign_ctx_deterministic(purpose, message)
.map(|sig| sig.bytes)
.map_err(|_| IdentityError::SerializationError)
}
pub fn project_at_context(
&self,
tau: &[u8],
randomness: &[u8],
) -> Result<crate::plp::EphemeralProjection, IdentityError> {
if randomness.len() < 32 {
return Err(IdentityError::InvalidInputLength);
}
let identity = crate::plp::MasterIdentity::from_seed(&self.plp_seed);
Ok(identity.project_at_context(tau, randomness))
}
pub fn prove(
&self,
tau: &[u8],
randomness: &[u8],
) -> Result<crate::plp::ZkIdentityProof, IdentityError> {
if randomness.len() < 32 {
return Err(IdentityError::InvalidInputLength);
}
let identity = crate::plp::MasterIdentity::from_seed(&self.plp_seed);
let proj = identity.project_at_context(tau, randomness);
crate::plp::Prover::prove_identity(&identity, &proj, &self.plp_seed)
}
#[cfg(any(feature = "component", test))]
pub(crate) fn plp_seed(&self) -> &[u8; 32] {
&self.plp_seed
}
}
impl Drop for Identity {
fn drop(&mut self) {
self.plp_seed.zeroize();
self.entropy.zeroize();
}
}
pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<bool, IdentityError> {
let pk = SigPublicKey {
algorithm: pqc_sig::SigAlgorithm::MlDsa65,
bytes: public_key.to_vec(),
};
let sig = Signature {
algorithm: pqc_sig::SigAlgorithm::MlDsa65,
bytes: signature.to_vec(),
};
match MlDsa65Keypair::verify(&pk, message, &sig) {
Ok(()) => Ok(true),
Err(_) => Ok(false),
}
}
pub fn verify_with_purpose(
public_key: &[u8],
purpose: &[u8],
message: &[u8],
signature: &[u8],
) -> Result<bool, IdentityError> {
if purpose.len() > pqc_sig::MAX_CONTEXT_LEN {
return Err(IdentityError::InvalidInputLength);
}
let pk = SigPublicKey {
algorithm: pqc_sig::SigAlgorithm::MlDsa65,
bytes: public_key.to_vec(),
};
let sig = Signature {
algorithm: pqc_sig::SigAlgorithm::MlDsa65,
bytes: signature.to_vec(),
};
match MlDsa65Keypair::verify_ctx(&pk, purpose, message, &sig) {
Ok(()) => Ok(true),
Err(_) => Ok(false),
}
}
const SEAL_VERSION: u8 = 2;
const SEAL_KDF_DOMAIN: &[u8] = b"AETHEL_IDENTITY_SEAL_KDF_V1";
const SEAL_TYPE_IDENTITY: &[u8] = b"identity";
fn seal_aad(version: u8, type_tag: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(1 + type_tag.len());
aad.push(version);
aad.extend_from_slice(type_tag);
aad
}
pub const MIN_SEAL_KEY_BYTES: usize = 32;
const SEAL_NONCE_BYTES: usize = 24;
const SEAL_OVERHEAD: usize = 1 + SEAL_NONCE_BYTES;
impl Identity {
pub fn export_sealed(&self, key: &[u8]) -> Result<Vec<u8>, IdentityError> {
if key.len() < MIN_SEAL_KEY_BYTES {
return Err(IdentityError::InvalidInputLength);
}
let (cipher_key, nonce) = derive_seal_material(key, SEAL_TYPE_IDENTITY, &self.entropy);
let cipher = XChaCha20Poly1305::new((&cipher_key).into());
let aad = seal_aad(SEAL_VERSION, SEAL_TYPE_IDENTITY);
let ciphertext = cipher
.encrypt(
(&nonce).into(),
Payload {
msg: self.entropy.as_slice(),
aad: &aad,
},
)
.map_err(|_| IdentityError::SerializationError)?;
let mut out = Vec::with_capacity(SEAL_OVERHEAD + ciphertext.len());
out.push(SEAL_VERSION);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
pub fn import_sealed(sealed: &[u8], key: &[u8]) -> Result<Self, IdentityError> {
if key.len() < MIN_SEAL_KEY_BYTES || sealed.len() <= SEAL_OVERHEAD {
return Err(IdentityError::InvalidInputLength);
}
if sealed[0] != SEAL_VERSION {
return Err(IdentityError::SerializationError);
}
let nonce: [u8; SEAL_NONCE_BYTES] = sealed[1..1 + SEAL_NONCE_BYTES]
.try_into()
.map_err(|_| IdentityError::InvalidInputLength)?;
let ciphertext = &sealed[SEAL_OVERHEAD..];
let cipher_key = derive_seal_key(key);
let cipher = XChaCha20Poly1305::new((&cipher_key).into());
let aad = seal_aad(SEAL_VERSION, SEAL_TYPE_IDENTITY);
let mut entropy = cipher
.decrypt(
(&nonce).into(),
Payload {
msg: ciphertext,
aad: &aad,
},
)
.map_err(|_| IdentityError::SerializationError)?;
let identity = Identity::generate(&entropy);
entropy.zeroize();
identity
}
}
fn derive_seal_key(key: &[u8]) -> [u8; 32] {
let mut hasher = Shake256::default();
hasher.update(SEAL_KDF_DOMAIN);
hasher.update(b"key");
hasher.update(&(key.len() as u32).to_le_bytes());
hasher.update(key);
let mut reader = hasher.finalize_xof();
let mut out = [0u8; 32];
reader.read(&mut out);
out
}
fn derive_seal_material(
key: &[u8],
type_tag: &[u8],
entropy: &[u8],
) -> ([u8; 32], [u8; SEAL_NONCE_BYTES]) {
let cipher_key = derive_seal_key(key);
let mut hasher = Shake256::default();
hasher.update(SEAL_KDF_DOMAIN);
hasher.update(b"nonce");
hasher.update(&(type_tag.len() as u32).to_le_bytes());
hasher.update(type_tag);
hasher.update(&(key.len() as u32).to_le_bytes());
hasher.update(key);
hasher.update(&(entropy.len() as u32).to_le_bytes());
hasher.update(entropy);
let mut reader = hasher.finalize_xof();
let mut nonce = [0u8; SEAL_NONCE_BYTES];
reader.read(&mut nonce);
(cipher_key, nonce)
}
#[cfg(test)]
mod tests {
use super::*;
const ENTROPY: &[u8; 32] = b"deterministic entropy for tests!";
#[test]
fn generation_is_deterministic() {
let a = Identity::generate(ENTROPY).expect("generate");
let b = Identity::generate(ENTROPY).expect("generate");
assert_eq!(a.public_key(), b.public_key());
assert_eq!(a.plp_seed(), b.plp_seed());
}
#[test]
fn different_entropy_yields_a_different_identity() {
let a = Identity::generate(ENTROPY).expect("generate");
let b = Identity::generate(b"a completely different entropy!!").expect("generate");
assert_ne!(
a.public_key(),
b.public_key(),
"two different entropy inputs produced the same signing key"
);
assert_ne!(
a.plp_seed(),
b.plp_seed(),
"two different entropy inputs produced the same PLP seed"
);
}
#[test]
fn the_plp_seed_is_not_the_signing_key() {
let id = Identity::generate(ENTROPY).expect("generate");
let pk = id.public_key();
assert!(
!pk.windows(32).any(|w| w == id.plp_seed()),
"the PLP seed appears verbatim inside the public key"
);
}
#[test]
fn short_entropy_is_refused() {
for len in [0usize, 1, 31] {
let entropy = alloc::vec![0xABu8; len];
assert!(
matches!(
Identity::generate(&entropy),
Err(IdentityError::InvalidInputLength)
),
"{len} bytes of entropy was accepted"
);
}
assert!(
Identity::generate(&[0xABu8; 32]).is_ok(),
"32 bytes was refused"
);
}
#[test]
fn sign_and_verify_round_trip() {
let id = Identity::generate(ENTROPY).expect("generate");
let msg = b"the message that was actually signed";
let sig = id.sign(msg).expect("sign");
assert_eq!(verify(&id.public_key(), msg, &sig), Ok(true));
}
#[test]
fn a_tampered_message_does_not_verify() {
let id = Identity::generate(ENTROPY).expect("generate");
let sig = id.sign(b"transfer 10 to alice").expect("sign");
assert_eq!(
verify(&id.public_key(), b"transfer 99 to alice", &sig),
Ok(false),
"a signature verified against a message it was not made over"
);
}
#[test]
fn a_wrong_key_does_not_verify() {
let signer = Identity::generate(ENTROPY).expect("generate");
let other = Identity::generate(b"a completely different entropy!!").expect("generate");
let msg = b"signed by exactly one of these";
let sig = signer.sign(msg).expect("sign");
assert_eq!(
verify(&other.public_key(), msg, &sig),
Ok(false),
"a signature verified under a key that did not produce it"
);
}
#[test]
fn a_tampered_signature_does_not_verify() {
let id = Identity::generate(ENTROPY).expect("generate");
let msg = b"message";
let mut sig = id.sign(msg).expect("sign");
sig[0] ^= 0x01;
assert_eq!(verify(&id.public_key(), msg, &sig), Ok(false));
}
#[test]
fn signing_is_deterministic_across_calls() {
let id = Identity::generate(ENTROPY).expect("generate");
let msg = b"same message, twice";
assert_eq!(id.sign(msg).unwrap(), id.sign(msg).unwrap());
}
#[test]
fn identity_projection_matches_component_path() {
let id = Identity::generate(ENTROPY).expect("generate");
let tau = b"bridge-parity-context";
let randomness = [0x5au8; 32];
let via_identity = id.project_at_context(tau, &randomness).expect("project");
let via_component_path = crate::plp::MasterIdentity::from_seed(id.plp_seed())
.project_at_context(tau, &randomness);
assert_eq!(via_identity.tau, via_component_path.tau);
assert_eq!(via_identity.salt, via_component_path.salt);
let flat = |v: &crate::plp::PolyVec| {
v.iter()
.flat_map(|p| p.coeffs().to_vec())
.collect::<alloc::vec::Vec<u32>>()
};
assert_eq!(
flat(&via_identity.public_b),
flat(&via_component_path.public_b)
);
}
#[test]
fn identity_prove_verifies_against_identity_project_at_context() {
let id = Identity::generate(ENTROPY).expect("generate");
let tau = b"bridge-prove-context";
let randomness = [0x7bu8; 32];
let proj = id.project_at_context(tau, &randomness).expect("project");
let proof = id.prove(tau, &randomness).expect("prove");
assert!(
crate::plp::Verifier::verify(&proj, &proof),
"the bridge's own proof did not verify"
);
}
#[test]
fn bridge_methods_reject_short_randomness() {
let id = Identity::generate(ENTROPY).expect("generate");
let short = [0u8; 31];
assert_eq!(
id.project_at_context(b"ctx", &short).err(),
Some(IdentityError::InvalidInputLength)
);
assert_eq!(
id.prove(b"ctx", &short).err(),
Some(IdentityError::InvalidInputLength)
);
}
#[test]
fn purpose_separated_signature_does_not_verify_under_another_purpose() {
let id = Identity::generate(ENTROPY).expect("generate");
let msg = b"attach challenge bytes";
let sig = id
.sign_with_purpose(purpose::PLP_PRESENT_V1, msg)
.expect("sign");
assert_eq!(
verify_with_purpose(&id.public_key(), purpose::PLP_PRESENT_V1, msg, &sig),
Ok(true),
"control: the signature must verify under its own purpose"
);
assert_eq!(
verify_with_purpose(&id.public_key(), purpose::CREDENTIAL_V1, msg, &sig),
Ok(false),
"a signature verified under a purpose it was not made for"
);
assert_eq!(
verify(&id.public_key(), msg, &sig),
Ok(false),
"a purpose-separated signature verified under plain (no-context) verify"
);
}
#[test]
fn empty_purpose_is_interchangeable_with_plain_sign() {
let id = Identity::generate(ENTROPY).expect("generate");
let msg = b"empty context message";
let sig = id.sign_with_purpose(b"", msg).expect("sign");
assert_eq!(sig, id.sign(msg).expect("sign"));
assert_eq!(verify(&id.public_key(), msg, &sig), Ok(true));
}
#[test]
fn oversized_purpose_is_refused() {
let id = Identity::generate(ENTROPY).expect("generate");
let long_purpose = alloc::vec![0u8; pqc_sig::MAX_CONTEXT_LEN + 1];
assert_eq!(
id.sign_with_purpose(&long_purpose, b"msg").err(),
Some(IdentityError::InvalidInputLength)
);
assert_eq!(
verify_with_purpose(&id.public_key(), &long_purpose, b"msg", &[0u8; 10]).err(),
Some(IdentityError::InvalidInputLength)
);
}
}
#[cfg(test)]
mod seal_tests {
use super::*;
use alloc::vec;
const ENTROPY: &[u8; 32] = b"deterministic entropy for tests!";
const KEY: &[u8; 32] = b"a sealing key of thirty-two byte";
const OTHER_KEY: &[u8; 32] = b"a different sealing key, 32 byte";
#[test]
fn a_sealed_identity_round_trips() {
let original = Identity::generate(ENTROPY).expect("generate");
let sealed = original.export_sealed(KEY).expect("seal");
let reopened = Identity::import_sealed(&sealed, KEY).expect("open");
assert_eq!(
original.public_key(),
reopened.public_key(),
"the reopened identity has a different public key"
);
assert_eq!(
original.plp_seed(),
reopened.plp_seed(),
"the reopened identity has a different PLP seed"
);
}
#[test]
fn a_reopened_identity_can_still_sign() {
let original = Identity::generate(ENTROPY).expect("generate");
let sealed = original.export_sealed(KEY).expect("seal");
let reopened = Identity::import_sealed(&sealed, KEY).expect("open");
let message = b"signed after being reopened";
let signature = reopened.sign(message).expect("sign");
assert_eq!(
verify(&original.public_key(), message, &signature),
Ok(true),
"a signature from the reopened identity did not verify under the original key"
);
}
#[test]
fn sealing_two_identities_yields_two_different_identities() {
let a = Identity::generate(ENTROPY).expect("generate");
let b = Identity::generate(b"a completely different entropy!!").expect("generate");
let sealed_a = a.export_sealed(KEY).expect("seal");
let sealed_b = b.export_sealed(KEY).expect("seal");
assert_ne!(
sealed_a, sealed_b,
"two identities sealed to the same bytes"
);
let reopened_a = Identity::import_sealed(&sealed_a, KEY).expect("open");
let reopened_b = Identity::import_sealed(&sealed_b, KEY).expect("open");
assert_ne!(
reopened_a.public_key(),
reopened_b.public_key(),
"two different sealed identities reopened as the same identity"
);
assert_eq!(reopened_a.public_key(), a.public_key());
assert_eq!(reopened_b.public_key(), b.public_key());
}
#[test]
fn the_wrong_key_does_not_open_it() {
let identity = Identity::generate(ENTROPY).expect("generate");
let sealed = identity.export_sealed(KEY).expect("seal");
assert!(
Identity::import_sealed(&sealed, OTHER_KEY).is_err(),
"a sealed identity opened under the wrong key"
);
}
#[test]
fn any_tampered_byte_is_rejected() {
let identity = Identity::generate(ENTROPY).expect("generate");
let sealed = identity.export_sealed(KEY).expect("seal");
for index in 0..sealed.len() {
let mut tampered = sealed.clone();
tampered[index] ^= 0x01;
assert!(
Identity::import_sealed(&tampered, KEY).is_err(),
"a blob with byte {index} flipped still opened"
);
}
}
#[test]
fn a_truncated_blob_is_rejected() {
let identity = Identity::generate(ENTROPY).expect("generate");
let sealed = identity.export_sealed(KEY).expect("seal");
for cut in [0usize, 1, SEAL_OVERHEAD, sealed.len() - 1] {
assert!(
Identity::import_sealed(&sealed[..cut], KEY).is_err(),
"a blob truncated to {cut} bytes still opened"
);
}
}
#[test]
fn an_unknown_version_is_refused() {
let identity = Identity::generate(ENTROPY).expect("generate");
let mut sealed = identity.export_sealed(KEY).expect("seal");
sealed[0] = 0xFF;
assert!(
matches!(
Identity::import_sealed(&sealed, KEY),
Err(IdentityError::SerializationError)
),
"a blob with an unknown format version was parsed anyway"
);
}
#[test]
fn a_short_key_is_refused_on_both_sides() {
let identity = Identity::generate(ENTROPY).expect("generate");
let short = b"too short";
assert!(matches!(
identity.export_sealed(short),
Err(IdentityError::InvalidInputLength)
));
let sealed = identity.export_sealed(KEY).expect("seal");
assert!(matches!(
Identity::import_sealed(&sealed, short),
Err(IdentityError::InvalidInputLength)
));
}
#[test]
fn the_blob_does_not_contain_the_secret_in_the_clear() {
let identity = Identity::generate(ENTROPY).expect("generate");
let sealed = identity.export_sealed(KEY).expect("seal");
assert!(
!sealed
.windows(ENTROPY.len())
.any(|w| w == ENTROPY.as_slice()),
"the entropy appears verbatim in the sealed blob"
);
assert!(
!sealed
.windows(32)
.any(|w| w == identity.plp_seed().as_slice()),
"the PLP seed appears verbatim in the sealed blob"
);
}
fn seal_as_other_type(key: &[u8], plaintext: &[u8]) -> Vec<u8> {
const OTHER: &[u8] = b"rotation-record";
let (cipher_key, nonce) = derive_seal_material(key, OTHER, plaintext);
let cipher = XChaCha20Poly1305::new((&cipher_key).into());
let aad = seal_aad(SEAL_VERSION, OTHER);
let ciphertext = cipher
.encrypt(
(&nonce).into(),
Payload {
msg: plaintext,
aad: &aad,
},
)
.expect("seal");
let mut out = vec![SEAL_VERSION];
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
out
}
#[test]
fn a_blob_of_another_type_does_not_open_as_an_identity() {
let blob = seal_as_other_type(KEY, ENTROPY);
assert!(
matches!(
Identity::import_sealed(&blob, KEY),
Err(IdentityError::SerializationError)
),
"a blob sealed as a different object type opened as an identity"
);
}
#[test]
fn the_type_tag_check_is_what_rejects_the_other_type() {
let (cipher_key, nonce) = derive_seal_material(KEY, SEAL_TYPE_IDENTITY, ENTROPY);
let cipher = XChaCha20Poly1305::new((&cipher_key).into());
let aad = seal_aad(SEAL_VERSION, SEAL_TYPE_IDENTITY);
let ciphertext = cipher
.encrypt(
(&nonce).into(),
Payload {
msg: ENTROPY,
aad: &aad,
},
)
.expect("seal");
let mut blob = vec![SEAL_VERSION];
blob.extend_from_slice(&nonce);
blob.extend_from_slice(&ciphertext);
assert!(
Identity::import_sealed(&blob, KEY).is_ok(),
"the same construction with the identity tag failed to open, so the rejection of the other type is not attributable to the tag"
);
}
#[test]
fn the_type_tag_separates_nonces_for_identical_plaintext() {
let (_, identity_nonce) = derive_seal_material(KEY, SEAL_TYPE_IDENTITY, ENTROPY);
let (_, other_nonce) = derive_seal_material(KEY, b"rotation-record", ENTROPY);
assert_ne!(
identity_nonce, other_nonce,
"the same plaintext under the same key produced one nonce for two types"
);
}
#[test]
fn the_leak_check_catches_an_unsealed_blob() {
let mut unsealed = vec![SEAL_VERSION];
unsealed.extend_from_slice(&[0u8; SEAL_NONCE_BYTES]);
unsealed.extend_from_slice(ENTROPY);
assert!(
unsealed
.windows(ENTROPY.len())
.any(|w| w == ENTROPY.as_slice()),
"the leak check cannot see the entropy even when it is stored in the clear"
);
}
#[test]
fn sealing_is_deterministic() {
let identity = Identity::generate(ENTROPY).expect("generate");
assert_eq!(
identity.export_sealed(KEY).expect("seal"),
identity.export_sealed(KEY).expect("seal")
);
}
#[test]
fn different_keys_give_different_nonces() {
let identity = Identity::generate(ENTROPY).expect("generate");
let a = identity.export_sealed(KEY).expect("seal");
let b = identity.export_sealed(OTHER_KEY).expect("seal");
let nonce_a = &a[1..1 + SEAL_NONCE_BYTES];
let nonce_b = &b[1..1 + SEAL_NONCE_BYTES];
assert_ne!(nonce_a, nonce_b, "two keys produced the same nonce");
}
}
#[cfg(test)]
mod byte_identity_tests {
use super::*;
use alloc::string::String;
use core::fmt::Write;
use sha3::{Digest, Sha3_256};
const ENTROPY: &[u8; 32] = b"aethel-core byte-identity guard!";
const TAU: &[u8] = b"aethel-core/byte-identity/tau";
const RANDOMNESS: [u8; 32] = [0x5A; 32];
const MESSAGE: &[u8] = b"aethel-core byte-identity message";
const PUBLIC_KEY: (usize, &str) =
(1952, "1429617ae4d2f10984004fe0d6ffd791bc79f2238cdca9b60adcbbdae52ead7b");
const PROJECTION: (usize, &str) =
(4160, "afe118b195e29e3c248c6de4f381770598d78f747700d490826510a0ad61932f");
const PROOF: (usize, &str) =
(9216, "096eb35fd3f289e393f230b4d3eb906b143ef3e3c7e135a47c0e0d4a20b92393");
const SIGNATURE: (usize, &str) =
(3309, "e309b2c430f59eb7d6a60ea7b621c59e2aa1997e8d10b9aceb40298fafc5225d");
const PURPOSE_SIGNATURE: (usize, &str) =
(3309, "393e67327c79532e9ff29df25d6c4c061ada0416758333fee42f33a7df0fb0d9");
fn sha3_hex(bytes: &[u8]) -> String {
let mut out = String::new();
for byte in Sha3_256::digest(bytes) {
write!(out, "{byte:02x}").expect("writing to a String cannot fail");
}
out
}
fn assert_pinned(what: &str, bytes: &[u8], expected: (usize, &str)) {
assert_eq!(bytes.len(), expected.0, "{what}: length changed since 0.7.4");
assert_eq!(sha3_hex(bytes), expected.1, "{what}: bytes changed since 0.7.4");
}
fn assert_outputs_match_0_7_4() {
let id = Identity::generate(ENTROPY).expect("generate");
let public_key = id.public_key();
assert_pinned("public key", &public_key, PUBLIC_KEY);
let projection = id.project_at_context(TAU, &RANDOMNESS).expect("project");
let proof = id.prove(TAU, &RANDOMNESS).expect("prove");
assert_pinned("PLP projection", &projection.to_bytes(), PROJECTION);
assert_pinned("PLP proof", &proof.to_bytes(), PROOF);
assert!(
crate::plp::Verifier::verify(&projection, &proof),
"the pinned proof must still verify"
);
let signature = id.sign(MESSAGE).expect("sign");
assert_pinned("signature", &signature, SIGNATURE);
assert_eq!(verify(&public_key, MESSAGE, &signature), Ok(true));
let purpose_signature = id
.sign_with_purpose(purpose::PLP_PRESENT_V1, MESSAGE)
.expect("sign");
assert_pinned("purpose signature", &purpose_signature, PURPOSE_SIGNATURE);
assert_eq!(
verify_with_purpose(&public_key, purpose::PLP_PRESENT_V1, MESSAGE, &purpose_signature),
Ok(true)
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn outputs_are_byte_identical_to_0_7_4() {
assert_outputs_match_0_7_4();
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn outputs_are_byte_identical_to_0_7_4_wasm() {
assert_outputs_match_0_7_4();
}
#[test]
fn a_changed_byte_is_detected() {
let mut public_key = Identity::generate(ENTROPY).expect("generate").public_key();
public_key[0] ^= 1;
assert_eq!(public_key.len(), PUBLIC_KEY.0);
assert_ne!(sha3_hex(&public_key), PUBLIC_KEY.1);
}
}