use arkhe_forge_core::event::RuntimeSignatureClass;
use ed25519_dalek::{Signature, VerifyingKey};
#[cfg(feature = "tier-2-pqc-receipts")]
use ml_dsa::signature::{Keypair, Signer};
#[cfg(feature = "tier-2-pqc-receipts")]
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, B32};
#[cfg(feature = "tier-2-pqc-receipts")]
use zeroize::Zeroize;
const ED25519_PK_LEN: usize = 32;
const ED25519_SIG_LEN: usize = 64;
const MLDSA65_PK_LEN: usize = 1952;
const MLDSA65_SIG_LEN: usize = 3309;
pub(crate) const FORGE_RECEIPT_SIG_DOMAIN: &[u8] =
b"arkhe-forge v0.15 audit receipt attestation domain";
pub(crate) fn domain_separated(message: &[u8]) -> Vec<u8> {
let mut m = Vec::with_capacity(FORGE_RECEIPT_SIG_DOMAIN.len() + message.len());
m.extend_from_slice(FORGE_RECEIPT_SIG_DOMAIN);
m.extend_from_slice(message);
m
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum VerifyError {
#[error("a signature is required but the policy class is None")]
SignatureRequired,
#[error("public key has the wrong length for the signature class")]
WrongKeyLength,
#[error("signature has the wrong length for the signature class")]
WrongSignatureLength,
#[error("signature did not validate")]
Mismatch,
#[error("PQC verification unavailable; build with feature tier-2-pqc-receipts")]
PqcUnavailable,
#[error("receipt envelope is internally incoherent")]
EnvelopeIncoherent,
}
pub fn verify_attestation(
class: RuntimeSignatureClass,
public_key: &[u8],
message: &[u8],
sig: &[u8],
) -> Result<(), VerifyError> {
let m = domain_separated(message);
match class {
RuntimeSignatureClass::None => Err(VerifyError::SignatureRequired),
RuntimeSignatureClass::Ed25519 => verify_ed25519(public_key, &m, sig),
RuntimeSignatureClass::MlDsa65 => verify_ml_dsa65(public_key, &m, sig),
RuntimeSignatureClass::Hybrid => {
if public_key.len() != ED25519_PK_LEN + MLDSA65_PK_LEN {
return Err(VerifyError::WrongKeyLength);
}
if sig.len() != ED25519_SIG_LEN + MLDSA65_SIG_LEN {
return Err(VerifyError::WrongSignatureLength);
}
let (ed_pk, mldsa_pk) = public_key.split_at(ED25519_PK_LEN);
let (ed_sig, mldsa_sig) = sig.split_at(ED25519_SIG_LEN);
verify_ed25519(ed_pk, &m, ed_sig)?;
verify_ml_dsa65(mldsa_pk, &m, mldsa_sig)
}
_ => Err(VerifyError::Mismatch),
}
}
pub fn verify_receipt_envelope(
algorithm: RuntimeSignatureClass,
public_key: &[u8],
message: &[u8],
attestation_64: &[u8],
attestation_pqc: Option<&[u8]>,
) -> Result<(), VerifyError> {
let pqc_required = matches!(
algorithm,
RuntimeSignatureClass::MlDsa65 | RuntimeSignatureClass::Hybrid
);
if attestation_pqc.is_some() != pqc_required {
return Err(VerifyError::EnvelopeIncoherent);
}
match algorithm {
RuntimeSignatureClass::None => Err(VerifyError::SignatureRequired),
RuntimeSignatureClass::Ed25519 => verify_attestation(
RuntimeSignatureClass::Ed25519,
public_key,
message,
attestation_64,
),
RuntimeSignatureClass::MlDsa65 => {
if !attestation_64.is_empty() {
return Err(VerifyError::EnvelopeIncoherent);
}
match attestation_pqc {
Some(pqc) => {
verify_attestation(RuntimeSignatureClass::MlDsa65, public_key, message, pqc)
}
None => Err(VerifyError::EnvelopeIncoherent),
}
}
RuntimeSignatureClass::Hybrid => match attestation_pqc {
Some(pqc) => {
if attestation_64.len() != ED25519_SIG_LEN {
return Err(VerifyError::WrongSignatureLength);
}
let mut sig = Vec::with_capacity(attestation_64.len() + pqc.len());
sig.extend_from_slice(attestation_64);
sig.extend_from_slice(pqc);
verify_attestation(RuntimeSignatureClass::Hybrid, public_key, message, &sig)
}
None => Err(VerifyError::EnvelopeIncoherent),
},
_ => Err(VerifyError::Mismatch),
}
}
fn verify_ed25519(vk_bytes: &[u8], m: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
if vk_bytes.len() != ED25519_PK_LEN {
return Err(VerifyError::WrongKeyLength);
}
if sig.len() != ED25519_SIG_LEN {
return Err(VerifyError::WrongSignatureLength);
}
let mut pk = [0u8; ED25519_PK_LEN];
pk.copy_from_slice(vk_bytes);
let vk = VerifyingKey::from_bytes(&pk).map_err(|_| VerifyError::Mismatch)?;
let mut sb = [0u8; ED25519_SIG_LEN];
sb.copy_from_slice(sig);
let sig_obj = Signature::from_bytes(&sb);
vk.verify_strict(m, &sig_obj)
.map_err(|_| VerifyError::Mismatch)
}
#[cfg(feature = "tier-2-pqc-receipts")]
fn verify_ml_dsa65(vk_bytes: &[u8], m: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
use ml_dsa::signature::Verifier as _;
if sig.len() != MLDSA65_SIG_LEN {
return Err(VerifyError::WrongSignatureLength);
}
if vk_bytes.len() != MLDSA65_PK_LEN {
return Err(VerifyError::WrongKeyLength);
}
let mut sb = EncodedSignature::<MlDsa65>::default();
sb.as_mut_slice().copy_from_slice(sig);
let sig_obj = ml_dsa::Signature::<MlDsa65>::decode(&sb).ok_or(VerifyError::Mismatch)?;
let mut kb = EncodedVerifyingKey::<MlDsa65>::default();
kb.as_mut_slice().copy_from_slice(vk_bytes);
let vk = ml_dsa::VerifyingKey::<MlDsa65>::decode(&kb);
vk.verify(m, &sig_obj).map_err(|_| VerifyError::Mismatch)
}
#[cfg(not(feature = "tier-2-pqc-receipts"))]
fn verify_ml_dsa65(_vk_bytes: &[u8], _m: &[u8], _sig: &[u8]) -> Result<(), VerifyError> {
Err(VerifyError::PqcUnavailable)
}
#[must_use]
pub fn dek_shred_message(dek_id: &arkhe_forge_core::pii::DekId, log_index: u64) -> Vec<u8> {
let mut m = Vec::with_capacity(16 + 8);
m.extend_from_slice(&dek_id.0);
m.extend_from_slice(&log_index.to_be_bytes());
m
}
#[cfg(feature = "tier-2-pqc-receipts")]
pub struct ReceiptSigner {
signing_key: ml_dsa::SigningKey<MlDsa65>,
verifying_key_bytes: Vec<u8>,
}
#[cfg(feature = "tier-2-pqc-receipts")]
impl ReceiptSigner {
#[must_use]
pub fn mldsa65_from_seed(mut seed: [u8; 32]) -> Self {
let mut xi: B32 = seed.into();
let signing_key = ml_dsa::SigningKey::<MlDsa65>::from_seed(&xi);
let verifying_key_bytes = signing_key.verifying_key().encode().to_vec();
seed.zeroize();
xi.zeroize();
Self {
signing_key,
verifying_key_bytes,
}
}
#[must_use]
pub fn sign(&self, message: &[u8]) -> Vec<u8> {
let m = domain_separated(message);
#[allow(clippy::expect_used)]
let sig: ml_dsa::Signature<MlDsa65> = self
.signing_key
.try_sign(&m)
.expect("ml-dsa-65 software try_sign is infallible (deterministic, no RNG)");
sig.encode().to_vec()
}
#[must_use]
pub fn public_key_bytes(&self) -> &[u8] {
&self.verifying_key_bytes
}
}
#[cfg(feature = "tier-2-pqc-receipts")]
impl core::fmt::Debug for ReceiptSigner {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"ReceiptSigner {{ verifying_key_bytes: <1952B>, signing_key: <redacted> }}"
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use arkhe_forge_core::pii::DekId;
#[test]
fn domain_separated_prefixes_tag() {
let m = domain_separated(b"payload");
assert!(m.starts_with(FORGE_RECEIPT_SIG_DOMAIN));
assert_eq!(&m[FORGE_RECEIPT_SIG_DOMAIN.len()..], b"payload");
}
#[test]
fn forge_domain_distinct_from_kernel_wal_domain() {
assert_ne!(
FORGE_RECEIPT_SIG_DOMAIN,
b"arkhe-kernel v0.14 WAL record signature domain".as_slice()
);
}
#[test]
fn dek_shred_message_canonical_layout_frozen() {
let dek_id = DekId([0xAB; 16]);
let m = dek_shred_message(&dek_id, 0x0102_0304_0506_0708);
assert_eq!(m.len(), 24);
assert_eq!(&m[..16], &[0xAB; 16]);
assert_eq!(
&m[16..24],
&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
);
}
#[test]
fn none_class_requires_signature() {
let err = verify_attestation(RuntimeSignatureClass::None, &[], b"m", &[]).unwrap_err();
assert_eq!(err, VerifyError::SignatureRequired);
}
#[test]
fn ed25519_round_trip_validates() {
use ed25519_dalek::{Signer as _, SigningKey};
let sk = SigningKey::from_bytes(&[7u8; 32]);
let vk = sk.verifying_key();
let message = b"erasure receipt body";
let m = domain_separated(message);
let sig = sk.sign(&m);
assert!(verify_attestation(
RuntimeSignatureClass::Ed25519,
&vk.to_bytes(),
message,
&sig.to_bytes()
)
.is_ok());
}
#[test]
fn ed25519_wrong_key_length_rejected() {
let err = verify_attestation(RuntimeSignatureClass::Ed25519, &[0u8; 31], b"m", &[0u8; 64])
.unwrap_err();
assert_eq!(err, VerifyError::WrongKeyLength);
}
#[test]
fn ed25519_wrong_sig_length_rejected() {
let err = verify_attestation(RuntimeSignatureClass::Ed25519, &[0u8; 32], b"m", &[0u8; 63])
.unwrap_err();
assert_eq!(err, VerifyError::WrongSignatureLength);
}
#[test]
fn ed25519_corrupt_sig_rejected() {
use ed25519_dalek::{Signer as _, SigningKey};
let sk = SigningKey::from_bytes(&[9u8; 32]);
let vk = sk.verifying_key();
let message = b"body";
let m = domain_separated(message);
let mut sig = sk.sign(&m).to_bytes();
sig[0] ^= 0xFF;
let err = verify_attestation(
RuntimeSignatureClass::Ed25519,
&vk.to_bytes(),
message,
&sig,
)
.unwrap_err();
assert_eq!(err, VerifyError::Mismatch);
}
#[cfg(not(feature = "tier-2-pqc-receipts"))]
#[test]
fn ml_dsa65_unavailable_in_default_build() {
let err = verify_attestation(
RuntimeSignatureClass::MlDsa65,
&[0u8; MLDSA65_PK_LEN],
b"m",
&[0u8; MLDSA65_SIG_LEN],
)
.unwrap_err();
assert_eq!(err, VerifyError::PqcUnavailable);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn ml_dsa65_round_trip_validates() {
let signer = ReceiptSigner::mldsa65_from_seed([11u8; 32]);
let message = b"ml-dsa receipt body";
let sig = signer.sign(message);
assert_eq!(sig.len(), MLDSA65_SIG_LEN);
assert_eq!(signer.public_key_bytes().len(), MLDSA65_PK_LEN);
assert!(verify_attestation(
RuntimeSignatureClass::MlDsa65,
signer.public_key_bytes(),
message,
&sig
)
.is_ok());
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn ml_dsa65_corrupt_sig_rejected() {
let signer = ReceiptSigner::mldsa65_from_seed([13u8; 32]);
let message = b"body";
let mut sig = signer.sign(message);
sig[0] ^= 0xFF;
let err = verify_attestation(
RuntimeSignatureClass::MlDsa65,
signer.public_key_bytes(),
message,
&sig,
)
.unwrap_err();
assert_eq!(err, VerifyError::Mismatch);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn hybrid_round_trip_validates_and_mode() {
use ed25519_dalek::{Signer as _, SigningKey};
let ed_sk = SigningKey::from_bytes(&[23u8; 32]);
let ed_vk = ed_sk.verifying_key();
let pqc = ReceiptSigner::mldsa65_from_seed([29u8; 32]);
let message = b"hybrid receipt body";
let m = domain_separated(message);
let ed_sig = ed_sk.sign(&m).to_bytes();
let pqc_sig = pqc.sign(message);
let mut public_key = Vec::new();
public_key.extend_from_slice(&ed_vk.to_bytes());
public_key.extend_from_slice(pqc.public_key_bytes());
let mut sig = Vec::new();
sig.extend_from_slice(&ed_sig);
sig.extend_from_slice(&pqc_sig);
assert_eq!(public_key.len(), ED25519_PK_LEN + MLDSA65_PK_LEN);
assert_eq!(sig.len(), ED25519_SIG_LEN + MLDSA65_SIG_LEN);
assert!(
verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig).is_ok()
);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn hybrid_rejects_when_pqc_half_corrupt() {
use ed25519_dalek::{Signer as _, SigningKey};
let ed_sk = SigningKey::from_bytes(&[31u8; 32]);
let ed_vk = ed_sk.verifying_key();
let pqc = ReceiptSigner::mldsa65_from_seed([37u8; 32]);
let message = b"body";
let m = domain_separated(message);
let ed_sig = ed_sk.sign(&m).to_bytes();
let mut pqc_sig = pqc.sign(message);
pqc_sig[0] ^= 0xFF;
let mut public_key = Vec::new();
public_key.extend_from_slice(&ed_vk.to_bytes());
public_key.extend_from_slice(pqc.public_key_bytes());
let mut sig = Vec::new();
sig.extend_from_slice(&ed_sig);
sig.extend_from_slice(&pqc_sig);
let err = verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig)
.unwrap_err();
assert_eq!(err, VerifyError::Mismatch);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn hybrid_rejects_when_ed25519_half_corrupt() {
use ed25519_dalek::{Signer as _, SigningKey};
let ed_sk = SigningKey::from_bytes(&[41u8; 32]);
let ed_vk = ed_sk.verifying_key();
let pqc = ReceiptSigner::mldsa65_from_seed([43u8; 32]);
let message = b"body";
let m = domain_separated(message);
let mut ed_sig = ed_sk.sign(&m).to_bytes();
ed_sig[0] ^= 0xFF;
let pqc_sig = pqc.sign(message);
let mut public_key = Vec::new();
public_key.extend_from_slice(&ed_vk.to_bytes());
public_key.extend_from_slice(pqc.public_key_bytes());
let mut sig = Vec::new();
sig.extend_from_slice(&ed_sig);
sig.extend_from_slice(&pqc_sig);
let err = verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig)
.unwrap_err();
assert_eq!(err, VerifyError::Mismatch);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn hybrid_wrong_key_length_rejected() {
let err = verify_attestation(
RuntimeSignatureClass::Hybrid,
&[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN - 1],
b"m",
&[0u8; ED25519_SIG_LEN + MLDSA65_SIG_LEN],
)
.unwrap_err();
assert_eq!(err, VerifyError::WrongKeyLength);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn hybrid_wrong_sig_length_rejected() {
let err = verify_attestation(
RuntimeSignatureClass::Hybrid,
&[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN],
b"m",
&[0u8; ED25519_SIG_LEN + MLDSA65_SIG_LEN - 1],
)
.unwrap_err();
assert_eq!(err, VerifyError::WrongSignatureLength);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn receipt_signer_debug_redacts_key() {
let signer = ReceiptSigner::mldsa65_from_seed([0u8; 32]);
let dbg = format!("{:?}", signer);
assert!(dbg.contains("<redacted>"));
}
#[test]
fn envelope_ed25519_ok() {
use ed25519_dalek::{Signer as _, SigningKey};
let sk = SigningKey::from_bytes(&[59u8; 32]);
let vk = sk.verifying_key();
let message = b"envelope ed25519 body";
let m = domain_separated(message);
let sig = sk.sign(&m).to_bytes();
assert!(verify_receipt_envelope(
RuntimeSignatureClass::Ed25519,
&vk.to_bytes(),
message,
&sig,
None,
)
.is_ok());
}
#[test]
fn envelope_incoherent_ed25519_with_pqc_slot() {
let err = verify_receipt_envelope(
RuntimeSignatureClass::Ed25519,
&[0u8; ED25519_PK_LEN],
b"m",
&[0u8; ED25519_SIG_LEN],
Some(&[0u8; MLDSA65_SIG_LEN]),
)
.unwrap_err();
assert_eq!(err, VerifyError::EnvelopeIncoherent);
}
#[test]
fn envelope_incoherent_mldsa65_without_pqc_slot() {
let err = verify_receipt_envelope(
RuntimeSignatureClass::MlDsa65,
&[0u8; MLDSA65_PK_LEN],
b"m",
&[],
None,
)
.unwrap_err();
assert_eq!(err, VerifyError::EnvelopeIncoherent);
}
#[test]
fn envelope_none_class_requires_signature() {
let err =
verify_receipt_envelope(RuntimeSignatureClass::None, &[], b"m", &[], None).unwrap_err();
assert_eq!(err, VerifyError::SignatureRequired);
}
#[test]
fn envelope_incoherent_mldsa65_with_nonempty_classical_slot() {
let err = verify_receipt_envelope(
RuntimeSignatureClass::MlDsa65,
&[0u8; MLDSA65_PK_LEN],
b"m",
&[0u8; ED25519_SIG_LEN],
Some(&[0u8; MLDSA65_SIG_LEN]),
)
.unwrap_err();
assert_eq!(err, VerifyError::EnvelopeIncoherent);
}
#[test]
fn envelope_hybrid_mis_split_classical_half_rejected() {
let err = verify_receipt_envelope(
RuntimeSignatureClass::Hybrid,
&[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN],
b"m",
&[0u8; ED25519_SIG_LEN - 1],
Some(&[0u8; MLDSA65_SIG_LEN]),
)
.unwrap_err();
assert_eq!(err, VerifyError::WrongSignatureLength);
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn envelope_mldsa65_ok() {
let signer = ReceiptSigner::mldsa65_from_seed([61u8; 32]);
let message = b"envelope ml-dsa body";
let sig = signer.sign(message);
assert_eq!(sig.len(), MLDSA65_SIG_LEN);
assert!(verify_receipt_envelope(
RuntimeSignatureClass::MlDsa65,
signer.public_key_bytes(),
message,
&[],
Some(&sig),
)
.is_ok());
}
#[cfg(feature = "tier-2-pqc-receipts")]
#[test]
fn envelope_hybrid_ok() {
use ed25519_dalek::{Signer as _, SigningKey};
let ed_sk = SigningKey::from_bytes(&[67u8; 32]);
let ed_vk = ed_sk.verifying_key();
let pqc = ReceiptSigner::mldsa65_from_seed([71u8; 32]);
let message = b"envelope hybrid body";
let m = domain_separated(message);
let ed_sig = ed_sk.sign(&m).to_bytes();
let pqc_sig = pqc.sign(message);
let mut public_key = Vec::new();
public_key.extend_from_slice(&ed_vk.to_bytes());
public_key.extend_from_slice(pqc.public_key_bytes());
assert!(verify_receipt_envelope(
RuntimeSignatureClass::Hybrid,
&public_key,
message,
&ed_sig,
Some(&pqc_sig),
)
.is_ok());
}
}