1use serde::{Deserialize, Serialize};
10
11use crate::abi::{EntityId, InstanceId, Principal, Tick, TypeCode};
12use crate::runtime::stage::StepStage;
13
14use super::signature::{SignatureClass, VerifierClass};
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TypeRegistryPin {
21 pub type_code: TypeCode,
23 pub schema_hash: [u8; 32],
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct WalHeader {
32 pub magic: [u8; 8],
34 pub kernel_semver: (u16, u16, u16),
36 pub postcard_version: u32,
38 pub blake3_version: u32,
40 pub domain_separation_context: Vec<u8>,
45 pub world_id: [u8; 32],
48 pub abi_version: (u16, u16),
50 pub manifest_digest: [u8; 32],
52 pub type_registry_pins: Vec<TypeRegistryPin>,
55 pub verifying_key: Option<[u8; 32]>,
59 pub verifying_key_pqc: Option<Vec<u8>>,
65}
66
67impl WalHeader {
68 pub const MAGIC: [u8; 8] = *b"ARKHEWAL";
70 pub const CURRENT_KERNEL_SEMVER: (u16, u16, u16) = (0, 14, 0);
72 pub const ABI_VERSION: (u16, u16) = (0, 14);
74 pub const POSTCARD_MAJOR: u32 = 1;
76 pub const BLAKE3_MAJOR: u32 = 1;
78 pub const DOMAIN_CTX: &'static [u8] = b"arkhe-kernel v0.14 WAL chain domain separation context";
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[repr(u8)]
93pub enum AuthDecisionAnnotation {
94 AllAuthorized = 0,
96 SomeDenied = 1,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct WalRecord {
103 pub seq: u64,
105 pub at: Tick,
107 pub instance: InstanceId,
109 pub principal: Principal,
111 pub actor: Option<EntityId>,
117 pub action_type_code: TypeCode,
119 pub action_bytes: Vec<u8>,
121 pub caps_bits: u64,
123 pub(crate) stage: StepStage,
124 pub auth_decision: AuthDecisionAnnotation,
126 pub prev_chain_hash: [u8; 32],
128 pub this_chain_hash: [u8; 32],
130 pub signature: Option<Vec<u8>>,
137 pub signature_pqc: Option<Vec<u8>>,
144}
145
146#[derive(Serialize)]
147struct WalRecordBody<'a> {
148 seq: u64,
149 at: Tick,
150 instance: InstanceId,
151 principal: &'a Principal,
152 actor: Option<EntityId>,
153 action_type_code: TypeCode,
154 action_bytes: &'a [u8],
155 caps_bits: u64,
156 stage: &'a StepStage,
157 auth_decision: AuthDecisionAnnotation,
158 prev_chain_hash: [u8; 32],
159}
160
161impl<'a> WalRecordBody<'a> {
162 fn from_record(rec: &'a WalRecord, prev: [u8; 32]) -> Self {
168 Self {
169 seq: rec.seq,
170 at: rec.at,
171 instance: rec.instance,
172 principal: &rec.principal,
173 actor: rec.actor,
174 action_type_code: rec.action_type_code,
175 action_bytes: &rec.action_bytes,
176 caps_bits: rec.caps_bits,
177 stage: &rec.stage,
178 auth_decision: rec.auth_decision,
179 prev_chain_hash: prev,
180 }
181 }
182}
183
184#[derive(Debug, Serialize, Deserialize)]
188pub struct Wal {
189 pub header: WalHeader,
191 pub records: Vec<WalRecord>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
199pub enum SignatureTier {
200 None,
202 Ed25519,
204 Hybrid,
206}
207
208#[derive(Debug, Clone, Default)]
222pub struct TrustAnchor {
223 pub min_tier: Option<SignatureTier>,
226 pub ed25519_verifying_key: Option<[u8; 32]>,
229 pub mldsa_verifying_key: Option<Vec<u8>>,
232 pub expected_manifest_digest: Option<[u8; 32]>,
235 pub expected_chain_tip: Option<[u8; 32]>,
238}
239
240pub struct WalWriter {
243 header: WalHeader,
244 records: Vec<WalRecord>,
245 next_seq: u64,
246 prev_hash: [u8; 32],
247 chain_key: [u8; 32],
248 sig_class: SignatureClass,
249}
250
251fn build_chain_key(world_id: &[u8; 32]) -> [u8; 32] {
252 let ctx = core::str::from_utf8(WalHeader::DOMAIN_CTX).expect("DOMAIN_CTX is valid UTF-8 ASCII");
253 blake3::derive_key(ctx, world_id)
254}
255
256fn build_dsc() -> Vec<u8> {
257 WalHeader::DOMAIN_CTX.to_vec()
258}
259
260impl WalWriter {
261 pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
263 Self::with_signature(world_id, manifest_digest, SignatureClass::None)
264 }
265
266 pub fn with_signature(
270 world_id: [u8; 32],
271 manifest_digest: [u8; 32],
272 sig_class: SignatureClass,
273 ) -> Self {
274 let chain_key = build_chain_key(&world_id);
275 let header = WalHeader {
276 magic: WalHeader::MAGIC,
277 kernel_semver: WalHeader::CURRENT_KERNEL_SEMVER,
278 postcard_version: WalHeader::POSTCARD_MAJOR,
279 blake3_version: WalHeader::BLAKE3_MAJOR,
280 domain_separation_context: build_dsc(),
281 world_id,
282 abi_version: WalHeader::ABI_VERSION,
283 manifest_digest,
284 type_registry_pins: Vec::new(),
285 verifying_key: sig_class.verifying_key_bytes(),
286 verifying_key_pqc: sig_class.verifying_key_pqc_bytes(),
287 };
288 Self {
289 header,
290 records: Vec::new(),
291 next_seq: 0,
292 prev_hash: [0u8; 32],
293 chain_key,
294 sig_class,
295 }
296 }
297
298 #[allow(clippy::too_many_arguments)]
299 pub(crate) fn append(
300 &mut self,
301 at: Tick,
302 instance: InstanceId,
303 principal: Principal,
304 actor: Option<EntityId>,
305 action_type_code: TypeCode,
306 action_bytes: Vec<u8>,
307 caps_bits: u64,
308 stage: StepStage,
309 auth_decision: AuthDecisionAnnotation,
310 ) -> Result<&WalRecord, WalError> {
311 self.next_seq = self.next_seq.saturating_add(1);
312 let body = WalRecordBody {
313 seq: self.next_seq,
314 at,
315 instance,
316 principal: &principal,
317 actor,
318 action_type_code,
319 action_bytes: &action_bytes,
320 caps_bits,
321 stage: &stage,
322 auth_decision,
323 prev_chain_hash: self.prev_hash,
324 };
325 let body_bytes = postcard::to_allocvec(&body)
326 .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
327 let mut hasher = blake3::Hasher::new_keyed(&self.chain_key);
328 hasher.update(&self.prev_hash);
329 hasher.update(&body_bytes);
330 let this_hash: [u8; 32] = *hasher.finalize().as_bytes();
331
332 let (signature, signature_pqc) = match self.sig_class.sign_hybrid(&body_bytes) {
336 Some(hyb) => (Some(hyb.ed25519.to_vec()), Some(hyb.pqc)),
337 None => (self.sig_class.sign(&body_bytes).map(|s| s.to_vec()), None),
338 };
339
340 let record = WalRecord {
341 seq: self.next_seq,
342 at,
343 instance,
344 principal,
345 actor,
346 action_type_code,
347 action_bytes,
348 caps_bits,
349 stage,
350 auth_decision,
351 prev_chain_hash: self.prev_hash,
352 this_chain_hash: this_hash,
353 signature,
354 signature_pqc,
355 };
356 self.records.push(record);
357 self.prev_hash = this_hash;
358 Ok(self.records.last().expect("just pushed"))
359 }
360
361 pub fn header(&self) -> &WalHeader {
363 &self.header
364 }
365 pub fn records(&self) -> &[WalRecord] {
367 &self.records
368 }
369 pub fn chain_tip(&self) -> [u8; 32] {
371 self.prev_hash
372 }
373 pub fn record_count(&self) -> usize {
375 self.records.len()
376 }
377}
378
379impl Wal {
380 pub fn from_writer(w: WalWriter) -> Self {
382 Self {
383 header: w.header,
384 records: w.records,
385 }
386 }
387
388 pub fn serialize(&self) -> Result<Vec<u8>, WalError> {
391 postcard::to_allocvec(self).map_err(|e| WalError::SerializeFailed(format!("{}", e)))
392 }
393
394 pub fn deserialize(bytes: &[u8]) -> Result<Self, WalError> {
396 postcard::from_bytes(bytes).map_err(|e| WalError::DeserializeFailed(format!("{}", e)))
397 }
398
399 pub fn chain_tip(&self) -> [u8; 32] {
401 self.records
402 .last()
403 .map(|r| r.this_chain_hash)
404 .unwrap_or([0u8; 32])
405 }
406
407 pub fn verify_chain(&self, world_id: [u8; 32]) -> Result<(), WalError> {
413 let chain_key = build_chain_key(&world_id);
414 let verifier = VerifierClass::from_header_bytes(
415 self.header.verifying_key.as_ref(),
416 self.header.verifying_key_pqc.as_deref(),
417 )
418 .map_err(|e| match e {
419 crate::persist::signature::VerifierInitError::InvalidEd25519Key
420 | crate::persist::signature::VerifierInitError::InvalidPqcKey => {
421 WalError::InvalidVerifyingKey
422 }
423 crate::persist::signature::VerifierInitError::PqcWithoutEd25519 => {
424 WalError::PqcWithoutEd25519
425 }
426 })?;
427 let mut prev = [0u8; 32];
428 for (i, rec) in self.records.iter().enumerate() {
429 if blake3::Hash::from(rec.prev_chain_hash) != blake3::Hash::from(prev) {
430 return Err(WalError::ChainBroken { at_record: i });
431 }
432 let body = WalRecordBody::from_record(rec, prev);
433 let body_bytes = postcard::to_allocvec(&body)
434 .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
435 let mut hasher = blake3::Hasher::new_keyed(&chain_key);
436 hasher.update(&prev);
437 hasher.update(&body_bytes);
438 let computed: [u8; 32] = *hasher.finalize().as_bytes();
439 if blake3::Hash::from(computed) != blake3::Hash::from(rec.this_chain_hash) {
440 return Err(WalError::HashMismatch { at_record: i });
441 }
442
443 match &verifier {
444 VerifierClass::None => {}
445 VerifierClass::Ed25519(_) => {
446 let sig_vec = rec
447 .signature
448 .as_ref()
449 .ok_or(WalError::MissingSignature { at_record: i })?;
450 verifier
451 .verify(&body_bytes, sig_vec)
452 .map_err(|_| WalError::SignatureMismatch { at_record: i })?;
453 }
454 VerifierClass::Hybrid { .. } => {
455 let sig_vec = rec
456 .signature
457 .as_ref()
458 .ok_or(WalError::MissingSignature { at_record: i })?;
459 let sig_pqc = rec
460 .signature_pqc
461 .as_ref()
462 .ok_or(WalError::MissingPqcSignature { at_record: i })?;
463 verifier
464 .verify_hybrid(&body_bytes, sig_vec, sig_pqc)
465 .map_err(|_| WalError::PqcSignatureMismatch { at_record: i })?;
466 }
467 }
468
469 prev = computed;
470 }
471 Ok(())
472 }
473
474 fn header_tier(&self) -> Result<SignatureTier, WalError> {
478 match (
479 self.header.verifying_key.is_some(),
480 self.header.verifying_key_pqc.is_some(),
481 ) {
482 (false, false) => Ok(SignatureTier::None),
483 (true, false) => Ok(SignatureTier::Ed25519),
484 (true, true) => Ok(SignatureTier::Hybrid),
485 (false, true) => Err(WalError::PqcWithoutEd25519),
486 }
487 }
488
489 pub fn verify_chain_anchored(
498 &self,
499 world_id: [u8; 32],
500 anchor: &TrustAnchor,
501 ) -> Result<(), WalError> {
502 let tier = self.header_tier()?;
504 if let Some(floor) = anchor.min_tier {
505 if tier < floor {
506 return Err(WalError::TierDowngrade);
507 }
508 }
509 if let Some(expected) = anchor.ed25519_verifying_key {
512 match self.header.verifying_key {
513 Some(k) if k == expected => {}
514 _ => return Err(WalError::VerifyingKeyMismatch),
515 }
516 }
517 if let Some(expected) = anchor.mldsa_verifying_key.as_deref() {
518 match self.header.verifying_key_pqc.as_deref() {
519 Some(k) if k == expected => {}
520 _ => return Err(WalError::VerifyingKeyMismatch),
521 }
522 }
523 if let Some(expected) = anchor.expected_manifest_digest {
525 if self.header.manifest_digest != expected {
526 return Err(WalError::ManifestDigestMismatch);
527 }
528 }
529 self.verify_chain(world_id)?;
531 if let Some(expected_tip) = anchor.expected_chain_tip {
533 if self.chain_tip() != expected_tip {
534 return Err(WalError::ChainTipMismatch);
535 }
536 }
537 Ok(())
538 }
539}
540
541#[derive(Debug, Clone)]
544#[non_exhaustive]
545pub enum WalError {
546 SerializeFailed(String),
548 DeserializeFailed(String),
550 ChainBroken {
553 at_record: usize,
555 },
556 HashMismatch {
559 at_record: usize,
561 },
562 HeaderIncompatible(String),
564 InvalidVerifyingKey,
566 MissingSignature {
568 at_record: usize,
570 },
571 SignatureMismatch {
573 at_record: usize,
575 },
576 MissingPqcSignature {
579 at_record: usize,
581 },
582 PqcSignatureMismatch {
585 at_record: usize,
587 },
588 PqcWithoutEd25519,
592 TierDowngrade,
596 VerifyingKeyMismatch,
599 ManifestDigestMismatch,
602 ChainTipMismatch,
605}
606
607impl core::fmt::Display for WalError {
608 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
609 match self {
610 Self::SerializeFailed(m) => write!(f, "wal serialize failed: {}", m),
611 Self::DeserializeFailed(m) => write!(f, "wal deserialize failed: {}", m),
612 Self::ChainBroken { at_record } => {
613 write!(f, "wal chain broken at record {}", at_record)
614 }
615 Self::HashMismatch { at_record } => {
616 write!(f, "wal hash mismatch at record {}", at_record)
617 }
618 Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
619 Self::InvalidVerifyingKey => write!(
620 f,
621 "wal verifying_key invalid (not a valid Ed25519 public key)"
622 ),
623 Self::MissingSignature { at_record } => {
624 write!(f, "wal signature missing at record {}", at_record)
625 }
626 Self::SignatureMismatch { at_record } => {
627 write!(f, "wal signature mismatch at record {}", at_record)
628 }
629 Self::MissingPqcSignature { at_record } => {
630 write!(f, "wal PQC signature missing at record {}", at_record)
631 }
632 Self::PqcSignatureMismatch { at_record } => {
633 write!(f, "wal PQC signature mismatch at record {}", at_record)
634 }
635 Self::PqcWithoutEd25519 => write!(
636 f,
637 "wal envelope invalid (verifying_key_pqc set without verifying_key)"
638 ),
639 Self::TierDowngrade => write!(
640 f,
641 "wal signature tier downgraded below the trust anchor's minimum"
642 ),
643 Self::VerifyingKeyMismatch => write!(
644 f,
645 "wal verifying key does not match the trust anchor's expected key"
646 ),
647 Self::ManifestDigestMismatch => {
648 write!(f, "wal manifest_digest does not match the expected digest")
649 }
650 Self::ChainTipMismatch => {
651 write!(f, "wal chain tip does not match the expected tip (tail truncation?)")
652 }
653 }
654 }
655}
656
657impl std::error::Error for WalError {}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::abi::{EntityId, ExternalId, RouteId};
663 use crate::runtime::stage::{LedgerOp, StagedStateDelta};
664 use crate::state::EntityMeta;
665
666 fn world() -> [u8; 32] {
667 [7u8; 32]
668 }
669 fn manifest() -> [u8; 32] {
670 [3u8; 32]
671 }
672
673 fn sample_stage() -> StepStage {
674 let mut s = StepStage::default();
675 s.state_ops.push(StagedStateDelta::SpawnEntity {
676 id: EntityId::new(1).unwrap(),
677 meta: EntityMeta {
678 owner: Principal::System,
679 created: Tick(0),
680 },
681 });
682 s.ledger_delta
683 .ops
684 .push(LedgerOp::AddEntity(EntityId::new(1).unwrap()));
685 s.id_counters.next_entity_advance = 1;
686 s
687 }
688
689 #[test]
690 fn empty_writer_serializes_and_deserializes() {
691 let w = WalWriter::new(world(), manifest());
692 let wal = Wal::from_writer(w);
693 let bytes = wal.serialize().unwrap();
694 let back = Wal::deserialize(&bytes).unwrap();
695 assert_eq!(back.header, wal.header);
696 assert_eq!(back.records.len(), 0);
697 assert_eq!(back.chain_tip(), [0u8; 32]);
698 }
699
700 #[test]
701 fn single_append_produces_nonzero_chain_tip() {
702 let mut w = WalWriter::new(world(), manifest());
703 w.append(
704 Tick(5),
705 InstanceId::new(1).unwrap(),
706 Principal::System,
707 None,
708 TypeCode(100),
709 vec![1, 2, 3],
710 0,
711 sample_stage(),
712 AuthDecisionAnnotation::AllAuthorized,
713 )
714 .unwrap();
715 let tip = w.chain_tip();
716 assert_ne!(tip, [0u8; 32]);
717 assert_eq!(w.record_count(), 1);
718 }
719
720 #[test]
721 fn multi_record_chain_links_each_record() {
722 let mut w = WalWriter::new(world(), manifest());
723 for i in 0..5 {
724 w.append(
725 Tick(i),
726 InstanceId::new(1).unwrap(),
727 Principal::System,
728 None,
729 TypeCode(100),
730 vec![i as u8],
731 0,
732 StepStage::default(),
733 AuthDecisionAnnotation::AllAuthorized,
734 )
735 .unwrap();
736 }
737 let wal = Wal::from_writer(w);
738 assert_eq!(wal.records.len(), 5);
739 let mut prev = [0u8; 32];
741 for rec in &wal.records {
742 assert_eq!(rec.prev_chain_hash, prev);
743 prev = rec.this_chain_hash;
744 }
745 wal.verify_chain(world()).expect("clean chain");
746 }
747
748 #[test]
749 fn tampered_record_breaks_verify_chain() {
750 let mut w = WalWriter::new(world(), manifest());
751 for i in 0..3 {
752 w.append(
753 Tick(i),
754 InstanceId::new(1).unwrap(),
755 Principal::System,
756 None,
757 TypeCode(100),
758 vec![i as u8],
759 0,
760 StepStage::default(),
761 AuthDecisionAnnotation::AllAuthorized,
762 )
763 .unwrap();
764 }
765 let mut wal = Wal::from_writer(w);
766 wal.records[1].caps_bits = 0xDEAD_BEEF;
768 let result = wal.verify_chain(world());
769 assert!(matches!(result, Err(WalError::HashMismatch { .. })));
770 }
771
772 #[test]
773 fn verify_chain_detects_broken_prev_link() {
774 let mut w = WalWriter::new(world(), manifest());
775 for i in 0..3 {
776 w.append(
777 Tick(i),
778 InstanceId::new(1).unwrap(),
779 Principal::System,
780 None,
781 TypeCode(100),
782 vec![i as u8],
783 0,
784 StepStage::default(),
785 AuthDecisionAnnotation::AllAuthorized,
786 )
787 .unwrap();
788 }
789 let mut wal = Wal::from_writer(w);
790 wal.records[1].prev_chain_hash[0] ^= 1;
795 let result = wal.verify_chain(world());
796 assert!(matches!(
797 result,
798 Err(WalError::ChainBroken { at_record: 1 })
799 ));
800 }
801
802 #[test]
803 fn different_world_id_produces_different_chain() {
804 let mut w1 = WalWriter::new([1u8; 32], manifest());
805 let mut w2 = WalWriter::new([2u8; 32], manifest());
806 for w in [&mut w1, &mut w2] {
807 w.append(
808 Tick(0),
809 InstanceId::new(1).unwrap(),
810 Principal::System,
811 None,
812 TypeCode(100),
813 vec![],
814 0,
815 StepStage::default(),
816 AuthDecisionAnnotation::AllAuthorized,
817 )
818 .unwrap();
819 }
820 assert_ne!(w1.chain_tip(), w2.chain_tip());
822 }
823
824 #[test]
825 fn verify_chain_against_wrong_world_id_fails() {
826 let mut w = WalWriter::new(world(), manifest());
827 w.append(
828 Tick(0),
829 InstanceId::new(1).unwrap(),
830 Principal::System,
831 None,
832 TypeCode(100),
833 vec![],
834 0,
835 StepStage::default(),
836 AuthDecisionAnnotation::AllAuthorized,
837 )
838 .unwrap();
839 let wal = Wal::from_writer(w);
840 let result = wal.verify_chain([99u8; 32]);
841 assert!(matches!(result, Err(WalError::HashMismatch { .. })));
842 }
843
844 #[test]
845 fn auth_decision_annotation_round_trips() {
846 let mut w = WalWriter::new(world(), manifest());
847 w.append(
848 Tick(0),
849 InstanceId::new(1).unwrap(),
850 Principal::External(ExternalId(7)),
851 None,
852 TypeCode(101),
853 vec![],
854 0,
855 StepStage::default(),
856 AuthDecisionAnnotation::SomeDenied,
857 )
858 .unwrap();
859 let wal = Wal::from_writer(w);
860 let bytes = wal.serialize().unwrap();
861 let back = Wal::deserialize(&bytes).unwrap();
862 assert_eq!(
863 back.records[0].auth_decision,
864 AuthDecisionAnnotation::SomeDenied
865 );
866 }
867
868 #[test]
869 fn header_carries_magic_and_versions() {
870 let h = WalWriter::new(world(), manifest()).header().clone();
871 assert_eq!(h.magic, *b"ARKHEWAL");
872 assert_eq!(h.kernel_semver, (0, 14, 0));
873 assert_eq!(h.world_id, world());
874 assert_eq!(h.manifest_digest, manifest());
875 assert!(h.type_registry_pins.is_empty());
876 assert!(h.verifying_key.is_none());
877 let _ = RouteId(1);
878 }
879
880 fn append_one(w: &mut WalWriter) {
883 w.append(
884 Tick(0),
885 InstanceId::new(1).unwrap(),
886 Principal::System,
887 None,
888 TypeCode(100),
889 vec![1, 2, 3],
890 0,
891 sample_stage(),
892 AuthDecisionAnnotation::AllAuthorized,
893 )
894 .unwrap();
895 }
896
897 #[test]
898 fn signature_class_none_produces_no_signature() {
899 let mut w = WalWriter::new(world(), manifest());
900 append_one(&mut w);
901 let wal = Wal::from_writer(w);
902 assert!(wal.header.verifying_key.is_none());
903 assert!(wal.records[0].signature.is_none());
904 wal.verify_chain(world())
905 .expect("Tier 1 chain still verifies");
906 }
907
908 #[test]
909 fn signature_class_ed25519_signs_each_record() {
910 let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
911 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
912 for _ in 0..3 {
913 append_one(&mut w);
914 }
915 let wal = Wal::from_writer(w);
916 assert!(wal.header.verifying_key.is_some());
917 assert_eq!(wal.records.len(), 3);
918 for rec in &wal.records {
919 let sig = rec.signature.as_ref().expect("Ed25519 signs every record");
920 assert_eq!(sig.len(), 64);
921 }
922 }
923
924 #[test]
925 fn verify_chain_validates_signatures() {
926 let sig_class = SignatureClass::new_ed25519_from_secret([11u8; 32]);
927 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
928 for _ in 0..3 {
929 append_one(&mut w);
930 }
931 let wal = Wal::from_writer(w);
932 let bytes = wal.serialize().unwrap();
934 let back = Wal::deserialize(&bytes).unwrap();
935 back.verify_chain(world()).expect("signed chain verifies");
936 }
937
938 #[test]
939 fn tampered_signature_fails_verify() {
940 let sig_class = SignatureClass::new_ed25519_from_secret([13u8; 32]);
943 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
944 append_one(&mut w);
945 append_one(&mut w);
946 let mut wal = Wal::from_writer(w);
947 if let Some(sig) = wal.records[1].signature.as_mut() {
949 sig[0] ^= 0xFF;
950 }
951 let result = wal.verify_chain(world());
952 assert!(matches!(
953 result,
954 Err(WalError::SignatureMismatch { at_record: 1 })
955 ));
956 }
957
958 #[test]
959 fn missing_signature_fails_verify_when_header_has_key() {
960 let sig_class = SignatureClass::new_ed25519_from_secret([17u8; 32]);
961 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
962 append_one(&mut w);
963 let mut wal = Wal::from_writer(w);
964 wal.records[0].signature = None;
966 let result = wal.verify_chain(world());
967 assert!(matches!(
968 result,
969 Err(WalError::MissingSignature { at_record: 0 })
970 ));
971 }
972
973 #[test]
974 fn wrong_key_fails_verify() {
975 let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
976 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
977 append_one(&mut w);
978 let mut wal = Wal::from_writer(w);
979 let other = SignatureClass::new_ed25519_from_secret([23u8; 32])
982 .verifying_key_bytes()
983 .unwrap();
984 wal.header.verifying_key = Some(other);
985 let result = wal.verify_chain(world());
986 assert!(matches!(
987 result,
988 Err(WalError::SignatureMismatch { at_record: 0 })
989 ));
990 }
991
992 #[test]
993 fn signature_deterministic_across_runs() {
994 let mk = |secret: [u8; 32]| -> Vec<Vec<u8>> {
998 let mut w = WalWriter::with_signature(
999 world(),
1000 manifest(),
1001 SignatureClass::new_ed25519_from_secret(secret),
1002 );
1003 append_one(&mut w);
1004 append_one(&mut w);
1005 let wal = Wal::from_writer(w);
1006 wal.records
1007 .iter()
1008 .map(|r| r.signature.clone().unwrap())
1009 .collect()
1010 };
1011 let sigs1 = mk([29u8; 32]);
1012 let sigs2 = mk([29u8; 32]);
1013 assert_eq!(sigs1, sigs2);
1014 assert_eq!(sigs1[0].len(), 64);
1015 }
1016
1017 #[test]
1018 fn domain_ctx_byte_identity_blake3() {
1019 const EXPECTED: &[u8] = b"arkhe-kernel v0.14 WAL chain domain separation context";
1036 assert_eq!(WalHeader::DOMAIN_CTX, EXPECTED);
1037 assert_eq!(WalHeader::DOMAIN_CTX.len(), 54);
1038
1039 const FROZEN_HEX: &str = "4c20c496b74ba868f2669cfcc1a8b6f4c2e0f122bdba5e0337eaeebe599a1fda";
1041 let actual_hex = blake3::hash(WalHeader::DOMAIN_CTX).to_hex();
1042 assert_eq!(
1043 actual_hex.as_str(),
1044 FROZEN_HEX,
1045 "DOMAIN_CTX BLAKE3 hash regression — byte-level edit detected",
1046 );
1047 }
1048
1049 #[test]
1050 fn wal_record_persists_actor_for_replay_determinism() {
1051 let mut w = WalWriter::new(world(), manifest());
1055 let actor = Some(EntityId::new(42).unwrap());
1056 w.append(
1057 Tick(0),
1058 InstanceId::new(1).unwrap(),
1059 Principal::System,
1060 actor,
1061 TypeCode(100),
1062 vec![1, 2, 3],
1063 0,
1064 sample_stage(),
1065 AuthDecisionAnnotation::AllAuthorized,
1066 )
1067 .unwrap();
1068 let wal = Wal::from_writer(w);
1069 assert_eq!(wal.records[0].actor, actor);
1070 assert!(wal.verify_chain(world()).is_ok());
1071 }
1072
1073 #[test]
1074 fn verify_chain_anchored_rejects_downgrade_and_substitution() {
1075 let sig = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1078 let vk = sig.verifying_key_bytes().unwrap();
1079 let mut w = WalWriter::with_signature(world(), manifest(), sig);
1080 append_one(&mut w);
1081 let wal = Wal::from_writer(w);
1082
1083 let good = TrustAnchor {
1085 min_tier: Some(SignatureTier::Ed25519),
1086 ed25519_verifying_key: Some(vk),
1087 ..Default::default()
1088 };
1089 assert!(wal.verify_chain_anchored(world(), &good).is_ok());
1090
1091 let downgrade = TrustAnchor {
1093 min_tier: Some(SignatureTier::Hybrid),
1094 ..Default::default()
1095 };
1096 assert!(matches!(
1097 wal.verify_chain_anchored(world(), &downgrade),
1098 Err(WalError::TierDowngrade)
1099 ));
1100
1101 let wrong_key = TrustAnchor {
1103 min_tier: Some(SignatureTier::Ed25519),
1104 ed25519_verifying_key: Some([0xAB; 32]),
1105 ..Default::default()
1106 };
1107 assert!(matches!(
1108 wal.verify_chain_anchored(world(), &wrong_key),
1109 Err(WalError::VerifyingKeyMismatch)
1110 ));
1111 }
1112
1113 #[test]
1116 fn wal_record_postcard_layout_byte_identity() {
1117 let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1122 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1123 append_one(&mut w);
1124 let wal = Wal::from_writer(w);
1125 let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1126 const FROZEN_HEX: &str = "170607201a4352833ebfe81a1546b4937cdafd587a3e266408b6c1a6bea16709";
1127 let actual = blake3::hash(&encoded);
1128 assert_eq!(
1129 actual.to_hex().as_str(),
1130 FROZEN_HEX,
1131 "WalRecord postcard byte sequence regression",
1132 );
1133 }
1134
1135 #[test]
1136 fn wal_record_hybrid_layout_byte_identity() {
1137 let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
1142 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1143 append_one(&mut w);
1144 let mut wal = Wal::from_writer(w);
1145 let baseline_encoded = postcard::to_allocvec(&wal.records[0]).expect("baseline encode");
1147 wal.records[0].signature_pqc = Some(vec![0xAB; 3309]);
1149 let with_pqc_encoded = postcard::to_allocvec(&wal.records[0]).expect("with_pqc encode");
1150 assert_eq!(
1155 with_pqc_encoded.len() - baseline_encoded.len(),
1156 3311,
1157 "PQC signature envelope size mismatch — ML-DSA 65 must fit",
1158 );
1159 }
1160
1161 #[test]
1162 fn wal_header_verifying_key_pqc_slot_pinned() {
1163 let h = WalWriter::new(world(), manifest()).header().clone();
1168 assert!(h.verifying_key.is_none());
1170 assert!(h.verifying_key_pqc.is_none());
1171
1172 let mut h_pqc = h.clone();
1173 h_pqc.verifying_key_pqc = Some(vec![0xCD; 1952]);
1175 let baseline = postcard::to_allocvec(&h).expect("encode baseline");
1176 let with_pqc = postcard::to_allocvec(&h_pqc).expect("encode with pqc key");
1177 assert_eq!(
1182 with_pqc.len() - baseline.len(),
1183 1954,
1184 "PQC verifying-key envelope size mismatch — ML-DSA 65 must fit",
1185 );
1186 }
1187
1188 #[test]
1189 fn chain_hash_unchanged_for_ed25519_records() {
1190 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1196 w.append(
1197 Tick(0),
1198 InstanceId::new(1).unwrap(),
1199 Principal::System,
1200 None,
1201 TypeCode(100),
1202 vec![1, 2, 3],
1203 0,
1204 sample_stage(),
1205 AuthDecisionAnnotation::AllAuthorized,
1206 )
1207 .unwrap();
1208 let wal = Wal::from_writer(w);
1209 const FROZEN_HEX: &str = "4bb26e665e904314600b52b0d3bef34ee476de1c155f701fa95c8f5438140319";
1210 let actual_hex = blake3::Hash::from(wal.records[0].this_chain_hash).to_hex();
1211 assert_eq!(
1212 actual_hex.as_str(),
1213 FROZEN_HEX,
1214 "chain hash regression — DOMAIN_CTX or WalRecordBody field order changed",
1215 );
1216 }
1217
1218 #[test]
1219 fn wal_record_postcard_field_order_baseline() {
1220 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1225 w.append(
1226 Tick(42),
1227 InstanceId::new(99).unwrap(),
1228 Principal::System,
1229 None,
1230 TypeCode(0xCAFE),
1231 vec![0xAA, 0xBB, 0xCC],
1232 0xFF,
1233 sample_stage(),
1234 AuthDecisionAnnotation::AllAuthorized,
1235 )
1236 .unwrap();
1237 let wal = Wal::from_writer(w);
1238 let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1239 const FROZEN_HEX: &str = "ca468fac6ac1c0742c3b48e6398290630015411da040cf22c0228012b7e71cfa";
1240 let actual = blake3::hash(&encoded);
1241 assert_eq!(
1242 actual.to_hex().as_str(),
1243 FROZEN_HEX,
1244 "WalRecord postcard field order regression",
1245 );
1246 }
1247
1248 #[test]
1251 fn hybrid_writer_emits_both_signatures() {
1252 let sig_class = SignatureClass::new_hybrid_from_secrets([7u8; 32], [11u8; 32]);
1257 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1258 for _ in 0..3 {
1259 append_one(&mut w);
1260 }
1261 let wal = Wal::from_writer(w);
1262 assert_eq!(
1263 wal.header
1264 .verifying_key
1265 .expect("Hybrid pins Ed25519 vk")
1266 .len(),
1267 32
1268 );
1269 assert_eq!(
1270 wal.header
1271 .verifying_key_pqc
1272 .as_ref()
1273 .expect("Hybrid pins PQC vk")
1274 .len(),
1275 1952
1276 );
1277 assert_eq!(wal.records.len(), 3);
1278 for rec in &wal.records {
1279 assert_eq!(
1280 rec.signature
1281 .as_ref()
1282 .expect("Hybrid signs Ed25519 every record")
1283 .len(),
1284 64
1285 );
1286 assert_eq!(
1287 rec.signature_pqc
1288 .as_ref()
1289 .expect("Hybrid signs PQC every record")
1290 .len(),
1291 3309
1292 );
1293 }
1294 }
1295
1296 #[test]
1297 fn hybrid_verify_chain_and_mode_passes_with_both_valid() {
1298 let sig_class = SignatureClass::new_hybrid_from_secrets([13u8; 32], [17u8; 32]);
1302 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1303 for _ in 0..3 {
1304 append_one(&mut w);
1305 }
1306 let wal = Wal::from_writer(w);
1307 let bytes = wal.serialize().unwrap();
1308 let back = Wal::deserialize(&bytes).unwrap();
1309 back.verify_chain(world())
1310 .expect("Hybrid signed chain verifies (AND-mode pass)");
1311 }
1312
1313 #[test]
1314 fn hybrid_verify_chain_rejects_missing_pqc() {
1315 let sig_class = SignatureClass::new_hybrid_from_secrets([19u8; 32], [23u8; 32]);
1318 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1319 append_one(&mut w);
1320 let mut wal = Wal::from_writer(w);
1321 wal.records[0].signature_pqc = None;
1323 let result = wal.verify_chain(world());
1324 assert!(matches!(
1325 result,
1326 Err(WalError::MissingPqcSignature { at_record: 0 })
1327 ));
1328 }
1329
1330 #[test]
1331 fn hybrid_verify_chain_rejects_corrupt_pqc_signature() {
1332 let sig_class = SignatureClass::new_hybrid_from_secrets([29u8; 32], [31u8; 32]);
1336 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1337 append_one(&mut w);
1338 append_one(&mut w);
1339 let mut wal = Wal::from_writer(w);
1340 if let Some(sig_pqc) = wal.records[1].signature_pqc.as_mut() {
1342 sig_pqc[0] ^= 0xFF;
1343 }
1344 let result = wal.verify_chain(world());
1345 assert!(matches!(
1346 result,
1347 Err(WalError::PqcSignatureMismatch { at_record: 1 })
1348 ));
1349 }
1350
1351 #[test]
1352 fn hybrid_verify_chain_rejects_corrupt_ed25519_when_pqc_valid() {
1353 let sig_class = SignatureClass::new_hybrid_from_secrets([37u8; 32], [41u8; 32]);
1359 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1360 append_one(&mut w);
1361 append_one(&mut w);
1362 let mut wal = Wal::from_writer(w);
1363 if let Some(sig) = wal.records[0].signature.as_mut() {
1365 sig[0] ^= 0xFF;
1366 }
1367 let result = wal.verify_chain(world());
1368 assert!(matches!(
1369 result,
1370 Err(WalError::PqcSignatureMismatch { at_record: 0 })
1371 ));
1372 }
1373
1374 #[test]
1375 fn ed25519_only_wal_replays_under_hybrid_kernel() {
1376 let sig_class = SignatureClass::new_ed25519_from_secret([43u8; 32]);
1382 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1383 for _ in 0..3 {
1384 append_one(&mut w);
1385 }
1386 let wal = Wal::from_writer(w);
1387 assert!(wal.header.verifying_key.is_some());
1390 assert!(wal.header.verifying_key_pqc.is_none());
1391 for rec in &wal.records {
1392 assert!(rec.signature.is_some());
1393 assert!(rec.signature_pqc.is_none());
1394 }
1395 let bytes = wal.serialize().unwrap();
1396 let back = Wal::deserialize(&bytes).unwrap();
1397 back.verify_chain(world())
1398 .expect("Ed25519-only WAL replays under Hybrid-capable kernel");
1399 }
1400
1401 #[test]
1402 fn pqc_without_ed25519_envelope_rejected() {
1403 let sig_class = SignatureClass::new_hybrid_from_secrets([47u8; 32], [53u8; 32]);
1410 let w = WalWriter::with_signature(world(), manifest(), sig_class);
1411 let mut wal = Wal::from_writer(w);
1412 wal.header.verifying_key = None;
1415 let result = wal.verify_chain(world());
1416 assert!(matches!(result, Err(WalError::PqcWithoutEd25519)));
1417 }
1418}