1use serde::{Deserialize, Serialize};
10
11use crate::abi::{EntityId, InstanceId, Principal, Tick, TypeCode};
12use crate::state::ScheduledActionId;
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, 15, 0);
72 pub const ABI_VERSION: (u16, u16) = (0, 15);
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.15 WAL chain domain separation context";
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum WalRecordKind {
94 Submit,
96 Step,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum StepVerdict {
104 Committed,
106 AuthDenied,
108 BudgetPartial {
111 denied: u32,
113 },
114 Skipped {
117 reason: SkipReason,
119 },
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub enum SkipReason {
125 Unregistered,
127 DeserFailed,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137pub enum WalRecordContent {
138 Submit {
142 seq: u64,
144 instance: InstanceId,
146 principal: Principal,
148 actor: Option<EntityId>,
151 caps_at_submit: u64,
154 at: Tick,
156 action_type_code: TypeCode,
158 action_bytes: Vec<u8>,
160 allocated_id: ScheduledActionId,
163 },
164 Step {
168 seq: u64,
170 instance: InstanceId,
172 popped_id: ScheduledActionId,
174 now: Tick,
176 session_caps: u64,
182 verdict: StepVerdict,
184 post_state_digest: [u8; 32],
187 },
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct WalRecord {
195 pub content: WalRecordContent,
197 pub prev_chain_hash: [u8; 32],
199 pub this_chain_hash: [u8; 32],
201 pub signature: Option<Vec<u8>>,
205 pub signature_pqc: Option<Vec<u8>>,
208}
209
210impl WalRecord {
211 pub fn seq(&self) -> u64 {
213 match &self.content {
214 WalRecordContent::Submit { seq, .. } | WalRecordContent::Step { seq, .. } => *seq,
215 }
216 }
217
218 pub fn kind(&self) -> WalRecordKind {
220 match &self.content {
221 WalRecordContent::Submit { .. } => WalRecordKind::Submit,
222 WalRecordContent::Step { .. } => WalRecordKind::Step,
223 }
224 }
225}
226
227#[derive(Serialize)]
232struct WalRecordBody<'a> {
233 content: WalRecordBodyContent<'a>,
234 prev_chain_hash: [u8; 32],
235}
236
237#[derive(Serialize)]
238enum WalRecordBodyContent<'a> {
239 Submit {
240 seq: u64,
241 instance: InstanceId,
242 principal: &'a Principal,
243 actor: Option<EntityId>,
244 caps_at_submit: u64,
245 at: Tick,
246 action_type_code: TypeCode,
247 action_bytes: &'a [u8],
248 allocated_id: ScheduledActionId,
249 },
250 Step {
251 seq: u64,
252 instance: InstanceId,
253 popped_id: ScheduledActionId,
254 now: Tick,
255 session_caps: u64,
256 verdict: StepVerdict,
257 post_state_digest: [u8; 32],
258 },
259}
260
261impl<'a> WalRecordBody<'a> {
262 fn from_record(rec: &'a WalRecord, prev: [u8; 32]) -> Self {
265 Self::from_content(&rec.content, prev)
266 }
267
268 fn from_content(content_ref: &'a WalRecordContent, prev: [u8; 32]) -> Self {
272 let content = match content_ref {
273 WalRecordContent::Submit {
274 seq,
275 instance,
276 principal,
277 actor,
278 caps_at_submit,
279 at,
280 action_type_code,
281 action_bytes,
282 allocated_id,
283 } => WalRecordBodyContent::Submit {
284 seq: *seq,
285 instance: *instance,
286 principal,
287 actor: *actor,
288 caps_at_submit: *caps_at_submit,
289 at: *at,
290 action_type_code: *action_type_code,
291 action_bytes,
292 allocated_id: *allocated_id,
293 },
294 WalRecordContent::Step {
295 seq,
296 instance,
297 popped_id,
298 now,
299 session_caps,
300 verdict,
301 post_state_digest,
302 } => WalRecordBodyContent::Step {
303 seq: *seq,
304 instance: *instance,
305 popped_id: *popped_id,
306 now: *now,
307 session_caps: *session_caps,
308 verdict: *verdict,
309 post_state_digest: *post_state_digest,
310 },
311 };
312 Self {
313 content,
314 prev_chain_hash: prev,
315 }
316 }
317}
318
319#[derive(Debug, Serialize, Deserialize)]
323pub struct Wal {
324 pub header: WalHeader,
326 pub records: Vec<WalRecord>,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
334pub enum SignatureTier {
335 None,
337 Ed25519,
339 Hybrid,
341}
342
343#[derive(Debug, Clone, Default)]
357pub struct TrustAnchor {
358 pub min_tier: Option<SignatureTier>,
361 pub ed25519_verifying_key: Option<[u8; 32]>,
364 pub mldsa_verifying_key: Option<Vec<u8>>,
367 pub expected_manifest_digest: Option<[u8; 32]>,
370 pub expected_chain_tip: Option<[u8; 32]>,
373}
374
375pub struct WalWriter {
378 header: WalHeader,
379 records: Vec<WalRecord>,
380 next_seq: u64,
381 prev_hash: [u8; 32],
382 chain_key: [u8; 32],
383 sig_class: SignatureClass,
384}
385
386type SealedRecordParts = ([u8; 32], Option<Vec<u8>>, Option<Vec<u8>>);
389
390fn build_chain_key(world_id: &[u8; 32]) -> [u8; 32] {
391 let ctx = core::str::from_utf8(WalHeader::DOMAIN_CTX).expect("DOMAIN_CTX is valid UTF-8 ASCII");
392 blake3::derive_key(ctx, world_id)
393}
394
395fn build_dsc() -> Vec<u8> {
396 WalHeader::DOMAIN_CTX.to_vec()
397}
398
399impl WalWriter {
400 pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
402 Self::with_signature(world_id, manifest_digest, SignatureClass::None)
403 }
404
405 pub fn with_signature(
409 world_id: [u8; 32],
410 manifest_digest: [u8; 32],
411 sig_class: SignatureClass,
412 ) -> Self {
413 let chain_key = build_chain_key(&world_id);
414 let header = WalHeader {
415 magic: WalHeader::MAGIC,
416 kernel_semver: WalHeader::CURRENT_KERNEL_SEMVER,
417 postcard_version: WalHeader::POSTCARD_MAJOR,
418 blake3_version: WalHeader::BLAKE3_MAJOR,
419 domain_separation_context: build_dsc(),
420 world_id,
421 abi_version: WalHeader::ABI_VERSION,
422 manifest_digest,
423 type_registry_pins: Vec::new(),
424 verifying_key: sig_class.verifying_key_bytes(),
425 verifying_key_pqc: sig_class.verifying_key_pqc_bytes(),
426 };
427 Self {
428 header,
429 records: Vec::new(),
430 next_seq: 0,
431 prev_hash: [0u8; 32],
432 chain_key,
433 sig_class,
434 }
435 }
436
437 pub(crate) fn rebuild_from_header(header: &WalHeader) -> Self {
443 let chain_key = build_chain_key(&header.world_id);
444 Self {
445 header: header.clone(),
446 records: Vec::new(),
447 next_seq: 0,
448 prev_hash: [0u8; 32],
449 chain_key,
450 sig_class: SignatureClass::None,
451 }
452 }
453
454 fn seal(&self, body: &WalRecordBody) -> Result<SealedRecordParts, WalError> {
458 let body_bytes =
459 postcard::to_allocvec(body).map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
460 let mut hasher = blake3::Hasher::new_keyed(&self.chain_key);
461 hasher.update(&self.prev_hash);
462 hasher.update(&body_bytes);
463 let this_hash: [u8; 32] = *hasher.finalize().as_bytes();
464 let (signature, signature_pqc) = match self.sig_class.sign_hybrid(&body_bytes) {
467 Some(hyb) => (Some(hyb.ed25519.to_vec()), Some(hyb.pqc)),
468 None => (self.sig_class.sign(&body_bytes).map(|s| s.to_vec()), None),
469 };
470 Ok((this_hash, signature, signature_pqc))
471 }
472
473 fn push_sealed(&mut self, content: WalRecordContent) -> Result<&WalRecord, WalError> {
474 let prev = self.prev_hash;
475 let (this_hash, signature, signature_pqc) = {
478 let body = WalRecordBody::from_content(&content, prev);
479 self.seal(&body)?
480 };
481 let record = WalRecord {
482 content,
483 prev_chain_hash: prev,
484 this_chain_hash: this_hash,
485 signature,
486 signature_pqc,
487 };
488 self.records.push(record);
489 self.prev_hash = this_hash;
490 Ok(self.records.last().expect("just pushed"))
491 }
492
493 #[allow(clippy::too_many_arguments)]
496 pub(crate) fn append_submit(
497 &mut self,
498 instance: InstanceId,
499 principal: Principal,
500 actor: Option<EntityId>,
501 caps_at_submit: u64,
502 at: Tick,
503 action_type_code: TypeCode,
504 action_bytes: Vec<u8>,
505 allocated_id: ScheduledActionId,
506 ) -> Result<&WalRecord, WalError> {
507 self.next_seq = self.next_seq.saturating_add(1);
508 let seq = self.next_seq;
509 let content = WalRecordContent::Submit {
510 seq,
511 instance,
512 principal,
513 actor,
514 caps_at_submit,
515 at,
516 action_type_code,
517 action_bytes,
518 allocated_id,
519 };
520 self.push_sealed(content)
521 }
522
523 pub(crate) fn append_step(
526 &mut self,
527 instance: InstanceId,
528 popped_id: ScheduledActionId,
529 now: Tick,
530 session_caps: u64,
531 verdict: StepVerdict,
532 post_state_digest: [u8; 32],
533 ) -> Result<&WalRecord, WalError> {
534 self.next_seq = self.next_seq.saturating_add(1);
535 let seq = self.next_seq;
536 let content = WalRecordContent::Step {
537 seq,
538 instance,
539 popped_id,
540 now,
541 session_caps,
542 verdict,
543 post_state_digest,
544 };
545 self.push_sealed(content)
546 }
547
548 pub fn header(&self) -> &WalHeader {
550 &self.header
551 }
552 pub fn records(&self) -> &[WalRecord] {
554 &self.records
555 }
556 pub fn chain_tip(&self) -> [u8; 32] {
558 self.prev_hash
559 }
560 pub fn record_count(&self) -> usize {
562 self.records.len()
563 }
564}
565
566impl Wal {
567 pub fn from_writer(w: WalWriter) -> Self {
569 Self {
570 header: w.header,
571 records: w.records,
572 }
573 }
574
575 pub fn serialize(&self) -> Result<Vec<u8>, WalError> {
578 postcard::to_allocvec(self).map_err(|e| WalError::SerializeFailed(format!("{}", e)))
579 }
580
581 pub fn deserialize(bytes: &[u8]) -> Result<Self, WalError> {
583 postcard::from_bytes(bytes).map_err(|e| WalError::DeserializeFailed(format!("{}", e)))
584 }
585
586 pub fn chain_tip(&self) -> [u8; 32] {
588 self.records
589 .last()
590 .map(|r| r.this_chain_hash)
591 .unwrap_or([0u8; 32])
592 }
593
594 pub fn verify_chain(&self, world_id: [u8; 32]) -> Result<(), WalError> {
600 let chain_key = build_chain_key(&world_id);
601 let verifier = VerifierClass::from_header_bytes(
602 self.header.verifying_key.as_ref(),
603 self.header.verifying_key_pqc.as_deref(),
604 )
605 .map_err(|e| match e {
606 crate::persist::signature::VerifierInitError::InvalidEd25519Key
607 | crate::persist::signature::VerifierInitError::InvalidPqcKey => {
608 WalError::InvalidVerifyingKey
609 }
610 crate::persist::signature::VerifierInitError::PqcWithoutEd25519 => {
611 WalError::PqcWithoutEd25519
612 }
613 })?;
614 let mut prev = [0u8; 32];
615 for (i, rec) in self.records.iter().enumerate() {
616 if blake3::Hash::from(rec.prev_chain_hash) != blake3::Hash::from(prev) {
617 return Err(WalError::ChainBroken { at_record: i });
618 }
619 let body = WalRecordBody::from_record(rec, prev);
620 let body_bytes = postcard::to_allocvec(&body)
621 .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
622 let mut hasher = blake3::Hasher::new_keyed(&chain_key);
623 hasher.update(&prev);
624 hasher.update(&body_bytes);
625 let computed: [u8; 32] = *hasher.finalize().as_bytes();
626 if blake3::Hash::from(computed) != blake3::Hash::from(rec.this_chain_hash) {
627 return Err(WalError::HashMismatch { at_record: i });
628 }
629
630 match &verifier {
631 VerifierClass::None => {}
632 VerifierClass::Ed25519(_) => {
633 let sig_vec = rec
634 .signature
635 .as_ref()
636 .ok_or(WalError::MissingSignature { at_record: i })?;
637 verifier
638 .verify(&body_bytes, sig_vec)
639 .map_err(|_| WalError::SignatureMismatch { at_record: i })?;
640 }
641 VerifierClass::Hybrid { .. } => {
642 let sig_vec = rec
643 .signature
644 .as_ref()
645 .ok_or(WalError::MissingSignature { at_record: i })?;
646 let sig_pqc = rec
647 .signature_pqc
648 .as_ref()
649 .ok_or(WalError::MissingPqcSignature { at_record: i })?;
650 verifier
651 .verify_hybrid(&body_bytes, sig_vec, sig_pqc)
652 .map_err(|_| WalError::PqcSignatureMismatch { at_record: i })?;
653 }
654 }
655
656 prev = computed;
657 }
658 Ok(())
659 }
660
661 fn header_tier(&self) -> Result<SignatureTier, WalError> {
665 match (
666 self.header.verifying_key.is_some(),
667 self.header.verifying_key_pqc.is_some(),
668 ) {
669 (false, false) => Ok(SignatureTier::None),
670 (true, false) => Ok(SignatureTier::Ed25519),
671 (true, true) => Ok(SignatureTier::Hybrid),
672 (false, true) => Err(WalError::PqcWithoutEd25519),
673 }
674 }
675
676 pub fn verify_chain_anchored(
685 &self,
686 world_id: [u8; 32],
687 anchor: &TrustAnchor,
688 ) -> Result<(), WalError> {
689 let tier = self.header_tier()?;
691 if let Some(floor) = anchor.min_tier {
692 if tier < floor {
693 return Err(WalError::TierDowngrade);
694 }
695 }
696 if let Some(expected) = anchor.ed25519_verifying_key {
699 match self.header.verifying_key {
700 Some(k) if k == expected => {}
701 _ => return Err(WalError::VerifyingKeyMismatch),
702 }
703 }
704 if let Some(expected) = anchor.mldsa_verifying_key.as_deref() {
705 match self.header.verifying_key_pqc.as_deref() {
706 Some(k) if k == expected => {}
707 _ => return Err(WalError::VerifyingKeyMismatch),
708 }
709 }
710 if let Some(expected) = anchor.expected_manifest_digest {
712 if self.header.manifest_digest != expected {
713 return Err(WalError::ManifestDigestMismatch);
714 }
715 }
716 self.verify_chain(world_id)?;
718 if let Some(expected_tip) = anchor.expected_chain_tip {
720 if self.chain_tip() != expected_tip {
721 return Err(WalError::ChainTipMismatch);
722 }
723 }
724 Ok(())
725 }
726}
727
728#[derive(Debug, Clone)]
731#[non_exhaustive]
732pub enum WalError {
733 SerializeFailed(String),
735 DeserializeFailed(String),
737 ChainBroken {
740 at_record: usize,
742 },
743 HashMismatch {
746 at_record: usize,
748 },
749 HeaderIncompatible(String),
751 InvalidVerifyingKey,
753 MissingSignature {
755 at_record: usize,
757 },
758 SignatureMismatch {
760 at_record: usize,
762 },
763 MissingPqcSignature {
766 at_record: usize,
768 },
769 PqcSignatureMismatch {
772 at_record: usize,
774 },
775 PqcWithoutEd25519,
779 TierDowngrade,
783 VerifyingKeyMismatch,
786 ManifestDigestMismatch,
789 ChainTipMismatch,
792}
793
794impl core::fmt::Display for WalError {
795 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
796 match self {
797 Self::SerializeFailed(m) => write!(f, "wal serialize failed: {}", m),
798 Self::DeserializeFailed(m) => write!(f, "wal deserialize failed: {}", m),
799 Self::ChainBroken { at_record } => {
800 write!(f, "wal chain broken at record {}", at_record)
801 }
802 Self::HashMismatch { at_record } => {
803 write!(f, "wal hash mismatch at record {}", at_record)
804 }
805 Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
806 Self::InvalidVerifyingKey => write!(
807 f,
808 "wal verifying_key invalid (not a valid Ed25519 public key)"
809 ),
810 Self::MissingSignature { at_record } => {
811 write!(f, "wal signature missing at record {}", at_record)
812 }
813 Self::SignatureMismatch { at_record } => {
814 write!(f, "wal signature mismatch at record {}", at_record)
815 }
816 Self::MissingPqcSignature { at_record } => {
817 write!(f, "wal PQC signature missing at record {}", at_record)
818 }
819 Self::PqcSignatureMismatch { at_record } => {
820 write!(f, "wal PQC signature mismatch at record {}", at_record)
821 }
822 Self::PqcWithoutEd25519 => write!(
823 f,
824 "wal envelope invalid (verifying_key_pqc set without verifying_key)"
825 ),
826 Self::TierDowngrade => write!(
827 f,
828 "wal signature tier downgraded below the trust anchor's minimum"
829 ),
830 Self::VerifyingKeyMismatch => write!(
831 f,
832 "wal verifying key does not match the trust anchor's expected key"
833 ),
834 Self::ManifestDigestMismatch => {
835 write!(f, "wal manifest_digest does not match the expected digest")
836 }
837 Self::ChainTipMismatch => {
838 write!(f, "wal chain tip does not match the expected tip (tail truncation?)")
839 }
840 }
841 }
842}
843
844impl std::error::Error for WalError {}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::abi::{EntityId, ExternalId, InstanceId, Principal, RouteId, Tick, TypeCode};
850 use crate::state::ScheduledActionId;
851
852 fn world() -> [u8; 32] {
853 [7u8; 32]
854 }
855 fn manifest() -> [u8; 32] {
856 [3u8; 32]
857 }
858 fn sid(n: u64) -> ScheduledActionId {
859 ScheduledActionId::new(n).unwrap()
860 }
861
862 fn submit_one(w: &mut WalWriter) {
864 w.append_submit(
865 InstanceId::new(1).unwrap(),
866 Principal::System,
867 None,
868 0xFF,
869 Tick(0),
870 TypeCode(100),
871 vec![1, 2, 3],
872 sid(1),
873 )
874 .unwrap();
875 }
876
877 fn step_n(w: &mut WalWriter, n: u8) {
880 w.append_step(
881 InstanceId::new(1).unwrap(),
882 sid(1),
883 Tick(n as u64),
884 0xFF,
885 StepVerdict::Committed,
886 [n; 32],
887 )
888 .unwrap();
889 }
890
891 fn append_one(w: &mut WalWriter) {
893 step_n(w, 1);
894 }
895
896 #[test]
897 fn empty_writer_serializes_and_deserializes() {
898 let w = WalWriter::new(world(), manifest());
899 let wal = Wal::from_writer(w);
900 let bytes = wal.serialize().unwrap();
901 let back = Wal::deserialize(&bytes).unwrap();
902 assert_eq!(back.header, wal.header);
903 assert_eq!(back.records.len(), 0);
904 assert_eq!(back.chain_tip(), [0u8; 32]);
905 }
906
907 #[test]
908 fn single_step_append_produces_nonzero_chain_tip() {
909 let mut w = WalWriter::new(world(), manifest());
910 append_one(&mut w);
911 let tip = w.chain_tip();
912 assert_ne!(tip, [0u8; 32]);
913 assert_eq!(w.record_count(), 1);
914 }
915
916 #[test]
917 fn multi_record_chain_links_each_record() {
918 let mut w = WalWriter::new(world(), manifest());
919 for i in 0..5 {
920 step_n(&mut w, i);
921 }
922 let wal = Wal::from_writer(w);
923 assert_eq!(wal.records.len(), 5);
924 let mut prev = [0u8; 32];
925 for rec in &wal.records {
926 assert_eq!(rec.prev_chain_hash, prev);
927 prev = rec.this_chain_hash;
928 }
929 wal.verify_chain(world()).expect("clean chain");
930 }
931
932 #[test]
933 fn submit_and_step_chain_verifies() {
934 let mut w = WalWriter::new(world(), manifest());
937 submit_one(&mut w);
938 step_n(&mut w, 2);
939 let wal = Wal::from_writer(w);
940 assert_eq!(wal.records.len(), 2);
941 assert!(matches!(wal.records[0].kind(), WalRecordKind::Submit));
942 assert!(matches!(wal.records[1].kind(), WalRecordKind::Step));
943 assert_eq!(wal.records[0].seq(), 1);
944 assert_eq!(wal.records[1].seq(), 2);
945 wal.verify_chain(world()).expect("clean chain");
946 }
947
948 #[test]
949 fn tampered_step_body_breaks_verify_chain() {
950 let mut w = WalWriter::new(world(), manifest());
951 for i in 0..3 {
952 step_n(&mut w, i);
953 }
954 let mut wal = Wal::from_writer(w);
955 if let WalRecordContent::Step {
957 post_state_digest, ..
958 } = &mut wal.records[1].content
959 {
960 post_state_digest[0] ^= 0xFF;
961 }
962 assert!(matches!(
963 wal.verify_chain(world()),
964 Err(WalError::HashMismatch { .. })
965 ));
966 }
967
968 #[test]
969 fn verify_chain_detects_broken_prev_link() {
970 let mut w = WalWriter::new(world(), manifest());
971 for i in 0..3 {
972 step_n(&mut w, i);
973 }
974 let mut wal = Wal::from_writer(w);
975 wal.records[1].prev_chain_hash[0] ^= 1;
976 assert!(matches!(
977 wal.verify_chain(world()),
978 Err(WalError::ChainBroken { at_record: 1 })
979 ));
980 }
981
982 #[test]
983 fn different_world_id_produces_different_chain() {
984 let mut w1 = WalWriter::new([1u8; 32], manifest());
985 let mut w2 = WalWriter::new([2u8; 32], manifest());
986 append_one(&mut w1);
987 append_one(&mut w2);
988 assert_ne!(w1.chain_tip(), w2.chain_tip());
989 }
990
991 #[test]
992 fn verify_chain_against_wrong_world_id_fails() {
993 let mut w = WalWriter::new(world(), manifest());
994 append_one(&mut w);
995 let wal = Wal::from_writer(w);
996 assert!(matches!(
997 wal.verify_chain([99u8; 32]),
998 Err(WalError::HashMismatch { .. })
999 ));
1000 }
1001
1002 #[test]
1003 fn step_verdict_round_trips() {
1004 let mut w = WalWriter::new(world(), manifest());
1007 w.append_step(
1008 InstanceId::new(1).unwrap(),
1009 sid(1),
1010 Tick(0),
1011 0xFF,
1012 StepVerdict::BudgetPartial { denied: 3 },
1013 [4u8; 32],
1014 )
1015 .unwrap();
1016 let wal = Wal::from_writer(w);
1017 let bytes = wal.serialize().unwrap();
1018 let back = Wal::deserialize(&bytes).unwrap();
1019 match &back.records[0].content {
1020 WalRecordContent::Step { verdict, .. } => {
1021 assert_eq!(*verdict, StepVerdict::BudgetPartial { denied: 3 });
1022 }
1023 WalRecordContent::Submit { .. } => panic!("expected Step"),
1024 }
1025 }
1026
1027 #[test]
1028 fn submit_record_round_trips_inputs() {
1029 let actor = Some(EntityId::new(42).unwrap());
1030 let mut w = WalWriter::new(world(), manifest());
1031 w.append_submit(
1032 InstanceId::new(9).unwrap(),
1033 Principal::External(ExternalId(7)),
1034 actor,
1035 0xDEAD_BEEF,
1036 Tick(5),
1037 TypeCode(101),
1038 vec![9, 8, 7],
1039 sid(3),
1040 )
1041 .unwrap();
1042 let wal = Wal::from_writer(w);
1043 let bytes = wal.serialize().unwrap();
1044 let back = Wal::deserialize(&bytes).unwrap();
1045 match &back.records[0].content {
1046 WalRecordContent::Submit {
1047 instance,
1048 actor: a,
1049 caps_at_submit,
1050 allocated_id,
1051 action_bytes,
1052 ..
1053 } => {
1054 assert_eq!(*instance, InstanceId::new(9).unwrap());
1055 assert_eq!(*a, actor);
1056 assert_eq!(*caps_at_submit, 0xDEAD_BEEF);
1057 assert_eq!(*allocated_id, sid(3));
1058 assert_eq!(action_bytes, &vec![9, 8, 7]);
1059 }
1060 WalRecordContent::Step { .. } => panic!("expected Submit"),
1061 }
1062 assert!(back.verify_chain(world()).is_ok());
1063 }
1064
1065 #[test]
1066 fn header_carries_magic_and_versions() {
1067 let h = WalWriter::new(world(), manifest()).header().clone();
1068 assert_eq!(h.magic, *b"ARKHEWAL");
1069 assert_eq!(h.kernel_semver, (0, 15, 0));
1070 assert_eq!(h.abi_version, (0, 15));
1071 assert_eq!(h.world_id, world());
1072 assert_eq!(h.manifest_digest, manifest());
1073 assert!(h.type_registry_pins.is_empty());
1074 assert!(h.verifying_key.is_none());
1075 let _ = RouteId(1);
1076 }
1077
1078 #[test]
1081 fn signature_class_none_produces_no_signature() {
1082 let mut w = WalWriter::new(world(), manifest());
1083 append_one(&mut w);
1084 let wal = Wal::from_writer(w);
1085 assert!(wal.header.verifying_key.is_none());
1086 assert!(wal.records[0].signature.is_none());
1087 wal.verify_chain(world())
1088 .expect("Tier 1 chain still verifies");
1089 }
1090
1091 #[test]
1092 fn signature_class_ed25519_signs_each_record() {
1093 let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1094 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1095 for _ in 0..3 {
1096 append_one(&mut w);
1097 }
1098 let wal = Wal::from_writer(w);
1099 assert!(wal.header.verifying_key.is_some());
1100 assert_eq!(wal.records.len(), 3);
1101 for rec in &wal.records {
1102 let sig = rec.signature.as_ref().expect("Ed25519 signs every record");
1103 assert_eq!(sig.len(), 64);
1104 }
1105 }
1106
1107 #[test]
1108 fn verify_chain_validates_signatures() {
1109 let sig_class = SignatureClass::new_ed25519_from_secret([11u8; 32]);
1110 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1111 for _ in 0..3 {
1112 append_one(&mut w);
1113 }
1114 let wal = Wal::from_writer(w);
1115 let bytes = wal.serialize().unwrap();
1116 let back = Wal::deserialize(&bytes).unwrap();
1117 back.verify_chain(world()).expect("signed chain verifies");
1118 }
1119
1120 #[test]
1121 fn tampered_signature_fails_verify() {
1122 let sig_class = SignatureClass::new_ed25519_from_secret([13u8; 32]);
1123 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1124 append_one(&mut w);
1125 append_one(&mut w);
1126 let mut wal = Wal::from_writer(w);
1127 if let Some(sig) = wal.records[1].signature.as_mut() {
1128 sig[0] ^= 0xFF;
1129 }
1130 assert!(matches!(
1131 wal.verify_chain(world()),
1132 Err(WalError::SignatureMismatch { at_record: 1 })
1133 ));
1134 }
1135
1136 #[test]
1137 fn missing_signature_fails_verify_when_header_has_key() {
1138 let sig_class = SignatureClass::new_ed25519_from_secret([17u8; 32]);
1139 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1140 append_one(&mut w);
1141 let mut wal = Wal::from_writer(w);
1142 wal.records[0].signature = None;
1143 assert!(matches!(
1144 wal.verify_chain(world()),
1145 Err(WalError::MissingSignature { at_record: 0 })
1146 ));
1147 }
1148
1149 #[test]
1150 fn wrong_key_fails_verify() {
1151 let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
1152 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1153 append_one(&mut w);
1154 let mut wal = Wal::from_writer(w);
1155 let other = SignatureClass::new_ed25519_from_secret([23u8; 32])
1156 .verifying_key_bytes()
1157 .unwrap();
1158 wal.header.verifying_key = Some(other);
1159 assert!(matches!(
1160 wal.verify_chain(world()),
1161 Err(WalError::SignatureMismatch { at_record: 0 })
1162 ));
1163 }
1164
1165 #[test]
1166 fn signature_deterministic_across_runs() {
1167 let mk = |secret: [u8; 32]| -> Vec<Vec<u8>> {
1168 let mut w = WalWriter::with_signature(
1169 world(),
1170 manifest(),
1171 SignatureClass::new_ed25519_from_secret(secret),
1172 );
1173 append_one(&mut w);
1174 append_one(&mut w);
1175 let wal = Wal::from_writer(w);
1176 wal.records
1177 .iter()
1178 .map(|r| r.signature.clone().unwrap())
1179 .collect()
1180 };
1181 let sigs1 = mk([29u8; 32]);
1182 let sigs2 = mk([29u8; 32]);
1183 assert_eq!(sigs1, sigs2);
1184 assert_eq!(sigs1[0].len(), 64);
1185 }
1186
1187 #[test]
1188 fn domain_ctx_byte_identity_blake3() {
1189 const EXPECTED: &[u8] = b"arkhe-kernel v0.15 WAL chain domain separation context";
1199 assert_eq!(WalHeader::DOMAIN_CTX, EXPECTED);
1200 assert_eq!(WalHeader::DOMAIN_CTX.len(), 54);
1201
1202 const FROZEN_HEX: &str = "3e1aa9478e76820fbdb31ca8fdf136df81d83635e2925584934cfa110f682129";
1203 let actual_hex = blake3::hash(WalHeader::DOMAIN_CTX).to_hex();
1204 assert_eq!(
1205 actual_hex.as_str(),
1206 FROZEN_HEX,
1207 "DOMAIN_CTX BLAKE3 hash regression — byte-level edit detected",
1208 );
1209 }
1210
1211 #[test]
1212 fn wal_record_persists_actor_for_replay_determinism() {
1213 let actor = Some(EntityId::new(42).unwrap());
1216 let mut w = WalWriter::new(world(), manifest());
1217 w.append_submit(
1218 InstanceId::new(1).unwrap(),
1219 Principal::System,
1220 actor,
1221 0,
1222 Tick(0),
1223 TypeCode(100),
1224 vec![1, 2, 3],
1225 sid(1),
1226 )
1227 .unwrap();
1228 let wal = Wal::from_writer(w);
1229 match &wal.records[0].content {
1230 WalRecordContent::Submit { actor: a, .. } => assert_eq!(*a, actor),
1231 WalRecordContent::Step { .. } => panic!("expected Submit"),
1232 }
1233 assert!(wal.verify_chain(world()).is_ok());
1234 }
1235
1236 #[test]
1237 fn verify_chain_anchored_rejects_downgrade_and_substitution() {
1238 let sig = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1239 let vk = sig.verifying_key_bytes().unwrap();
1240 let mut w = WalWriter::with_signature(world(), manifest(), sig);
1241 append_one(&mut w);
1242 let wal = Wal::from_writer(w);
1243
1244 let good = TrustAnchor {
1245 min_tier: Some(SignatureTier::Ed25519),
1246 ed25519_verifying_key: Some(vk),
1247 ..Default::default()
1248 };
1249 assert!(wal.verify_chain_anchored(world(), &good).is_ok());
1250
1251 let downgrade = TrustAnchor {
1252 min_tier: Some(SignatureTier::Hybrid),
1253 ..Default::default()
1254 };
1255 assert!(matches!(
1256 wal.verify_chain_anchored(world(), &downgrade),
1257 Err(WalError::TierDowngrade)
1258 ));
1259
1260 let wrong_key = TrustAnchor {
1261 min_tier: Some(SignatureTier::Ed25519),
1262 ed25519_verifying_key: Some([0xAB; 32]),
1263 ..Default::default()
1264 };
1265 assert!(matches!(
1266 wal.verify_chain_anchored(world(), &wrong_key),
1267 Err(WalError::VerifyingKeyMismatch)
1268 ));
1269 }
1270
1271 #[test]
1274 fn submit_record_postcard_layout_byte_identity() {
1275 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1278 w.append_submit(
1279 InstanceId::new(99).unwrap(),
1280 Principal::System,
1281 Some(EntityId::new(5).unwrap()),
1282 0xCAFE,
1283 Tick(42),
1284 TypeCode(0xBEEF),
1285 vec![0xAA, 0xBB, 0xCC],
1286 sid(7),
1287 )
1288 .unwrap();
1289 let wal = Wal::from_writer(w);
1290 let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1291 const FROZEN_HEX: &str = "43e10a825aa2b78cbbe59114fc546c31f64c8ffdf00dada54127bdc598967562";
1292 assert_eq!(
1293 blake3::hash(&encoded).to_hex().as_str(),
1294 FROZEN_HEX,
1295 "Submit record postcard byte sequence regression",
1296 );
1297 }
1298
1299 #[test]
1300 fn step_record_postcard_layout_byte_identity() {
1301 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1302 w.append_step(
1303 InstanceId::new(99).unwrap(),
1304 sid(13),
1305 Tick(42),
1306 0xC0FFEE,
1307 StepVerdict::BudgetPartial { denied: 2 },
1308 [0x5A; 32],
1309 )
1310 .unwrap();
1311 let wal = Wal::from_writer(w);
1312 let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1313 const FROZEN_HEX: &str = "44a440370648b160fa342075d77a1fd72629c1fde3467c0458c34c36d2ae4aa9";
1314 assert_eq!(
1315 blake3::hash(&encoded).to_hex().as_str(),
1316 FROZEN_HEX,
1317 "Step record postcard byte sequence regression",
1318 );
1319 }
1320
1321 #[test]
1322 fn chain_hash_frozen_for_step_record() {
1323 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1326 w.append_step(
1327 InstanceId::new(1).unwrap(),
1328 sid(1),
1329 Tick(0),
1330 0xFF,
1331 StepVerdict::Committed,
1332 [9u8; 32],
1333 )
1334 .unwrap();
1335 let wal = Wal::from_writer(w);
1336 const FROZEN_HEX: &str = "0627f6472e44e5af82b9019698b80adc08fe02a5ffcf081b79a3ea824556df14";
1337 assert_eq!(
1338 blake3::Hash::from(wal.records[0].this_chain_hash)
1339 .to_hex()
1340 .as_str(),
1341 FROZEN_HEX,
1342 "chain hash regression — DOMAIN_CTX or Step body field order changed",
1343 );
1344 }
1345
1346 #[test]
1347 fn wal_record_hybrid_layout_byte_identity() {
1348 let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
1351 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1352 append_one(&mut w);
1353 let mut wal = Wal::from_writer(w);
1354 let baseline_encoded = postcard::to_allocvec(&wal.records[0]).expect("baseline encode");
1355 wal.records[0].signature_pqc = Some(vec![0xAB; 3309]);
1356 let with_pqc_encoded = postcard::to_allocvec(&wal.records[0]).expect("with_pqc encode");
1357 assert_eq!(
1359 with_pqc_encoded.len() - baseline_encoded.len(),
1360 3311,
1361 "PQC signature envelope size mismatch — ML-DSA 65 must fit",
1362 );
1363 }
1364
1365 #[test]
1366 fn wal_header_verifying_key_pqc_slot_pinned() {
1367 let h = WalWriter::new(world(), manifest()).header().clone();
1368 assert!(h.verifying_key.is_none());
1369 assert!(h.verifying_key_pqc.is_none());
1370
1371 let mut h_pqc = h.clone();
1372 h_pqc.verifying_key_pqc = Some(vec![0xCD; 1952]);
1373 let baseline = postcard::to_allocvec(&h).expect("encode baseline");
1374 let with_pqc = postcard::to_allocvec(&h_pqc).expect("encode with pqc key");
1375 assert_eq!(
1377 with_pqc.len() - baseline.len(),
1378 1954,
1379 "PQC verifying-key envelope size mismatch — ML-DSA 65 must fit",
1380 );
1381 }
1382
1383 #[test]
1386 fn hybrid_writer_emits_both_signatures() {
1387 let sig_class = SignatureClass::new_hybrid_from_secrets([7u8; 32], [11u8; 32]);
1388 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1389 for _ in 0..3 {
1390 append_one(&mut w);
1391 }
1392 let wal = Wal::from_writer(w);
1393 assert_eq!(wal.header.verifying_key.expect("Hybrid pins Ed25519 vk").len(), 32);
1394 assert_eq!(
1395 wal.header
1396 .verifying_key_pqc
1397 .as_ref()
1398 .expect("Hybrid pins PQC vk")
1399 .len(),
1400 1952
1401 );
1402 assert_eq!(wal.records.len(), 3);
1403 for rec in &wal.records {
1404 assert_eq!(
1405 rec.signature
1406 .as_ref()
1407 .expect("Hybrid signs Ed25519 every record")
1408 .len(),
1409 64
1410 );
1411 assert_eq!(
1412 rec.signature_pqc
1413 .as_ref()
1414 .expect("Hybrid signs PQC every record")
1415 .len(),
1416 3309
1417 );
1418 }
1419 }
1420
1421 #[test]
1422 fn hybrid_verify_chain_and_mode_passes_with_both_valid() {
1423 let sig_class = SignatureClass::new_hybrid_from_secrets([13u8; 32], [17u8; 32]);
1424 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1425 for _ in 0..3 {
1426 append_one(&mut w);
1427 }
1428 let wal = Wal::from_writer(w);
1429 let bytes = wal.serialize().unwrap();
1430 let back = Wal::deserialize(&bytes).unwrap();
1431 back.verify_chain(world())
1432 .expect("Hybrid signed chain verifies (AND-mode pass)");
1433 }
1434
1435 #[test]
1436 fn hybrid_verify_chain_rejects_missing_pqc() {
1437 let sig_class = SignatureClass::new_hybrid_from_secrets([19u8; 32], [23u8; 32]);
1438 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1439 append_one(&mut w);
1440 let mut wal = Wal::from_writer(w);
1441 wal.records[0].signature_pqc = None;
1442 assert!(matches!(
1443 wal.verify_chain(world()),
1444 Err(WalError::MissingPqcSignature { at_record: 0 })
1445 ));
1446 }
1447
1448 #[test]
1449 fn hybrid_verify_chain_rejects_corrupt_pqc_signature() {
1450 let sig_class = SignatureClass::new_hybrid_from_secrets([29u8; 32], [31u8; 32]);
1451 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1452 append_one(&mut w);
1453 append_one(&mut w);
1454 let mut wal = Wal::from_writer(w);
1455 if let Some(sig_pqc) = wal.records[1].signature_pqc.as_mut() {
1456 sig_pqc[0] ^= 0xFF;
1457 }
1458 assert!(matches!(
1459 wal.verify_chain(world()),
1460 Err(WalError::PqcSignatureMismatch { at_record: 1 })
1461 ));
1462 }
1463
1464 #[test]
1465 fn hybrid_verify_chain_rejects_corrupt_ed25519_when_pqc_valid() {
1466 let sig_class = SignatureClass::new_hybrid_from_secrets([37u8; 32], [41u8; 32]);
1467 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1468 append_one(&mut w);
1469 append_one(&mut w);
1470 let mut wal = Wal::from_writer(w);
1471 if let Some(sig) = wal.records[0].signature.as_mut() {
1472 sig[0] ^= 0xFF;
1473 }
1474 assert!(matches!(
1475 wal.verify_chain(world()),
1476 Err(WalError::PqcSignatureMismatch { at_record: 0 })
1477 ));
1478 }
1479
1480 #[test]
1481 fn ed25519_only_wal_replays_under_hybrid_kernel() {
1482 let sig_class = SignatureClass::new_ed25519_from_secret([43u8; 32]);
1483 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1484 for _ in 0..3 {
1485 append_one(&mut w);
1486 }
1487 let wal = Wal::from_writer(w);
1488 assert!(wal.header.verifying_key.is_some());
1489 assert!(wal.header.verifying_key_pqc.is_none());
1490 for rec in &wal.records {
1491 assert!(rec.signature.is_some());
1492 assert!(rec.signature_pqc.is_none());
1493 }
1494 let bytes = wal.serialize().unwrap();
1495 let back = Wal::deserialize(&bytes).unwrap();
1496 back.verify_chain(world())
1497 .expect("Ed25519-only WAL replays under Hybrid-capable kernel");
1498 }
1499
1500 #[test]
1501 fn pqc_without_ed25519_envelope_rejected() {
1502 let sig_class = SignatureClass::new_hybrid_from_secrets([47u8; 32], [53u8; 32]);
1503 let w = WalWriter::with_signature(world(), manifest(), sig_class);
1504 let mut wal = Wal::from_writer(w);
1505 wal.header.verifying_key = None;
1506 assert!(matches!(
1507 wal.verify_chain(world()),
1508 Err(WalError::PqcWithoutEd25519)
1509 ));
1510 }
1511}