1use arkhe_forge_core::event::RuntimeSignatureClass;
20use ed25519_dalek::{Signature, VerifyingKey};
21
22#[cfg(feature = "tier-2-pqc-receipts")]
23use ml_dsa::signature::{Keypair, Signer};
24#[cfg(feature = "tier-2-pqc-receipts")]
25use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, B32};
26#[cfg(feature = "tier-2-pqc-receipts")]
27use zeroize::Zeroize;
28
29const ED25519_PK_LEN: usize = 32;
31const ED25519_SIG_LEN: usize = 64;
33const MLDSA65_PK_LEN: usize = 1952;
35const MLDSA65_SIG_LEN: usize = 3309;
37
38pub(crate) const FORGE_RECEIPT_SIG_DOMAIN: &[u8] =
50 b"arkhe-forge v0.15 audit receipt attestation domain";
51
52pub(crate) fn domain_separated(message: &[u8]) -> Vec<u8> {
58 let mut m = Vec::with_capacity(FORGE_RECEIPT_SIG_DOMAIN.len() + message.len());
59 m.extend_from_slice(FORGE_RECEIPT_SIG_DOMAIN);
60 m.extend_from_slice(message);
61 m
62}
63
64#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
67pub enum VerifyError {
68 #[error("a signature is required but the policy class is None")]
72 SignatureRequired,
73 #[error("public key has the wrong length for the signature class")]
75 WrongKeyLength,
76 #[error("signature has the wrong length for the signature class")]
78 WrongSignatureLength,
79 #[error("signature did not validate")]
81 Mismatch,
82 #[error("PQC verification unavailable; build with feature tier-2-pqc-receipts")]
85 PqcUnavailable,
86 #[error("receipt envelope is internally incoherent")]
89 EnvelopeIncoherent,
90}
91
92pub fn verify_attestation(
116 class: RuntimeSignatureClass,
117 public_key: &[u8],
118 message: &[u8],
119 sig: &[u8],
120) -> Result<(), VerifyError> {
121 let m = domain_separated(message);
124 match class {
125 RuntimeSignatureClass::None => Err(VerifyError::SignatureRequired),
126 RuntimeSignatureClass::Ed25519 => verify_ed25519(public_key, &m, sig),
127 RuntimeSignatureClass::MlDsa65 => verify_ml_dsa65(public_key, &m, sig),
128 RuntimeSignatureClass::Hybrid => {
129 if public_key.len() != ED25519_PK_LEN + MLDSA65_PK_LEN {
134 return Err(VerifyError::WrongKeyLength);
135 }
136 if sig.len() != ED25519_SIG_LEN + MLDSA65_SIG_LEN {
137 return Err(VerifyError::WrongSignatureLength);
138 }
139 let (ed_pk, mldsa_pk) = public_key.split_at(ED25519_PK_LEN);
140 let (ed_sig, mldsa_sig) = sig.split_at(ED25519_SIG_LEN);
141 verify_ed25519(ed_pk, &m, ed_sig)?;
144 verify_ml_dsa65(mldsa_pk, &m, mldsa_sig)
145 }
146 _ => Err(VerifyError::Mismatch),
149 }
150}
151
152pub fn verify_receipt_envelope(
178 algorithm: RuntimeSignatureClass,
179 public_key: &[u8],
180 message: &[u8],
181 attestation_64: &[u8],
182 attestation_pqc: Option<&[u8]>,
183) -> Result<(), VerifyError> {
184 let pqc_required = matches!(
189 algorithm,
190 RuntimeSignatureClass::MlDsa65 | RuntimeSignatureClass::Hybrid
191 );
192 if attestation_pqc.is_some() != pqc_required {
193 return Err(VerifyError::EnvelopeIncoherent);
194 }
195 match algorithm {
196 RuntimeSignatureClass::None => Err(VerifyError::SignatureRequired),
197 RuntimeSignatureClass::Ed25519 => verify_attestation(
198 RuntimeSignatureClass::Ed25519,
199 public_key,
200 message,
201 attestation_64,
202 ),
203 RuntimeSignatureClass::MlDsa65 => {
204 if !attestation_64.is_empty() {
208 return Err(VerifyError::EnvelopeIncoherent);
209 }
210 match attestation_pqc {
213 Some(pqc) => {
214 verify_attestation(RuntimeSignatureClass::MlDsa65, public_key, message, pqc)
215 }
216 None => Err(VerifyError::EnvelopeIncoherent),
217 }
218 }
219 RuntimeSignatureClass::Hybrid => match attestation_pqc {
220 Some(pqc) => {
221 if attestation_64.len() != ED25519_SIG_LEN {
227 return Err(VerifyError::WrongSignatureLength);
228 }
229 let mut sig = Vec::with_capacity(attestation_64.len() + pqc.len());
233 sig.extend_from_slice(attestation_64);
234 sig.extend_from_slice(pqc);
235 verify_attestation(RuntimeSignatureClass::Hybrid, public_key, message, &sig)
236 }
237 None => Err(VerifyError::EnvelopeIncoherent),
238 },
239 _ => Err(VerifyError::Mismatch),
241 }
242}
243
244fn verify_ed25519(vk_bytes: &[u8], m: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
249 if vk_bytes.len() != ED25519_PK_LEN {
250 return Err(VerifyError::WrongKeyLength);
251 }
252 if sig.len() != ED25519_SIG_LEN {
253 return Err(VerifyError::WrongSignatureLength);
254 }
255 let mut pk = [0u8; ED25519_PK_LEN];
256 pk.copy_from_slice(vk_bytes);
257 let vk = VerifyingKey::from_bytes(&pk).map_err(|_| VerifyError::Mismatch)?;
258 let mut sb = [0u8; ED25519_SIG_LEN];
259 sb.copy_from_slice(sig);
260 let sig_obj = Signature::from_bytes(&sb);
261 vk.verify_strict(m, &sig_obj)
262 .map_err(|_| VerifyError::Mismatch)
263}
264
265#[cfg(feature = "tier-2-pqc-receipts")]
271fn verify_ml_dsa65(vk_bytes: &[u8], m: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
272 use ml_dsa::signature::Verifier as _;
273
274 if sig.len() != MLDSA65_SIG_LEN {
275 return Err(VerifyError::WrongSignatureLength);
276 }
277 if vk_bytes.len() != MLDSA65_PK_LEN {
278 return Err(VerifyError::WrongKeyLength);
279 }
280 let mut sb = EncodedSignature::<MlDsa65>::default();
281 sb.as_mut_slice().copy_from_slice(sig);
282 let sig_obj = ml_dsa::Signature::<MlDsa65>::decode(&sb).ok_or(VerifyError::Mismatch)?;
283 let mut kb = EncodedVerifyingKey::<MlDsa65>::default();
284 kb.as_mut_slice().copy_from_slice(vk_bytes);
285 let vk = ml_dsa::VerifyingKey::<MlDsa65>::decode(&kb);
286 vk.verify(m, &sig_obj).map_err(|_| VerifyError::Mismatch)
287}
288
289#[cfg(not(feature = "tier-2-pqc-receipts"))]
295fn verify_ml_dsa65(_vk_bytes: &[u8], _m: &[u8], _sig: &[u8]) -> Result<(), VerifyError> {
296 Err(VerifyError::PqcUnavailable)
297}
298
299#[must_use]
306pub fn dek_shred_message(dek_id: &arkhe_forge_core::pii::DekId, log_index: u64) -> Vec<u8> {
307 let mut m = Vec::with_capacity(16 + 8);
308 m.extend_from_slice(&dek_id.0);
309 m.extend_from_slice(&log_index.to_be_bytes());
310 m
311}
312
313#[cfg(feature = "tier-2-pqc-receipts")]
320pub struct ReceiptSigner {
321 signing_key: ml_dsa::SigningKey<MlDsa65>,
322 verifying_key_bytes: Vec<u8>,
323}
324
325#[cfg(feature = "tier-2-pqc-receipts")]
326impl ReceiptSigner {
327 #[must_use]
335 pub fn mldsa65_from_seed(mut seed: [u8; 32]) -> Self {
336 let mut xi: B32 = seed.into();
341 let signing_key = ml_dsa::SigningKey::<MlDsa65>::from_seed(&xi);
342 let verifying_key_bytes = signing_key.verifying_key().encode().to_vec();
343 seed.zeroize();
344 xi.zeroize();
345 Self {
346 signing_key,
347 verifying_key_bytes,
348 }
349 }
350
351 #[must_use]
355 pub fn sign(&self, message: &[u8]) -> Vec<u8> {
356 let m = domain_separated(message);
357 #[allow(clippy::expect_used)]
361 let sig: ml_dsa::Signature<MlDsa65> = self
362 .signing_key
363 .try_sign(&m)
364 .expect("ml-dsa-65 software try_sign is infallible (deterministic, no RNG)");
365 sig.encode().to_vec()
366 }
367
368 #[must_use]
370 pub fn public_key_bytes(&self) -> &[u8] {
371 &self.verifying_key_bytes
372 }
373}
374
375#[cfg(feature = "tier-2-pqc-receipts")]
376impl core::fmt::Debug for ReceiptSigner {
377 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
378 write!(
379 f,
380 "ReceiptSigner {{ verifying_key_bytes: <1952B>, signing_key: <redacted> }}"
381 )
382 }
383}
384
385#[cfg(test)]
386#[allow(clippy::unwrap_used, clippy::expect_used)]
387mod tests {
388 use super::*;
389 use arkhe_forge_core::pii::DekId;
390
391 #[test]
392 fn domain_separated_prefixes_tag() {
393 let m = domain_separated(b"payload");
394 assert!(m.starts_with(FORGE_RECEIPT_SIG_DOMAIN));
395 assert_eq!(&m[FORGE_RECEIPT_SIG_DOMAIN.len()..], b"payload");
396 }
397
398 #[test]
399 fn forge_domain_distinct_from_kernel_wal_domain() {
400 assert_ne!(
403 FORGE_RECEIPT_SIG_DOMAIN,
404 b"arkhe-kernel v0.14 WAL record signature domain".as_slice()
405 );
406 }
407
408 #[test]
409 fn dek_shred_message_canonical_layout_frozen() {
410 let dek_id = DekId([0xAB; 16]);
412 let m = dek_shred_message(&dek_id, 0x0102_0304_0506_0708);
413 assert_eq!(m.len(), 24);
414 assert_eq!(&m[..16], &[0xAB; 16]);
415 assert_eq!(
416 &m[16..24],
417 &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
418 );
419 }
420
421 #[test]
422 fn none_class_requires_signature() {
423 let err = verify_attestation(RuntimeSignatureClass::None, &[], b"m", &[]).unwrap_err();
424 assert_eq!(err, VerifyError::SignatureRequired);
425 }
426
427 #[test]
428 fn ed25519_round_trip_validates() {
429 use ed25519_dalek::{Signer as _, SigningKey};
430 let sk = SigningKey::from_bytes(&[7u8; 32]);
431 let vk = sk.verifying_key();
432 let message = b"erasure receipt body";
433 let m = domain_separated(message);
434 let sig = sk.sign(&m);
435 assert!(verify_attestation(
436 RuntimeSignatureClass::Ed25519,
437 &vk.to_bytes(),
438 message,
439 &sig.to_bytes()
440 )
441 .is_ok());
442 }
443
444 #[test]
445 fn ed25519_wrong_key_length_rejected() {
446 let err = verify_attestation(RuntimeSignatureClass::Ed25519, &[0u8; 31], b"m", &[0u8; 64])
447 .unwrap_err();
448 assert_eq!(err, VerifyError::WrongKeyLength);
449 }
450
451 #[test]
452 fn ed25519_wrong_sig_length_rejected() {
453 let err = verify_attestation(RuntimeSignatureClass::Ed25519, &[0u8; 32], b"m", &[0u8; 63])
454 .unwrap_err();
455 assert_eq!(err, VerifyError::WrongSignatureLength);
456 }
457
458 #[test]
459 fn ed25519_corrupt_sig_rejected() {
460 use ed25519_dalek::{Signer as _, SigningKey};
461 let sk = SigningKey::from_bytes(&[9u8; 32]);
462 let vk = sk.verifying_key();
463 let message = b"body";
464 let m = domain_separated(message);
465 let mut sig = sk.sign(&m).to_bytes();
466 sig[0] ^= 0xFF;
467 let err = verify_attestation(
468 RuntimeSignatureClass::Ed25519,
469 &vk.to_bytes(),
470 message,
471 &sig,
472 )
473 .unwrap_err();
474 assert_eq!(err, VerifyError::Mismatch);
475 }
476
477 #[cfg(not(feature = "tier-2-pqc-receipts"))]
478 #[test]
479 fn ml_dsa65_unavailable_in_default_build() {
480 let err = verify_attestation(
481 RuntimeSignatureClass::MlDsa65,
482 &[0u8; MLDSA65_PK_LEN],
483 b"m",
484 &[0u8; MLDSA65_SIG_LEN],
485 )
486 .unwrap_err();
487 assert_eq!(err, VerifyError::PqcUnavailable);
488 }
489
490 #[cfg(feature = "tier-2-pqc-receipts")]
491 #[test]
492 fn ml_dsa65_round_trip_validates() {
493 let signer = ReceiptSigner::mldsa65_from_seed([11u8; 32]);
494 let message = b"ml-dsa receipt body";
495 let sig = signer.sign(message);
496 assert_eq!(sig.len(), MLDSA65_SIG_LEN);
497 assert_eq!(signer.public_key_bytes().len(), MLDSA65_PK_LEN);
498 assert!(verify_attestation(
499 RuntimeSignatureClass::MlDsa65,
500 signer.public_key_bytes(),
501 message,
502 &sig
503 )
504 .is_ok());
505 }
506
507 #[cfg(feature = "tier-2-pqc-receipts")]
508 #[test]
509 fn ml_dsa65_corrupt_sig_rejected() {
510 let signer = ReceiptSigner::mldsa65_from_seed([13u8; 32]);
511 let message = b"body";
512 let mut sig = signer.sign(message);
513 sig[0] ^= 0xFF;
514 let err = verify_attestation(
515 RuntimeSignatureClass::MlDsa65,
516 signer.public_key_bytes(),
517 message,
518 &sig,
519 )
520 .unwrap_err();
521 assert_eq!(err, VerifyError::Mismatch);
522 }
523
524 #[cfg(feature = "tier-2-pqc-receipts")]
525 #[test]
526 fn hybrid_round_trip_validates_and_mode() {
527 use ed25519_dalek::{Signer as _, SigningKey};
528 let ed_sk = SigningKey::from_bytes(&[23u8; 32]);
529 let ed_vk = ed_sk.verifying_key();
530 let pqc = ReceiptSigner::mldsa65_from_seed([29u8; 32]);
531 let message = b"hybrid receipt body";
532 let m = domain_separated(message);
533 let ed_sig = ed_sk.sign(&m).to_bytes();
534 let pqc_sig = pqc.sign(message);
535
536 let mut public_key = Vec::new();
537 public_key.extend_from_slice(&ed_vk.to_bytes());
538 public_key.extend_from_slice(pqc.public_key_bytes());
539 let mut sig = Vec::new();
540 sig.extend_from_slice(&ed_sig);
541 sig.extend_from_slice(&pqc_sig);
542 assert_eq!(public_key.len(), ED25519_PK_LEN + MLDSA65_PK_LEN);
543 assert_eq!(sig.len(), ED25519_SIG_LEN + MLDSA65_SIG_LEN);
544
545 assert!(
546 verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig).is_ok()
547 );
548 }
549
550 #[cfg(feature = "tier-2-pqc-receipts")]
551 #[test]
552 fn hybrid_rejects_when_pqc_half_corrupt() {
553 use ed25519_dalek::{Signer as _, SigningKey};
555 let ed_sk = SigningKey::from_bytes(&[31u8; 32]);
556 let ed_vk = ed_sk.verifying_key();
557 let pqc = ReceiptSigner::mldsa65_from_seed([37u8; 32]);
558 let message = b"body";
559 let m = domain_separated(message);
560 let ed_sig = ed_sk.sign(&m).to_bytes();
561 let mut pqc_sig = pqc.sign(message);
562 pqc_sig[0] ^= 0xFF;
563
564 let mut public_key = Vec::new();
565 public_key.extend_from_slice(&ed_vk.to_bytes());
566 public_key.extend_from_slice(pqc.public_key_bytes());
567 let mut sig = Vec::new();
568 sig.extend_from_slice(&ed_sig);
569 sig.extend_from_slice(&pqc_sig);
570 let err = verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig)
571 .unwrap_err();
572 assert_eq!(err, VerifyError::Mismatch);
573 }
574
575 #[cfg(feature = "tier-2-pqc-receipts")]
576 #[test]
577 fn hybrid_rejects_when_ed25519_half_corrupt() {
578 use ed25519_dalek::{Signer as _, SigningKey};
580 let ed_sk = SigningKey::from_bytes(&[41u8; 32]);
581 let ed_vk = ed_sk.verifying_key();
582 let pqc = ReceiptSigner::mldsa65_from_seed([43u8; 32]);
583 let message = b"body";
584 let m = domain_separated(message);
585 let mut ed_sig = ed_sk.sign(&m).to_bytes();
586 ed_sig[0] ^= 0xFF;
587 let pqc_sig = pqc.sign(message);
588
589 let mut public_key = Vec::new();
590 public_key.extend_from_slice(&ed_vk.to_bytes());
591 public_key.extend_from_slice(pqc.public_key_bytes());
592 let mut sig = Vec::new();
593 sig.extend_from_slice(&ed_sig);
594 sig.extend_from_slice(&pqc_sig);
595 let err = verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig)
596 .unwrap_err();
597 assert_eq!(err, VerifyError::Mismatch);
598 }
599
600 #[cfg(feature = "tier-2-pqc-receipts")]
601 #[test]
602 fn hybrid_wrong_key_length_rejected() {
603 let err = verify_attestation(
604 RuntimeSignatureClass::Hybrid,
605 &[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN - 1],
606 b"m",
607 &[0u8; ED25519_SIG_LEN + MLDSA65_SIG_LEN],
608 )
609 .unwrap_err();
610 assert_eq!(err, VerifyError::WrongKeyLength);
611 }
612
613 #[cfg(feature = "tier-2-pqc-receipts")]
614 #[test]
615 fn hybrid_wrong_sig_length_rejected() {
616 let err = verify_attestation(
617 RuntimeSignatureClass::Hybrid,
618 &[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN],
619 b"m",
620 &[0u8; ED25519_SIG_LEN + MLDSA65_SIG_LEN - 1],
621 )
622 .unwrap_err();
623 assert_eq!(err, VerifyError::WrongSignatureLength);
624 }
625
626 #[cfg(feature = "tier-2-pqc-receipts")]
627 #[test]
628 fn receipt_signer_debug_redacts_key() {
629 let signer = ReceiptSigner::mldsa65_from_seed([0u8; 32]);
630 let dbg = format!("{:?}", signer);
631 assert!(dbg.contains("<redacted>"));
632 }
633
634 #[test]
635 fn envelope_ed25519_ok() {
636 use ed25519_dalek::{Signer as _, SigningKey};
637 let sk = SigningKey::from_bytes(&[59u8; 32]);
638 let vk = sk.verifying_key();
639 let message = b"envelope ed25519 body";
640 let m = domain_separated(message);
641 let sig = sk.sign(&m).to_bytes();
642 assert!(verify_receipt_envelope(
643 RuntimeSignatureClass::Ed25519,
644 &vk.to_bytes(),
645 message,
646 &sig,
647 None,
648 )
649 .is_ok());
650 }
651
652 #[test]
653 fn envelope_incoherent_ed25519_with_pqc_slot() {
654 let err = verify_receipt_envelope(
656 RuntimeSignatureClass::Ed25519,
657 &[0u8; ED25519_PK_LEN],
658 b"m",
659 &[0u8; ED25519_SIG_LEN],
660 Some(&[0u8; MLDSA65_SIG_LEN]),
661 )
662 .unwrap_err();
663 assert_eq!(err, VerifyError::EnvelopeIncoherent);
664 }
665
666 #[test]
667 fn envelope_incoherent_mldsa65_without_pqc_slot() {
668 let err = verify_receipt_envelope(
670 RuntimeSignatureClass::MlDsa65,
671 &[0u8; MLDSA65_PK_LEN],
672 b"m",
673 &[],
674 None,
675 )
676 .unwrap_err();
677 assert_eq!(err, VerifyError::EnvelopeIncoherent);
678 }
679
680 #[test]
681 fn envelope_none_class_requires_signature() {
682 let err =
683 verify_receipt_envelope(RuntimeSignatureClass::None, &[], b"m", &[], None).unwrap_err();
684 assert_eq!(err, VerifyError::SignatureRequired);
685 }
686
687 #[test]
688 fn envelope_incoherent_mldsa65_with_nonempty_classical_slot() {
689 let err = verify_receipt_envelope(
697 RuntimeSignatureClass::MlDsa65,
698 &[0u8; MLDSA65_PK_LEN],
699 b"m",
700 &[0u8; ED25519_SIG_LEN],
701 Some(&[0u8; MLDSA65_SIG_LEN]),
702 )
703 .unwrap_err();
704 assert_eq!(err, VerifyError::EnvelopeIncoherent);
705 }
706
707 #[test]
708 fn envelope_hybrid_mis_split_classical_half_rejected() {
709 let err = verify_receipt_envelope(
715 RuntimeSignatureClass::Hybrid,
716 &[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN],
717 b"m",
718 &[0u8; ED25519_SIG_LEN - 1],
719 Some(&[0u8; MLDSA65_SIG_LEN]),
720 )
721 .unwrap_err();
722 assert_eq!(err, VerifyError::WrongSignatureLength);
723 }
724
725 #[cfg(feature = "tier-2-pqc-receipts")]
726 #[test]
727 fn envelope_mldsa65_ok() {
728 let signer = ReceiptSigner::mldsa65_from_seed([61u8; 32]);
729 let message = b"envelope ml-dsa body";
730 let sig = signer.sign(message);
731 assert_eq!(sig.len(), MLDSA65_SIG_LEN);
732 assert!(verify_receipt_envelope(
733 RuntimeSignatureClass::MlDsa65,
734 signer.public_key_bytes(),
735 message,
736 &[],
737 Some(&sig),
738 )
739 .is_ok());
740 }
741
742 #[cfg(feature = "tier-2-pqc-receipts")]
743 #[test]
744 fn envelope_hybrid_ok() {
745 use ed25519_dalek::{Signer as _, SigningKey};
746 let ed_sk = SigningKey::from_bytes(&[67u8; 32]);
747 let ed_vk = ed_sk.verifying_key();
748 let pqc = ReceiptSigner::mldsa65_from_seed([71u8; 32]);
749 let message = b"envelope hybrid body";
750 let m = domain_separated(message);
751 let ed_sig = ed_sk.sign(&m).to_bytes();
752 let pqc_sig = pqc.sign(message);
753
754 let mut public_key = Vec::new();
755 public_key.extend_from_slice(&ed_vk.to_bytes());
756 public_key.extend_from_slice(pqc.public_key_bytes());
757 assert!(verify_receipt_envelope(
758 RuntimeSignatureClass::Hybrid,
759 &public_key,
760 message,
761 &ed_sig,
762 Some(&pqc_sig),
763 )
764 .is_ok());
765 }
766}