extern crate alloc;
use alloc::vec::Vec;
use sha3::{
digest::{ExtendableOutput, Update, XofReader},
Shake256,
};
use crate::identity_error::IdentityError;
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const N: usize = 256;
pub const Q: u32 = 8_380_417;
pub const ETA: i32 = 2;
pub const GAMMA1: i32 = 131_072;
pub const BETA: i32 = 78;
pub const PROVE_ITERATION_CEILING: u8 = 48;
pub const CHALLENGE_WEIGHT: usize = 39;
const _: () = assert!(
BETA as usize >= CHALLENGE_WEIGHT * ETA as usize,
"BETA must bound the infinity norm of c*w: a challenge with CHALLENGE_WEIGHT nonzero ternary coefficients against a witness bounded by ETA reaches CHALLENGE_WEIGHT * ETA, and rejection sampling is only secret-independent when BETA is at least that."
);
pub const REJECTION_THRESHOLD: i32 = GAMMA1 - BETA;
pub const MODULE_K: usize = 4;
pub type PolyVec = [Poly; MODULE_K];
pub type PolyMat = [[Poly; MODULE_K]; MODULE_K];
fn mat_vec_mul(a: &PolyMat, v: &PolyVec) -> PolyVec {
let mut out = [Poly::zero(); MODULE_K];
for (slot, row) in out.iter_mut().zip(a.iter()) {
let mut acc = Poly::zero();
for (cell, vj) in row.iter().zip(v.iter()) {
acc = acc.add(&cell.mul_schoolbook(vj));
}
*slot = acc;
}
out
}
fn scalar_vec_mul(c: &Poly, v: &PolyVec) -> PolyVec {
let mut out = [Poly::zero(); MODULE_K];
for (slot, vi) in out.iter_mut().zip(v.iter()) {
*slot = c.mul_schoolbook(vi);
}
out
}
fn vec_add(a: &PolyVec, b: &PolyVec) -> PolyVec {
let mut out = [Poly::zero(); MODULE_K];
for (slot, (ai, bi)) in out.iter_mut().zip(a.iter().zip(b.iter())) {
*slot = ai.add(bi);
}
out
}
fn vec_sub(a: &PolyVec, b: &PolyVec) -> PolyVec {
let mut out = [Poly::zero(); MODULE_K];
for (slot, (ai, bi)) in out.iter_mut().zip(a.iter().zip(b.iter())) {
*slot = ai.sub(bi);
}
out
}
pub fn vec_infinity_norm(v: &PolyVec) -> i64 {
v.iter().map(|p| p.infinity_norm()).max().unwrap_or(0)
}
fn vec_zeroize(v: &mut PolyVec) {
for p in v.iter_mut() {
p.zeroize();
}
}
#[derive(Clone, Copy, PartialEq, Zeroize)]
pub struct Poly {
pub(crate) coeffs: [u32; N],
}
impl Poly {
pub const fn zero() -> Self {
Self { coeffs: [0u32; N] }
}
pub fn coeffs(&self) -> &[u32; N] {
&self.coeffs
}
#[inline]
pub fn add(&self, other: &Self) -> Self {
let mut res = Self::zero();
for i in 0..N {
res.coeffs[i] = add_mod(self.coeffs[i], other.coeffs[i]);
}
res
}
#[inline]
pub fn sub(&self, other: &Self) -> Self {
let mut res = Self::zero();
for i in 0..N {
res.coeffs[i] = sub_mod(self.coeffs[i], other.coeffs[i]);
}
res
}
pub fn mul_schoolbook(&self, other: &Self) -> Self {
let mut tmp = [0i64; 2 * N];
for i in 0..N {
for j in 0..N {
tmp[i + j] += (self.coeffs[i] as i64) * (other.coeffs[j] as i64);
}
}
let mut res = Self::zero();
for i in 0..N {
let v = (tmp[i] - tmp[i + N]).rem_euclid(Q as i64);
res.coeffs[i] = v as u32;
}
res
}
pub fn infinity_norm(&self) -> i64 {
let mut max_val = 0i64;
for &c in self.coeffs.iter() {
let centered = if c > Q / 2 {
Q as i64 - c as i64
} else {
c as i64
};
if centered > max_val {
max_val = centered;
}
}
max_val
}
pub fn center(&self) -> [i32; N] {
let mut out = [0i32; N];
for i in 0..N {
let c = self.coeffs[i];
out[i] = if c > Q / 2 {
c as i32 - Q as i32
} else {
c as i32
};
}
out
}
}
#[inline(always)]
fn add_mod(a: u32, b: u32) -> u32 {
let s = a + b;
if s >= Q {
s - Q
} else {
s
}
}
#[inline(always)]
fn sub_mod(a: u32, b: u32) -> u32 {
if a >= b {
a - b
} else {
a + Q - b
}
}
#[inline(always)]
fn mul_mod(a: u32, b: u32) -> u32 {
((a as u64 * b as u64) % Q as u64) as u32
}
fn pow_mod(mut base: u64, mut exp: u64, modulus: u64) -> u64 {
let mut result = 1u64;
base %= modulus;
while exp > 0 {
if exp & 1 == 1 {
result = result * base % modulus;
}
exp >>= 1;
base = base * base % modulus;
}
result
}
fn bit_reverse(mut x: usize, bits: usize) -> usize {
let mut result = 0usize;
for _ in 0..bits {
result = (result << 1) | (x & 1);
x >>= 1;
}
result
}
const PSI: u64 = 1753;
fn cyclic_ntt(a: &mut [u64; N], root: u64, q: u64) {
for i in 0..N {
let j = bit_reverse(i, 8);
if i < j {
a.swap(i, j);
}
}
let mut len = 1usize;
while len < N {
let w_len = pow_mod(root, (N / (2 * len)) as u64, q);
let mut base = 0usize;
while base < N {
let mut w = 1u64;
for j in 0..len {
let u = a[base + j];
let v = a[base + j + len] * w % q;
a[base + j] = (u + v) % q;
a[base + j + len] = (u + q - v) % q;
w = w * w_len % q;
}
base += 2 * len;
}
len *= 2;
}
}
pub fn ntt_forward(poly: &mut Poly) {
let q = Q as u64;
let omega = PSI * PSI % q;
let mut a = [0u64; N];
let mut psi_pow = 1u64;
for i in 0..N {
a[i] = (poly.coeffs[i] as u64) * psi_pow % q;
psi_pow = psi_pow * PSI % q;
}
cyclic_ntt(&mut a, omega, q);
for i in 0..N {
poly.coeffs[i] = a[i] as u32;
}
}
pub fn ntt_inverse(poly: &mut Poly) {
let q = Q as u64;
let omega = PSI * PSI % q;
let omega_inv = pow_mod(omega, q - 2, q);
let psi_inv = pow_mod(PSI, q - 2, q);
let n_inv = pow_mod(N as u64, q - 2, q);
let mut a = [0u64; N];
for i in 0..N {
a[i] = poly.coeffs[i] as u64;
}
cyclic_ntt(&mut a, omega_inv, q);
let mut psi_inv_pow = 1u64;
for i in 0..N {
poly.coeffs[i] = (a[i] * n_inv % q * psi_inv_pow % q) as u32;
psi_inv_pow = psi_inv_pow * psi_inv % q;
}
}
pub fn poly_mul_ntt(a: &Poly, b: &Poly) -> Poly {
let q = Q as u64;
let mut fa = *a;
let mut fb = *b;
ntt_forward(&mut fa);
ntt_forward(&mut fb);
let mut fc = Poly::zero();
for i in 0..N {
fc.coeffs[i] = ((fa.coeffs[i] as u64) * (fb.coeffs[i] as u64) % q) as u32;
}
ntt_inverse(&mut fc);
fc
}
pub(crate) fn derive_error_tau(rho: &[u8], tau: &[u8]) -> PolyVec {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_ERROR_V3");
hasher.update(rho);
hasher.update(tau);
let mut xof = hasher.finalize_xof();
let mut out = [Poly::zero(); MODULE_K];
for slot in out.iter_mut() {
*slot = sample_cbd_eta2_from_xof(&mut xof);
}
out
}
pub fn pad_tau(tau: &[u8]) -> [u8; 32] {
let mut t = [0u8; 32];
let len = tau.len().min(32);
t[..len].copy_from_slice(&tau[..len]);
t
}
pub(crate) fn derive_projection_salt(rho: &[u8], tau: &[u8; 32]) -> [u8; 32] {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_PLP_SALT_V1");
hasher.update(&(rho.len() as u32).to_le_bytes());
hasher.update(rho);
hasher.update(tau);
let mut xof = hasher.finalize_xof();
let mut salt = [0u8; 32];
xof.read(&mut salt);
salt
}
pub fn derive_context_matrix(tau: &[u8; 32], salt: &[u8; 32]) -> PolyMat {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_PLP_CTX_V3");
hasher.update(tau);
hasher.update(salt);
let mut xof = hasher.finalize_xof();
let mut mat = [[Poly::zero(); MODULE_K]; MODULE_K];
for row in mat.iter_mut() {
for cell in row.iter_mut() {
let mut coeff_idx = 0usize;
while coeff_idx < N {
let mut buf = [0u8; 3];
xof.read(&mut buf);
let val = (buf[0] as u32) | ((buf[1] as u32) << 8) | ((buf[2] as u32 & 0x7F) << 16);
if val < Q {
cell.coeffs[coeff_idx] = val;
coeff_idx += 1;
}
}
}
}
mat
}
fn sample_cbd_eta2_from_xof(xof: &mut impl XofReader) -> Poly {
let mut poly = Poly::zero();
let mut buf = [0u8; N]; xof.read(&mut buf);
for i in 0..N {
let byte = buf[i];
let a0 = (byte & 0x01) as i32;
let a1 = ((byte >> 1) & 0x01) as i32;
let b0 = ((byte >> 2) & 0x01) as i32;
let b1 = ((byte >> 3) & 0x01) as i32;
let coeff = (a0 + a1) - (b0 + b1); poly.coeffs[i] = (coeff.rem_euclid(Q as i32)) as u32;
}
poly
}
fn sample_mask_from_xof(xof: &mut impl XofReader) -> Poly {
let mut poly = Poly::zero();
let range = 2 * GAMMA1 as u32 + 1; let mut coeff_idx = 0usize;
while coeff_idx < N {
let mut buf = [0u8; 3];
xof.read(&mut buf);
let val = (buf[0] as u32) | ((buf[1] as u32) << 8) | ((buf[2] as u32 & 0x7F) << 16);
if val < range {
let centered = val as i32 - GAMMA1;
poly.coeffs[coeff_idx] = centered.rem_euclid(Q as i32) as u32;
coeff_idx += 1;
}
}
poly
}
pub fn hash_to_challenge(w: &PolyVec, public_b: &PolyVec, tau: &[u8; 32], salt: &[u8; 32]) -> Poly {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_PLP_CHALLENGE_V4");
for poly in w.iter() {
for &c in poly.coeffs.iter() {
hasher.update(&c.to_le_bytes());
}
}
for poly in public_b.iter() {
for &c in poly.coeffs.iter() {
hasher.update(&c.to_le_bytes());
}
}
hasher.update(tau);
hasher.update(salt);
let mut xof = hasher.finalize_xof();
let mut c_poly = Poly::zero();
const _: () = assert!(CHALLENGE_WEIGHT <= 64, "the sign buffer holds 64 bits");
let mut signs = [0u8; 8];
xof.read(&mut signs);
let mut sign_bit = 0usize;
let mut count = 0usize;
let mut used = [false; N];
let mut pos_buf = [0u8; 1];
while count < CHALLENGE_WEIGHT {
xof.read(&mut pos_buf);
let pos = pos_buf[0] as usize;
if pos >= N {
continue; }
if used[pos] {
continue; }
used[pos] = true;
let sign_byte = signs[sign_bit / 8];
let sign: i32 = if (sign_byte >> (sign_bit % 8)) & 1 == 0 {
1
} else {
-1
};
sign_bit += 1;
if sign_bit >= 64 {
xof.read(&mut signs);
sign_bit = 0;
}
c_poly.coeffs[pos] = sign.rem_euclid(Q as i32) as u32;
count += 1;
}
c_poly
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct MasterIdentity {
secret_key: PolyVec,
}
impl MasterIdentity {
pub(crate) fn secret(&self) -> &PolyVec {
&self.secret_key
}
pub fn from_seed(seed: &[u8; 32]) -> Self {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_MASTER_KEY_V1");
hasher.update(seed);
let mut xof = hasher.finalize_xof();
let mut secret_key = [Poly::zero(); MODULE_K];
for slot in secret_key.iter_mut() {
*slot = sample_cbd_eta2_from_xof(&mut xof);
}
Self { secret_key }
}
pub fn project_at_context(&self, tau: &[u8], rho: &[u8]) -> EphemeralProjection {
let tau_padded = pad_tau(tau);
let salt = derive_projection_salt(rho, &tau_padded);
let matrix_a = derive_context_matrix(&tau_padded, &salt);
let mut e_tau = derive_error_tau(rho, tau);
let public_b = vec_add(&mat_vec_mul(&matrix_a, &self.secret_key), &e_tau);
vec_zeroize(&mut e_tau);
EphemeralProjection {
tau: tau_padded,
salt,
matrix_a,
public_b,
}
}
}
pub fn checked_project_at_context(
secret: &[u8],
tau: &[u8],
rho: &[u8],
) -> Result<EphemeralProjection, IdentityError> {
if secret.len() != 32 {
return Err(IdentityError::InvalidInputLength);
}
if rho.len() < 32 {
return Err(IdentityError::InvalidInputLength);
}
let mut seed = [0u8; 32];
seed.copy_from_slice(secret);
let identity = MasterIdentity::from_seed(&seed);
Ok(identity.project_at_context(tau, rho))
}
pub const EPHEMERAL_PROJECTION_BYTE_LEN: usize = 32 + 32 + MODULE_K * N * 4;
#[derive(Clone)]
pub struct EphemeralProjection {
pub tau: [u8; 32],
pub salt: [u8; 32],
pub matrix_a: PolyMat,
pub public_b: PolyVec,
}
impl EphemeralProjection {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = alloc::vec![0u8; EPHEMERAL_PROJECTION_BYTE_LEN];
out[..32].copy_from_slice(&self.tau);
out[32..64].copy_from_slice(&self.salt);
for (i, &c) in self
.public_b
.iter()
.flat_map(|p| p.coeffs.iter())
.enumerate()
{
let offset = 64 + i * 4;
out[offset..offset + 4].copy_from_slice(&c.to_le_bytes());
}
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityError> {
if bytes.len() != EPHEMERAL_PROJECTION_BYTE_LEN {
return Err(IdentityError::SerializationError);
}
let mut tau = [0u8; 32];
tau.copy_from_slice(&bytes[..32]);
let mut salt = [0u8; 32];
salt.copy_from_slice(&bytes[32..64]);
let mut public_b = [Poly::zero(); MODULE_K];
for i in 0..MODULE_K * N {
let offset = 64 + i * 4;
let mut b = [0u8; 4];
b.copy_from_slice(&bytes[offset..offset + 4]);
let v = u32::from_le_bytes(b);
if v >= Q {
return Err(IdentityError::CoefficientOutOfRange);
}
public_b[i / N].coeffs[i % N] = v;
}
let matrix_a = derive_context_matrix(&tau, &salt);
Ok(Self {
tau,
salt,
matrix_a,
public_b,
})
}
}
#[derive(Clone)]
pub struct ZkIdentityProof {
pub commitment_w: PolyVec,
pub challenge_c: Poly,
pub response_z: PolyVec,
}
pub const ZK_IDENTITY_PROOF_BYTE_LEN: usize = (MODULE_K * N + N + MODULE_K * N) * 4;
impl ZkIdentityProof {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = alloc::vec![0u8; ZK_IDENTITY_PROOF_BYTE_LEN];
let mut offset = 0usize;
for &c in self.commitment_w.iter().flat_map(|p| p.coeffs.iter()) {
out[offset..offset + 4].copy_from_slice(&c.to_le_bytes());
offset += 4;
}
for &c in self.challenge_c.coeffs.iter() {
out[offset..offset + 4].copy_from_slice(&c.to_le_bytes());
offset += 4;
}
for &c in self.response_z.iter().flat_map(|p| p.coeffs.iter()) {
out[offset..offset + 4].copy_from_slice(&c.to_le_bytes());
offset += 4;
}
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityError> {
if bytes.len() != ZK_IDENTITY_PROOF_BYTE_LEN {
return Err(IdentityError::SerializationError);
}
let read_u32 = |off: usize| -> u32 {
u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
};
let mut offset = 0usize;
let mut commitment_w = [Poly::zero(); MODULE_K];
for poly in commitment_w.iter_mut() {
for c in poly.coeffs.iter_mut() {
let v = read_u32(offset);
if v >= Q {
return Err(IdentityError::CoefficientOutOfRange);
}
*c = v;
offset += 4;
}
}
let mut challenge_c = Poly::zero();
let mut nonzero = 0usize;
for c in challenge_c.coeffs.iter_mut() {
let v = read_u32(offset);
if v >= Q {
return Err(IdentityError::CoefficientOutOfRange);
}
if v != 0 {
if v != 1 && v != Q - 1 {
return Err(IdentityError::SerializationError);
}
nonzero += 1;
}
*c = v;
offset += 4;
}
if nonzero != CHALLENGE_WEIGHT {
return Err(IdentityError::SerializationError);
}
let mut response_z = [Poly::zero(); MODULE_K];
for poly in response_z.iter_mut() {
for c in poly.coeffs.iter_mut() {
let v = read_u32(offset);
if v >= Q {
return Err(IdentityError::CoefficientOutOfRange);
}
*c = v;
offset += 4;
}
}
Ok(Self {
commitment_w,
challenge_c,
response_z,
})
}
}
pub struct Prover;
impl Prover {
pub fn prove_identity(
identity: &MasterIdentity,
proj: &EphemeralProjection,
seed: &[u8; 32],
) -> Result<ZkIdentityProof, IdentityError> {
for iter in 0..PROVE_ITERATION_CEILING {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_MASK_V2");
hasher.update(seed);
hasher.update(&proj.tau);
hasher.update(&[iter]);
let mut xof = hasher.finalize_xof();
let mut y = [Poly::zero(); MODULE_K];
for slot in y.iter_mut() {
*slot = sample_mask_from_xof(&mut xof);
}
let mut w = mat_vec_mul(&proj.matrix_a, &y);
let mut challenge_c = hash_to_challenge(&w, &proj.public_b, &proj.tau, &proj.salt);
let mut cs = scalar_vec_mul(&challenge_c, &identity.secret_key);
let mut z = vec_add(&y, &cs);
vec_zeroize(&mut y);
vec_zeroize(&mut cs);
if vec_infinity_norm(&z) < REJECTION_THRESHOLD as i64 {
return Ok(ZkIdentityProof {
commitment_w: w,
challenge_c,
response_z: z,
});
}
vec_zeroize(&mut w);
challenge_c.zeroize();
vec_zeroize(&mut z);
}
Err(IdentityError::RejectionSamplingFailed)
}
}
pub struct Verifier;
impl Verifier {
pub fn verify(proj: &EphemeralProjection, proof: &ZkIdentityProof) -> bool {
if vec_infinity_norm(&proof.response_z) >= REJECTION_THRESHOLD as i64 {
return false;
}
let recomputed_c =
hash_to_challenge(&proof.commitment_w, &proj.public_b, &proj.tau, &proj.salt);
let mut mismatch = 0u32;
for i in 0..N {
mismatch |= recomputed_c.coeffs[i] ^ proof.challenge_c.coeffs[i];
}
if mismatch != 0 {
return false;
}
let matrix_a = derive_context_matrix(&proj.tau, &proj.salt);
let az = mat_vec_mul(&matrix_a, &proof.response_z);
let cb = scalar_vec_mul(&proof.challenge_c, &proj.public_b);
let w_prime = vec_sub(&az, &cb);
let diff = vec_sub(&w_prime, &proof.commitment_w);
vec_infinity_norm(&diff) < (BETA as i64 * 2)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_seed() -> [u8; 32] {
[0x42u8; 32]
}
fn test_rho() -> [u8; 32] {
[0xa5u8; 32]
}
const ATTACK_Q: i64 = 8_380_417;
fn attack_mod_inv(a: i64) -> i64 {
let mut result = 1i64;
let mut base = a.rem_euclid(ATTACK_Q);
let mut exp = ATTACK_Q - 2;
while exp > 0 {
if exp & 1 == 1 {
result = (result * base) % ATTACK_Q;
}
base = (base * base) % ATTACK_Q;
exp >>= 1;
}
result
}
fn attack_centered(x: i64) -> i64 {
let r = x.rem_euclid(ATTACK_Q);
if r > ATTACK_Q / 2 {
r - ATTACK_Q
} else {
r
}
}
fn attack_sub(a: &Poly, b: &Poly) -> Poly {
let mut out = Poly::zero();
for i in 0..N {
out.coeffs[i] = (a.coeffs[i] as i64 - b.coeffs[i] as i64).rem_euclid(ATTACK_Q) as u32;
}
out
}
fn attack_ring_divide(num: &Poly, den: &Poly) -> Option<Poly> {
let mut n = *num;
let mut d = *den;
ntt_forward(&mut n);
ntt_forward(&mut d);
let mut q = Poly::zero();
for i in 0..N {
let di = d.coeffs[i] as i64 % ATTACK_Q;
if di == 0 {
return None;
}
let ni = n.coeffs[i] as i64 % ATTACK_Q;
q.coeffs[i] = ((ni * attack_mod_inv(di)) % ATTACK_Q) as u32;
}
ntt_inverse(&mut q);
Some(q)
}
#[test]
fn ntt_forward_and_inverse_round_trip() {
let mut original = Poly::zero();
for i in 0..N {
original.coeffs[i] = ((i * 13 + 5) % 4096) as u32;
}
let mut round_tripped = original;
ntt_forward(&mut round_tripped);
ntt_inverse(&mut round_tripped);
let mut mismatches = 0usize;
for i in 0..N {
if original.coeffs[i] != round_tripped.coeffs[i] {
mismatches += 1;
}
}
assert_eq!(
mismatches, 0,
"ntt_inverse(ntt_forward(x)) != x on {}/{} coefficients - the NTT is not self-inverse",
mismatches, N
);
}
#[test]
fn ntt_and_schoolbook_multiplication_agree() {
let mut a = Poly::zero();
let mut b = Poly::zero();
for i in 0..N {
a.coeffs[i] = ((i * 31 + 7) % 1000) as u32;
b.coeffs[i] = ((i * 17 + 3) % 1000) as u32;
}
let school = a.mul_schoolbook(&b);
let ntt = poly_mul_ntt(&a, &b);
let mut mismatches = 0usize;
for i in 0..N {
if school.coeffs[i] != ntt.coeffs[i] {
mismatches += 1;
}
}
assert_eq!(
mismatches, 0,
"mul_schoolbook and poly_mul_ntt disagree on {}/{} coefficients",
mismatches, N
);
}
#[test]
fn the_recovery_machinery_works_when_a_mask_is_genuinely_shared() {
let identity = MasterIdentity::from_seed(&test_seed());
let proj = identity.project_at_context(b"ctx", &test_rho());
let mut y = Poly::zero();
for i in 0..N {
y.coeffs[i] = ((i as u64 * 7919 + 13) % 100_000) as u32;
}
let c1 = hash_to_challenge(&proj.public_b, &proj.public_b, &proj.tau, &[1u8; 32]);
let c2 = hash_to_challenge(&proj.public_b, &proj.public_b, &proj.tau, &[2u8; 32]);
assert_ne!(c1.coeffs, c2.coeffs, "setup: challenges must differ");
let z1 = y.add(&c1.mul_schoolbook(&identity.secret_key[0]));
let z2 = y.add(&c2.mul_schoolbook(&identity.secret_key[0]));
let recovered = attack_ring_divide(&attack_sub(&z1, &z2), &attack_sub(&c1, &c2))
.expect("control: the challenge difference should be invertible");
let mut max_abs = 0i64;
for i in 0..N {
max_abs = max_abs.max(attack_centered(recovered.coeffs[i] as i64).abs());
}
assert!(
max_abs <= 4,
"the recovery machinery FAILED on a synthetic shared-mask case (recovered infinity norm {}). The sweep's result is therefore meaningless - fix this before drawing any conclusion from it.",
max_abs
);
for i in 0..N {
assert_eq!(
attack_centered(recovered.coeffs[i] as i64),
attack_centered(identity.secret_key[0].coeffs[i] as i64),
"recovered coefficient {} does not match the real secret",
i
);
}
}
#[test]
fn two_proofs_at_different_contexts_do_not_leak_the_secret() {
let mut attempts = 0usize;
let mut recoveries = 0usize;
let mut first_hit = None;
for k in 0u8..64 {
let seed = [k.wrapping_mul(7).wrapping_add(1); 32];
let identity = MasterIdentity::from_seed(&seed);
let proj1 = identity.project_at_context(b"context-one", &[0x11u8; 32]);
let proj2 = identity.project_at_context(b"context-two", &[0x22u8; 32]);
let (p1, p2) = match (
Prover::prove_identity(&identity, &proj1, &seed),
Prover::prove_identity(&identity, &proj2, &seed),
) {
(Ok(a), Ok(b)) => (a, b),
_ => continue,
};
if p1.challenge_c.coeffs == p2.challenge_c.coeffs {
continue;
}
attempts += 1;
let z_diff = attack_sub(&p1.response_z[0], &p2.response_z[0]);
let c_diff = attack_sub(&p1.challenge_c, &p2.challenge_c);
let recovered = match attack_ring_divide(&z_diff, &c_diff) {
Some(s) => s,
None => continue,
};
let mut max_abs = 0i64;
for i in 0..N {
max_abs = max_abs.max(attack_centered(recovered.coeffs[i] as i64).abs());
}
if max_abs <= 4 {
recoveries += 1;
if first_hit.is_none() {
first_hit = Some((k, max_abs));
}
}
}
#[cfg(feature = "std")]
std::eprintln!(
"mask-reuse sweep: {}/{} pairs leaked a small-norm secret (first: {:?})",
recoveries,
attempts,
first_hit
);
let _ = &first_hit;
assert_eq!(
recoveries, 0,
"MASTER SECRET RECOVERED from two proofs at different contexts, in {}/{} sampled identities. prove_identity derives its mask from (seed, iter) only - tau never enters - so whenever two contexts accept at the same rejection-sampling iteration they share y, and z1 - z2 = (c1 - c2)*s reveals the secret. Recovered polynomials have infinity norm <= 4, matching CBD(eta=2); a wrong guess would be uniform over a 2^23 field.",
recoveries, attempts
);
}
#[test]
fn every_returned_proof_satisfies_the_norm_bound_and_verifies() {
let mut checked = 0usize;
for k in 0u8..48 {
let seed = [k.wrapping_mul(11).wrapping_add(3); 32];
let identity = MasterIdentity::from_seed(&seed);
for ctx in 0u8..4 {
let tau = [ctx.wrapping_mul(37).wrapping_add(5); 32];
let proj = identity.project_at_context(&tau, &[0x5au8; 32]);
let proof = match Prover::prove_identity(&identity, &proj, &seed) {
Ok(p) => p,
Err(IdentityError::RejectionSamplingFailed) => continue,
Err(e) => panic!("unexpected error from prove_identity: {e:?}"),
};
assert!(
vec_infinity_norm(&proof.response_z) < REJECTION_THRESHOLD as i64,
"prove_identity returned a response outside the norm bound \
(norm {}, bound {}). That is the value rejection sampling \
exists to withhold, and returning it is how the secret leaks.",
vec_infinity_norm(&proof.response_z),
REJECTION_THRESHOLD
);
assert!(
Verifier::verify(&proj, &proof),
"prove_identity returned a proof that does not verify against \
the projection it was produced for"
);
checked += 1;
}
}
assert!(
checked > 0,
"no proof was produced at all, so this test asserted nothing"
);
}
#[test]
fn the_norm_bound_check_detects_a_violating_response() {
let seed = test_seed();
let identity = MasterIdentity::from_seed(&seed);
let proj = identity.project_at_context(b"norm-control", &test_rho());
let mut proof = Prover::prove_identity(&identity, &proj, &seed)
.expect("honest proving must not exhaust rejection sampling");
proof.response_z[0].coeffs[0] = REJECTION_THRESHOLD as u32 + 1;
assert!(
vec_infinity_norm(&proof.response_z) >= REJECTION_THRESHOLD as i64,
"control: the constructed response should violate the norm bound"
);
assert!(
!Verifier::verify(&proj, &proof),
"control: the verifier accepted a response outside the norm bound, \
so the check the test above relies on proves nothing"
);
}
#[test]
fn no_tau_independent_mask_derivation_remains() {
let source = include_str!("plp.rs");
assert!(
!source.contains("update(b\"AETHEL_MASK_V1\")"),
"a mask derivation under AETHEL_MASK_V1 is back in plp.rs. That \
separator's derivation does not bind tau, which is what let two \
proofs at different contexts share a mask and leak the secret via \
z1 - z2 = (c1 - c2)*s. See P3-15 / 0X3-85 before restoring it."
);
}
#[test]
fn test_poly_add_sub() {
let mut a = Poly::zero();
let mut b = Poly::zero();
a.coeffs[0] = 100;
b.coeffs[0] = 200;
let c = a.add(&b);
assert_eq!(c.coeffs[0], 300);
let d = c.sub(&b);
assert_eq!(d.coeffs[0], 100);
}
#[test]
fn test_derive_context_matrix_deterministic() {
let tau = b"test_context_tau";
let salt = [0x5au8; 32];
let tau = &pad_tau(tau);
let a1 = derive_context_matrix(tau, &salt);
let a2 = derive_context_matrix(tau, &salt);
let flat = |m: &PolyMat| {
m.iter()
.flat_map(|row| row.iter().flat_map(|p| p.coeffs.iter().copied()))
.collect::<alloc::vec::Vec<u32>>()
};
assert_eq!(
flat(&a1),
flat(&a2),
"derivation must be deterministic in (tau, salt)"
);
let other = derive_context_matrix(tau, &[0xa5u8; 32]);
assert_ne!(
flat(&a1),
flat(&other),
"a different salt at the same tau produced the same matrix"
);
for &c in flat(&a1).iter() {
assert!(c < Q, "coefficient {} >= Q", c);
}
}
#[test]
fn test_hash_to_challenge_sparse() {
let w = [Poly::zero(); MODULE_K];
let public_b = [Poly::zero(); MODULE_K];
let c = hash_to_challenge(&w, &public_b, &[0x11u8; 32], &[0x22u8; 32]);
let nonzero = c.coeffs.iter().filter(|&&x| x != 0).count();
assert_eq!(
nonzero, CHALLENGE_WEIGHT,
"challenge should have exactly CHALLENGE_WEIGHT non-zero coefficients"
);
}
#[test]
fn a_forged_projection_with_no_secret_key_is_rejected() {
let tau = pad_tau(b"forgery-target-context");
let salt = [0x9au8; 32];
let matrix_a = derive_context_matrix(&tau, &salt);
let mut z = [Poly::zero(); MODULE_K];
for poly in z.iter_mut() {
for i in 0..N {
poly.coeffs[i] = 1;
}
}
assert!(
vec_infinity_norm(&z) < REJECTION_THRESHOLD as i64,
"test setup: z must satisfy the norm bound the same way an honest response would"
);
let w = [Poly::zero(); MODULE_K];
let decoy_b = [Poly::zero(); MODULE_K];
let c = hash_to_challenge(&w, &decoy_b, &tau, &salt);
let az = mat_vec_mul(&matrix_a, &z);
let mut forged_b = [Poly::zero(); MODULE_K];
for i in 0..MODULE_K {
let az_minus_w = attack_sub(&az[i], &w[i]);
forged_b[i] = attack_ring_divide(&az_minus_w, &c)
.expect("c should be invertible in R_q with overwhelming probability");
}
let check_w = vec_sub(&mat_vec_mul(&matrix_a, &z), &scalar_vec_mul(&c, &forged_b));
for i in 0..MODULE_K {
assert_eq!(
check_w[i].coeffs, w[i].coeffs,
"test setup: the solved-for b should make the verification equation exact in component {i}"
);
}
let proj = EphemeralProjection {
tau,
salt,
matrix_a,
public_b: forged_b,
};
let proof = ZkIdentityProof {
commitment_w: w,
challenge_c: c,
response_z: z,
};
assert!(
!Verifier::verify(&proj, &proof),
"a projection/proof pair forged with no secret key, by solving the \
verification equation for b_τ after fixing the challenge, was \
ACCEPTED. The challenge must bind b_τ so it cannot be fixed before \
b_τ is chosen — see hash_to_challenge and 0X3-108."
);
}
#[test]
fn two_projections_differing_only_in_public_b_produce_different_challenges() {
let w = [Poly::zero(); MODULE_K];
let tau = [0x11u8; 32];
let salt = [0x22u8; 32];
let mut b1 = [Poly::zero(); MODULE_K];
b1[0].coeffs[0] = 1;
let mut b2 = [Poly::zero(); MODULE_K];
b2[0].coeffs[0] = 2;
let c1 = hash_to_challenge(&w, &b1, &tau, &salt);
let c2 = hash_to_challenge(&w, &b2, &tau, &salt);
assert_ne!(
c1.coeffs, c2.coeffs,
"hash_to_challenge produced the same challenge for two projections \
differing only in public_b — b_τ is not actually bound"
);
}
fn averaging_attack_recovered_coeffs(samples: &[Poly], target: &Poly) -> usize {
let count = samples.len() as i64;
let mut recovered = 0usize;
for j in 0..N {
let sum: i64 = samples.iter().map(|p| p.coeffs[j] as i64).sum();
let mean = (sum + count / 2) / count;
if mean == target.coeffs[j] as i64 {
recovered += 1;
}
}
recovered
}
#[test]
fn the_averaging_attack_recovers_a_s_under_a_shared_context_matrix() {
let identity = MasterIdentity::from_seed(&test_seed());
let tau = pad_tau(b"block-height-1000");
let fixed_salt = [0u8; 32];
let matrix_a = derive_context_matrix(&tau, &fixed_salt);
let samples: Vec<Poly> = (0u8..64)
.map(|i| {
let rho = [i.wrapping_mul(7).wrapping_add(1); 32];
let e = derive_error_tau(&rho, &tau);
vec_add(&mat_vec_mul(&matrix_a, &identity.secret_key), &e)[0]
})
.collect();
let target = mat_vec_mul(&matrix_a, &identity.secret_key)[0];
let recovered = averaging_attack_recovered_coeffs(&samples, &target);
assert!(
recovered > (N * 9) / 10,
"the averaging attack recovered only {recovered}/{N} coefficients of A*s \
against a SHARED context matrix. It is supposed to succeed here; if it \
does not, the attack model is wrong and the negative test that depends \
on it proves nothing."
);
}
#[test]
fn a_reused_tau_does_not_leak_a_s() {
let identity = MasterIdentity::from_seed(&test_seed());
let tau = b"block-height-1000";
let projections: Vec<EphemeralProjection> = (0u8..64)
.map(|i| {
let rho = [i.wrapping_mul(7).wrapping_add(1); 32];
identity.project_at_context(tau, &rho)
})
.collect();
assert!(
projections.windows(2).all(|w| w[0].tau == w[1].tau),
"test setup: all projections must share tau"
);
assert_ne!(
projections[0].matrix_a[0][0].coeffs, projections[1].matrix_a[0][0].coeffs,
"test setup: two projections at one tau must not share A"
);
let samples: Vec<Poly> = projections.iter().map(|p| p.public_b[0]).collect();
let target = mat_vec_mul(&projections[0].matrix_a, &identity.secret_key)[0];
let recovered = averaging_attack_recovered_coeffs(&samples, &target);
assert!(
recovered < N / 10,
"averaging 64 projections at one tau recovered {recovered}/{N} coefficients \
of A*s. A is leaking across projections again: see AETHEL-F-02 and \
derive_context_matrix before changing the derivation."
);
}
#[test]
fn reusing_rho_at_one_tau_reinstates_the_shared_matrix() {
let identity = MasterIdentity::from_seed(&test_seed());
let rho = [0x5au8; 32];
let a = identity.project_at_context(b"one-tau", &rho);
let b = identity.project_at_context(b"one-tau", &rho);
assert_eq!(
a.matrix_a[0][0].coeffs, b.matrix_a[0][0].coeffs,
"same (tau, rho) is deterministic, so it must reproduce one matrix"
);
assert_eq!(
a.public_b[0].coeffs, b.public_b[0].coeffs,
"and therefore one projection: reusing rho gives the attacker nothing \
new, but it also gives the holder no fresh sample"
);
}
#[test]
fn test_prove_verify_roundtrip() {
let seed = test_seed();
let identity = MasterIdentity::from_seed(&seed);
let tau = b"block_1000_context";
let proj = identity.project_at_context(tau, &test_rho());
let proof = Prover::prove_identity(&identity, &proj, &seed)
.expect("honest proving must not exhaust rejection sampling");
assert!(Verifier::verify(&proj, &proof), "proof should verify");
}
#[test]
fn test_cross_context_unlinkability() {
let seed = test_seed();
let identity = MasterIdentity::from_seed(&seed);
let proj1 = identity.project_at_context(b"context_1", &test_rho());
let proj2 = identity.project_at_context(b"context_2", &test_rho());
assert_ne!(proj1.public_b[0].coeffs, proj2.public_b[0].coeffs);
let proof1 = Prover::prove_identity(&identity, &proj1, &seed)
.expect("honest proving must not exhaust rejection sampling");
assert!(
!Verifier::verify(&proj2, &proof1),
"cross-context replay should fail"
);
}
#[test]
fn test_key_generation() {
let seed = test_seed();
let identity = MasterIdentity::from_seed(&seed);
let all_zero = identity
.secret_key
.iter()
.all(|p| p.coeffs.iter().all(|&c| c == 0));
assert!(
!all_zero,
"Secret key should not be all-zero (negligible probability)"
);
for poly in identity.secret_key.iter() {
for &coeff in poly.coeffs.iter() {
assert!(
coeff < Q,
"Secret key coefficient {} out of range [0, Q-1]",
coeff
);
}
}
}
#[test]
fn test_key_generation_uniqueness() {
let identity_a = MasterIdentity::from_seed(&test_seed());
let identity_b = MasterIdentity::from_seed(&[0xABu8; 32]);
let keys_equal = identity_a
.secret_key
.iter()
.flat_map(|p| p.coeffs.iter())
.zip(identity_b.secret_key.iter().flat_map(|p| p.coeffs.iter()))
.all(|(a, b)| a == b);
assert!(
!keys_equal,
"Two independently generated secret keys should differ"
);
}
#[test]
fn test_tampered_proof_rejected() {
let seed = test_seed();
let identity = MasterIdentity::from_seed(&seed);
let tau = b"tamper_test_context_777";
let proj = identity.project_at_context(tau, &test_rho());
let mut proof = Prover::prove_identity(&identity, &proj, &seed)
.expect("honest proving must not exhaust rejection sampling");
proof.response_z[0].coeffs[0] = proof.response_z[0].coeffs[0].wrapping_add(1);
let valid = Verifier::verify(&proj, &proof);
assert!(!valid, "Tampered proof should be rejected by the verifier");
}
fn assert_master_identity_zeroizes() {
assert!(
core::mem::needs_drop::<MasterIdentity>(),
"MasterIdentity must carry Drop glue for ZeroizeOnDrop to do anything on scope exit"
);
let seed = [0x77u8; 32];
let mut identity = MasterIdentity::from_seed(&seed);
assert!(
identity
.secret_key
.iter()
.any(|p| p.coeffs.iter().any(|&c| c != 0)),
"sanity check: this seed should produce a non-zero secret key"
);
identity.zeroize();
assert!(
identity
.secret_key
.iter()
.all(|p| p.coeffs.iter().all(|&c| c == 0)),
"zeroize() must clear every coefficient of the secret key"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn zeroize_wipes_master_identity_secret() {
assert_master_identity_zeroizes();
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen_test::wasm_bindgen_test]
fn zeroize_wipes_master_identity_secret_wasm() {
assert_master_identity_zeroizes();
}
}