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