diff --git a/crates/rvf-forge-core/src/author/mod.rs b/crates/rvf-forge-core/src/author/mod.rs new file mode 100644 index 000000000..802ce1225 --- /dev/null +++ b/crates/rvf-forge-core/src/author/mod.rs @@ -0,0 +1,439 @@ +//! Authoring: build a valid RVF container from segments and sign it. +//! +//! Every other module in this crate reads an artifact someone else produced. +//! This one writes, and it is the only one that does. It exists because a +//! publisher who has just run `rvforge init --keygen` has a key, a config, and +//! nothing to sign: without a supported way to *make* an `.rvf`, the first +//! documented command after `init` cannot run. +//! +//! # What authoring is not +//! +//! Writing bytes is not running them. Core invariant 2 (ADR-283 §2) holds here +//! exactly as it does in [`crate::inspect`]: a payload handed to +//! [`ContainerBuilder::segment`] is copied, length-checked, and hashed. It is +//! never parsed as code, validated as WASM, linked, or executed, and that is +//! true whatever [`SegmentType`] the caller labels it with. +//! +//! # The shape produced +//! +//! ```text +//! [ META segment: capability declaration ] (when any are declared) +//! [ caller segments, in the order supplied ] +//! [ MANIFEST segment: the root manifest ] +//! [ 4096-byte Level-0 root manifest page ] <- at EOF, always +//! ``` +//! +//! The trailing page is what makes the output a *file* rather than a bare +//! segment stream: `rvf-manifest` puts a fixed 4096-byte Level-0 root at EOF, +//! and readers discover an RVF from its tail. [`crate::container`] trims it +//! before walking. +//! +//! # Determinism +//! +//! Identical input yields identical bytes. Nothing here reads the clock or the +//! environment: segment timestamps and the root page's created/modified fields +//! are zero, segment ids are assigned by position, and the file id is supplied +//! by the caller rather than generated. Two publishers building the same agent +//! from the same inputs get the same artifact, so the SHA-256 that identifies +//! it is reproducible. +//! +//! # Example +//! +//! ``` +//! use rvf_forge_core::{author::ContainerBuilder, inspect_bytes, CapabilityClass}; +//! +//! let built = ContainerBuilder::new() +//! .declare_capability(CapabilityClass::Network) +//! .segment(rvf_forge_core::SegmentType::Vec, b"embeddings".to_vec()) +//! .build()?; +//! +//! let inspection = inspect_bytes(&built.bytes)?; +//! assert_eq!(inspection.declared_capabilities, vec![CapabilityClass::Network]); +//! # Ok::<(), rvf_forge_core::ForgeError>(()) +//! ``` + +mod root_manifest; +mod segment; + +pub use root_manifest::{ + root_manifest_page_of, root_manifest_signing_bytes, verify_root_manifest_signature, +}; + +use crate::capability::CapabilityClass; +use crate::error::{ForgeError, Result}; +use crate::inspect::CAPABILITY_DECLARATION_KEY; +use ed25519_dalek::SigningKey; +use root_manifest::root_manifest_page; +use rvf_types::{SegmentType, MAX_SEGMENT_PAYLOAD}; +pub(crate) use segment::write_segment_bytes; +use std::collections::BTreeSet; + +/// Content-hash algorithm authored containers use: SHAKE-256 truncated to 128 +/// bits (`checksum_algo` 2). +/// +/// The format's default is XXH3-128, which is faster. SHAKE-256 is chosen here +/// because authoring is the one operation a *non-Rust* implementation has to +/// reproduce byte for byte — the TypeScript CLI writes the same containers — +/// and SHAKE-256 is reachable from any standard crypto library, where XXH3-128 +/// would mean shipping a hand-ported hash on the other side. It is also the +/// cryptographic option, which is the right default for a signing-adjacent +/// tool. +pub const DEFAULT_CHECKSUM_ALGO: u8 = 2; + +/// Byte length of an Ed25519 signature. +const ED25519_SIGNATURE_LENGTH: usize = 64; + +/// A container produced by [`ContainerBuilder::build`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthoredContainer { + /// The complete file, ready to write to disk. + pub bytes: Vec, + /// The 16-byte file id recorded in the root manifest page. + pub file_id: [u8; 16], + /// How many segments were written, including the generated capability and + /// root-manifest segments. + pub segment_count: usize, + /// Whether a signing key was supplied, and so whether the root manifest + /// page and the signable segments carry signatures. + pub signed: bool, +} + +/// Builds a valid RVF container. +/// +/// The builder owns two format obligations the caller should not have to know +/// about: every container ends with a `MANIFEST` segment (verification refuses +/// one without a root manifest), and every container ends with a Level-0 root +/// manifest page. +#[derive(Default)] +pub struct ContainerBuilder { + capabilities: BTreeSet, + segments: Vec<(SegmentType, Vec)>, + manifest_payload: Option>, + file_id: [u8; 16], + checksum_algo: Option, + signing_key: Option, +} + +impl ContainerBuilder { + /// A builder with no segments, no declared capabilities, and no key. + pub fn new() -> Self { + Self::default() + } + + /// Set the 16-byte file id recorded in the root manifest page. + /// + /// Taken from the caller rather than generated so that authoring stays a + /// pure function of its inputs. + pub fn file_id(mut self, file_id: [u8; 16]) -> Self { + self.file_id = file_id; + self + } + + /// Declare one capability class in the container's `META` segment. + /// + /// Declaring is not granting. The runtime is default-deny (ADR-286 §1); + /// this records what the artifact *asks* for, which is what + /// [`crate::inspect`] reports and what a reviewer reads. + pub fn declare_capability(mut self, class: CapabilityClass) -> Self { + self.capabilities.insert(class); + self + } + + /// Declare several capability classes at once. + pub fn declare_capabilities( + mut self, + classes: impl IntoIterator, + ) -> Self { + self.capabilities.extend(classes); + self + } + + /// Append a segment. Payload bytes are stored, never interpreted. + pub fn segment(mut self, seg_type: SegmentType, payload: impl Into>) -> Self { + self.segments.push((seg_type, payload.into())); + self + } + + /// Override the root `MANIFEST` segment's payload. + /// + /// One is always written; this replaces its contents. + pub fn manifest_payload(mut self, payload: impl Into>) -> Self { + self.manifest_payload = Some(payload.into()); + self + } + + /// Use a specific `checksum_algo` instead of [`DEFAULT_CHECKSUM_ALGO`]. + pub fn checksum_algo(mut self, algo: u8) -> Self { + self.checksum_algo = Some(algo); + self + } + + /// Sign with `key`: the root manifest page, the `MANIFEST` segment, and + /// every executable segment get Ed25519 signatures. + /// + /// Without a key the container is unsigned, which + /// [`crate::verify`] refuses under default options as soon as it holds an + /// executable segment (ADR-283 invariant 3). + pub fn sign_with(mut self, key: SigningKey) -> Self { + self.signing_key = Some(key); + self + } + + /// Assemble the container. + /// + /// # Errors + /// + /// [`ForgeError::InvalidRvf`] if a payload exceeds [`MAX_SEGMENT_PAYLOAD`]. + pub fn build(self) -> Result { + let algo = self.checksum_algo.unwrap_or(DEFAULT_CHECKSUM_ALGO); + let key = self.signing_key.as_ref(); + + let mut planned: Vec<(SegmentType, Vec)> = Vec::new(); + if !self.capabilities.is_empty() { + planned.push(( + SegmentType::Meta, + self.capability_declaration().into_bytes(), + )); + } + planned.extend(self.segments); + planned.push(( + SegmentType::Manifest, + self.manifest_payload.unwrap_or_else(|| b"root".to_vec()), + )); + + let segment_count = planned.len(); + let mut body: Vec = Vec::new(); + let mut manifest_span = (0usize, 0usize); + + for (index, (seg_type, payload)) in planned.into_iter().enumerate() { + if payload.len() as u64 > MAX_SEGMENT_PAYLOAD { + return Err(ForgeError::invalid_rvf(format!( + "segment {index} carries {} payload bytes, over the \ + {MAX_SEGMENT_PAYLOAD}-byte maximum", + payload.len() + ))); + } + let signer = key.filter(|_| is_signable(seg_type)); + let offset = body.len(); + // Segment ids are 1-based and assigned by position, so the same + // inputs always produce the same ids. + let encoded = write_segment_bytes(seg_type, &payload, index as u64 + 1, algo, signer); + if seg_type == SegmentType::Manifest { + manifest_span = (offset, encoded.len()); + } + body.extend_from_slice(&encoded); + } + + let page = root_manifest_page( + &self.file_id, + manifest_span.0 as u64, + manifest_span.1 as u64, + key, + ); + body.extend_from_slice(&page); + + Ok(AuthoredContainer { + bytes: body, + file_id: self.file_id, + segment_count, + signed: key.is_some(), + }) + } + + /// The `META` payload line declaring the requested capability classes. + fn capability_declaration(&self) -> String { + let names: Vec<&str> = self.capabilities.iter().map(|c| c.as_str()).collect(); + format!("{CAPABILITY_DECLARATION_KEY}={}\n", names.join(",")) + } +} + +/// Whether a segment of this type carries a signature footer when a key is +/// supplied. +/// +/// Executable segments must be signed for a runtime to load them (ADR-283 +/// invariant 3). The root manifest is signed because it is the thing every +/// other check is anchored to. Signing inert data segments as well would cost +/// 128 bytes each and prove nothing the root manifest signature does not. +fn is_signable(seg_type: SegmentType) -> bool { + matches!( + seg_type, + SegmentType::Kernel | SegmentType::Ebpf | SegmentType::Wasm | SegmentType::Manifest + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::TestKeypair; + use crate::{inspect_bytes, verify_bytes, VerifyOptions}; + use rvf_types::SEGMENT_HEADER_SIZE; + + #[test] + fn a_minimal_authored_container_inspects_and_verifies() { + let built = ContainerBuilder::new().build().unwrap(); + let inspection = inspect_bytes(&built.bytes).unwrap(); + assert_eq!(inspection.segments.len(), 1); + assert_eq!( + inspection.root_manifest().unwrap().segment_type_name, + "manifest" + ); + + let report = verify_bytes(&built.bytes, &VerifyOptions::default()).unwrap(); + assert!(report.ok, "{:?}", report.failures()); + } + + #[test] + fn declared_capabilities_round_trip_through_inspection() { + let built = ContainerBuilder::new() + .declare_capabilities([CapabilityClass::Network, CapabilityClass::Filesystem]) + .build() + .unwrap(); + let inspection = inspect_bytes(&built.bytes).unwrap(); + assert_eq!( + inspection.declared_capabilities, + vec![CapabilityClass::Filesystem, CapabilityClass::Network] + ); + } + + #[test] + fn an_unsigned_executable_is_refused_by_default_and_allowed_by_opt_in() { + let built = ContainerBuilder::new() + .segment(SegmentType::Wasm, b"\0asm\x01\0\0\0".to_vec()) + .build() + .unwrap(); + + let inspection = inspect_bytes(&built.bytes).unwrap(); + assert_eq!(inspection.executable_segment_count, 1); + assert!(!inspection.all_executables_signed()); + + let strict = verify_bytes(&built.bytes, &VerifyOptions::default()).unwrap(); + assert!(!strict.ok); + + let permissive = verify_bytes( + &built.bytes, + &VerifyOptions { + allow_unsigned_executable: true, + ..VerifyOptions::default() + }, + ) + .unwrap(); + assert!(permissive.ok, "{:?}", permissive.failures()); + } + + #[test] + fn a_signed_executable_verifies_against_the_signing_key() { + let kp = TestKeypair::deterministic(11); + let built = ContainerBuilder::new() + .segment(SegmentType::Wasm, b"\0asm\x01\0\0\0".to_vec()) + .sign_with(kp.signing.clone()) + .build() + .unwrap(); + + assert!(built.signed); + assert!(inspect_bytes(&built.bytes) + .unwrap() + .all_executables_signed()); + + let report = verify_bytes( + &built.bytes, + &VerifyOptions::with_trusted_keys(vec![kp.public]), + ) + .unwrap(); + assert!(report.ok, "{:?}", report.failures()); + } + + #[test] + fn tampering_with_any_byte_breaks_verification() { + let kp = TestKeypair::deterministic(14); + let built = ContainerBuilder::new() + .segment(SegmentType::Wasm, b"\0asm\x01\0\0\0".to_vec()) + .sign_with(kp.signing.clone()) + .build() + .unwrap(); + + let mut tampered = built.bytes.clone(); + // Flip a byte inside the WASM payload, past the first segment header. + let target = SEGMENT_HEADER_SIZE + 4; + tampered[target] ^= 0xff; + let report = verify_bytes( + &tampered, + &VerifyOptions::with_trusted_keys(vec![kp.public]), + ) + .unwrap(); + assert!(!report.ok); + } + + #[test] + fn identical_input_yields_identical_bytes() { + let build = || { + ContainerBuilder::new() + .file_id([7u8; 16]) + .declare_capability(CapabilityClass::Network) + .segment(SegmentType::Vec, b"payload".to_vec()) + .sign_with(TestKeypair::deterministic(3).signing) + .build() + .unwrap() + }; + assert_eq!(build(), build()); + } + + #[test] + fn capability_order_does_not_change_the_bytes() { + let one = ContainerBuilder::new() + .declare_capabilities([CapabilityClass::Network, CapabilityClass::Clock]) + .build() + .unwrap(); + let two = ContainerBuilder::new() + .declare_capabilities([CapabilityClass::Clock, CapabilityClass::Network]) + .build() + .unwrap(); + assert_eq!(one.bytes, two.bytes); + } + + #[test] + fn segments_appear_in_the_order_supplied_with_the_manifest_last() { + let built = ContainerBuilder::new() + .declare_capability(CapabilityClass::Network) + .segment(SegmentType::Vec, b"a".to_vec()) + .segment(SegmentType::Index, b"b".to_vec()) + .build() + .unwrap(); + let names: Vec = inspect_bytes(&built.bytes) + .unwrap() + .segments + .into_iter() + .map(|s| s.segment_type_name) + .collect(); + assert_eq!(names, ["meta", "vec", "index", "manifest"]); + assert_eq!(built.segment_count, 4); + } + + #[test] + fn an_empty_payload_is_legal_and_round_trips() { + let built = ContainerBuilder::new() + .segment(SegmentType::Vec, Vec::new()) + .build() + .unwrap(); + let segments = inspect_bytes(&built.bytes).unwrap().segments; + assert_eq!(segments[0].payload_length, 0); + assert!( + verify_bytes(&built.bytes, &VerifyOptions::default()) + .unwrap() + .ok + ); + } + + #[test] + fn authored_containers_use_shake256_content_hashes() { + let built = ContainerBuilder::new() + .segment(SegmentType::Vec, b"hash me".to_vec()) + .build() + .unwrap(); + let vec_segment = &inspect_bytes(&built.bytes).unwrap().segments[0]; + assert_eq!(vec_segment.checksum_algo, DEFAULT_CHECKSUM_ALGO); + assert_eq!( + vec_segment.content_hash, + crate::hex_lower(&rvf_wire::hash::compute_shake256_128(b"hash me")) + ); + } +} diff --git a/crates/rvf-forge-core/src/author/root_manifest.rs b/crates/rvf-forge-core/src/author/root_manifest.rs new file mode 100644 index 000000000..b78e29d0c --- /dev/null +++ b/crates/rvf-forge-core/src/author/root_manifest.rs @@ -0,0 +1,234 @@ +//! The Level-0 root manifest page that closes every authored container. +//! +//! An RVF file is discovered from its tail: the last 4096 bytes are a fixed +//! Level-0 root manifest (`rvf-manifest/src/level0.rs`), carrying `RVM0` rather +//! than the segment magic. It is not a segment, and [`crate::container`] trims +//! it before walking the segment stream. +//! +//! Writing that page is what makes authored output a *file* rather than a bare +//! segment stream, and it is where the container-level signature lives. + +use super::{AuthoredContainer, ED25519_SIGNATURE_LENGTH}; +use ed25519_dalek::SigningKey; +use rvf_types::{ROOT_MANIFEST_MAGIC, ROOT_MANIFEST_SIZE}; + +/// Ed25519, as the Level-0 root manifest's `sig_algo` field names it. +/// +/// Not the same value a *segment* signature footer uses for Ed25519, which is +/// 0 (`rvf-crypto/src/sign.rs`). The two structures carry different algorithm +/// enumerations; each writer has to use its own. +pub(super) const SIG_ALGO_ED25519: u16 = 1; + +/// Level-0 root manifest field offsets (`rvf-manifest/src/level0.rs`). +mod l0 { + pub(super) const VERSION: usize = 0x004; + pub(super) const L1_OFFSET: usize = 0x008; + pub(super) const L1_LENGTH: usize = 0x010; + pub(super) const DTYPE: usize = 0x022; + pub(super) const EPOCH: usize = 0x024; + pub(super) const SIG_ALGO: usize = 0x098; + pub(super) const SIG_LEN: usize = 0x09A; + pub(super) const SIGNATURE: usize = 0x09C; + pub(super) const FILE_ID: usize = 0xF00; + pub(super) const CHECKSUM: usize = 0xFFC; +} + +/// Build the 4096-byte Level-0 root manifest page. +pub(super) fn root_manifest_page( + file_id: &[u8; 16], + l1_offset: u64, + l1_length: u64, + key: Option<&SigningKey>, +) -> [u8; ROOT_MANIFEST_SIZE] { + let mut page = [0u8; ROOT_MANIFEST_SIZE]; + page[0x00..0x04].copy_from_slice(&ROOT_MANIFEST_MAGIC.to_le_bytes()); + page[l0::VERSION..l0::VERSION + 2].copy_from_slice(&1u16.to_le_bytes()); + page[l0::L1_OFFSET..l0::L1_OFFSET + 8].copy_from_slice(&l1_offset.to_le_bytes()); + page[l0::L1_LENGTH..l0::L1_LENGTH + 8].copy_from_slice(&l1_length.to_le_bytes()); + // total_vector_count and dimension stay zero: an agent container declares + // no vectors, and a zero dimension is only invalid alongside a non-zero + // vector count. + page[l0::DTYPE] = 1; // f32 + page[l0::EPOCH..l0::EPOCH + 4].copy_from_slice(&1u32.to_le_bytes()); + // created_ns and modified_ns stay zero, for reproducibility. + page[l0::FILE_ID..l0::FILE_ID + 16].copy_from_slice(file_id); + + if let Some(key) = key { + use ed25519_dalek::Signer; + // The algorithm and length fields are written *before* signing so the + // signature covers them. Leaving them out would let an attacker + // rewrite `sig_algo` to a weaker scheme without invalidating anything. + page[l0::SIG_ALGO..l0::SIG_ALGO + 2].copy_from_slice(&SIG_ALGO_ED25519.to_le_bytes()); + page[l0::SIG_LEN..l0::SIG_LEN + 2] + .copy_from_slice(&(ED25519_SIGNATURE_LENGTH as u16).to_le_bytes()); + let signature = key.sign(&root_manifest_signing_bytes(&page)).to_bytes(); + page[l0::SIGNATURE..l0::SIGNATURE + ED25519_SIGNATURE_LENGTH].copy_from_slice(&signature); + } + + let checksum = rvf_wire::hash::compute_crc32c(&page[..l0::CHECKSUM]); + page[l0::CHECKSUM..].copy_from_slice(&checksum.to_le_bytes()); + page +} + +/// The bytes a root manifest page's signature covers: the whole page with the +/// signature region and the trailing CRC32C zeroed. +/// +/// Both regions have to be excluded because both are written *after* signing. +/// Zeroing rather than skipping keeps the signed length fixed at 4096, so a +/// verifier reconstructs the input without knowing any field order. +pub fn root_manifest_signing_bytes(page: &[u8; ROOT_MANIFEST_SIZE]) -> [u8; ROOT_MANIFEST_SIZE] { + let mut signable = *page; + signable[l0::SIGNATURE..l0::SIGNATURE + ED25519_SIGNATURE_LENGTH].fill(0); + signable[l0::CHECKSUM..].fill(0); + signable +} + +/// Verify the Ed25519 signature on a container's root manifest page. +/// +/// Returns `false` when the page is unsigned, names an algorithm other than +/// Ed25519, or does not verify against `public_key`. +pub fn verify_root_manifest_signature( + page: &[u8; ROOT_MANIFEST_SIZE], + public_key: &[u8; 32], +) -> bool { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let algo = u16::from_le_bytes([page[l0::SIG_ALGO], page[l0::SIG_ALGO + 1]]); + let len = u16::from_le_bytes([page[l0::SIG_LEN], page[l0::SIG_LEN + 1]]); + if algo != SIG_ALGO_ED25519 || len as usize != ED25519_SIGNATURE_LENGTH { + return false; + } + let Ok(verifying) = VerifyingKey::from_bytes(public_key) else { + return false; + }; + let mut raw = [0u8; ED25519_SIGNATURE_LENGTH]; + raw.copy_from_slice(&page[l0::SIGNATURE..l0::SIGNATURE + ED25519_SIGNATURE_LENGTH]); + verifying + .verify( + &root_manifest_signing_bytes(page), + &Signature::from_bytes(&raw), + ) + .is_ok() +} + +/// Borrow the trailing Level-0 root manifest page of an authored container. +/// +/// Returns `None` when `data` does not end in one. +pub fn root_manifest_page_of(data: &[u8]) -> Option<&[u8; ROOT_MANIFEST_SIZE]> { + let (_, page) = crate::container::split_root_manifest(data); + page.map(|range| { + data[range] + .try_into() + .expect("split_root_manifest returns a 4096-byte range") + }) +} + +impl AuthoredContainer { + /// The container's Level-0 root manifest page. + pub fn root_manifest_page(&self) -> &[u8; ROOT_MANIFEST_SIZE] { + root_manifest_page_of(&self.bytes).expect("an authored container always ends in a page") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::author::ContainerBuilder; + use crate::inspect_bytes; + use crate::testkit::TestKeypair; + use crate::SegmentType; + use rvf_types::SEGMENT_ALIGNMENT; + + #[test] + fn the_page_closes_the_file_and_checksums_itself() { + let built = ContainerBuilder::new().build().unwrap(); + assert_eq!(built.bytes.len() % SEGMENT_ALIGNMENT, 0); + + let page = built.root_manifest_page(); + assert_eq!(page[..4], ROOT_MANIFEST_MAGIC.to_le_bytes()); + let stored = u32::from_le_bytes(page[l0::CHECKSUM..].try_into().unwrap()); + assert_eq!( + stored, + rvf_wire::hash::compute_crc32c(&page[..l0::CHECKSUM]) + ); + } + + #[test] + fn the_signature_verifies_and_only_against_the_signing_key() { + let kp = TestKeypair::deterministic(12); + let built = ContainerBuilder::new() + .sign_with(kp.signing.clone()) + .build() + .unwrap(); + + assert!(verify_root_manifest_signature( + built.root_manifest_page(), + &kp.public + )); + assert!(!verify_root_manifest_signature( + built.root_manifest_page(), + &TestKeypair::deterministic(13).public + )); + } + + #[test] + fn rewriting_a_signed_field_breaks_the_signature() { + let kp = TestKeypair::deterministic(15); + let built = ContainerBuilder::new() + .sign_with(kp.signing.clone()) + .build() + .unwrap(); + + let mut page = *built.root_manifest_page(); + page[l0::VERSION] = 7; + assert!(!verify_root_manifest_signature(&page, &kp.public)); + + // The algorithm field is covered too, so downgrading it is caught. + let mut downgraded = *built.root_manifest_page(); + downgraded[l0::SIG_ALGO] = 9; + assert!(!verify_root_manifest_signature(&downgraded, &kp.public)); + } + + #[test] + fn an_unsigned_page_carries_no_signature() { + let built = ContainerBuilder::new().build().unwrap(); + assert!(!built.signed); + assert!(!verify_root_manifest_signature( + built.root_manifest_page(), + &TestKeypair::deterministic(1).public + )); + } + + #[test] + fn the_file_id_reaches_the_page() { + let built = ContainerBuilder::new().file_id([0xab; 16]).build().unwrap(); + assert_eq!( + &built.root_manifest_page()[l0::FILE_ID..l0::FILE_ID + 16], + &[0xab; 16] + ); + } + + #[test] + fn the_l1_pointer_locates_the_manifest_segment() { + let built = ContainerBuilder::new() + .segment(SegmentType::Vec, vec![0u8; 100]) + .build() + .unwrap(); + let page = built.root_manifest_page(); + let offset = u64::from_le_bytes(page[l0::L1_OFFSET..l0::L1_OFFSET + 8].try_into().unwrap()); + let length = u64::from_le_bytes(page[l0::L1_LENGTH..l0::L1_LENGTH + 8].try_into().unwrap()); + + assert!(length > 0, "an empty L1 pointer would warn on validation"); + assert!(offset + length <= built.bytes.len() as u64); + assert_eq!( + inspect_bytes(&built.bytes).unwrap().root_manifest_offset, + Some(offset) + ); + } + + #[test] + fn a_bare_segment_stream_has_no_page() { + let stream = crate::testkit::minimal_container("network"); + assert!(root_manifest_page_of(&stream).is_none()); + } +} diff --git a/crates/rvf-forge-core/src/author/segment.rs b/crates/rvf-forge-core/src/author/segment.rs new file mode 100644 index 000000000..f8b48aa55 --- /dev/null +++ b/crates/rvf-forge-core/src/author/segment.rs @@ -0,0 +1,85 @@ +//! Segment serialization: the 64-byte header, the payload, and the optional +//! Ed25519 signature footer that follows it. +//! +//! This is the single place the segment layout is written. [`crate::testkit`] +//! calls it too, so fixtures cannot drift from authored output. + +use super::ED25519_SIGNATURE_LENGTH; +use ed25519_dalek::SigningKey; +use rvf_types::{ + SegmentFlags, SegmentType, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE, SEGMENT_VERSION, +}; + +/// Serialize one complete segment: header, payload, optional signature footer, +/// zero padding to the next 64-byte boundary. +/// +/// This is the single place the segment layout is written. [`crate::testkit`] +/// calls it too, so fixtures cannot drift from authored output. +pub(crate) fn write_segment_bytes( + seg_type: SegmentType, + payload: &[u8], + segment_id: u64, + checksum_algo: u8, + key: Option<&SigningKey>, +) -> Vec { + let footer_len = key + .map(|_| { + rvf_types::SignatureFooter::compute_footer_length(ED25519_SIGNATURE_LENGTH as u16) + as usize + }) + .unwrap_or(0); + let unpadded = SEGMENT_HEADER_SIZE + payload.len() + footer_len; + let pad = unpadded.div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT - unpadded; + + let flags = match key { + Some(_) => SegmentFlags::empty().with(SegmentFlags::SIGNED), + None => SegmentFlags::empty(), + }; + let head = segment_header( + seg_type as u8, + payload, + flags, + segment_id, + checksum_algo, + pad as u32, + ); + + let mut out = Vec::with_capacity(unpadded + pad); + out.extend_from_slice(&head); + out.extend_from_slice(payload); + if let Some(key) = key { + // `read_segment_header` cannot fail on bytes we just serialized; the + // signature covers the header as the verifier will re-read it. + let header = rvf_wire::read_segment_header(&head) + .expect("author serialized a well-formed segment header"); + let footer = rvf_crypto::sign_segment(&header, payload, key); + out.extend_from_slice(&rvf_crypto::encode_signature_footer(&footer)); + } + out.resize(unpadded + pad, 0); + out +} + +/// Serialize a 64-byte segment header. +fn segment_header( + seg_type: u8, + payload: &[u8], + flags: SegmentFlags, + segment_id: u64, + checksum_algo: u8, + alignment_pad: u32, +) -> [u8; SEGMENT_HEADER_SIZE] { + let content_hash = rvf_wire::hash::compute_content_hash(checksum_algo, payload); + + let mut b = [0u8; SEGMENT_HEADER_SIZE]; + b[0x00..0x04].copy_from_slice(&rvf_types::SEGMENT_MAGIC.to_le_bytes()); + b[0x04] = SEGMENT_VERSION; + b[0x05] = seg_type; + b[0x06..0x08].copy_from_slice(&flags.bits().to_le_bytes()); + b[0x08..0x10].copy_from_slice(&segment_id.to_le_bytes()); + b[0x10..0x18].copy_from_slice(&(payload.len() as u64).to_le_bytes()); + // timestamp_ns stays zero: authored containers must be byte-reproducible. + b[0x20] = checksum_algo; + b[0x28..0x38].copy_from_slice(&content_hash); + b[0x3C..0x40].copy_from_slice(&alignment_pad.to_le_bytes()); + b +} diff --git a/crates/rvf-forge-core/src/bin/rvforge-rvf-check.rs b/crates/rvf-forge-core/src/bin/rvforge-rvf-check.rs new file mode 100644 index 000000000..48c724c38 --- /dev/null +++ b/crates/rvf-forge-core/src/bin/rvforge-rvf-check.rs @@ -0,0 +1,158 @@ +//! Cross-implementation check: does the Rust crate accept an `.rvf` the +//! TypeScript CLI wrote? +//! +//! `npm/packages/rvforge` writes RVF containers; `rvf-forge-core` reads them. +//! Both implement the same wire format, and they were written independently — +//! so "both pass their own tests" says nothing about whether they interoperate. +//! This binary closes that gap the same way `rvforge-registry-check` does for +//! the registry: it takes a file the CLI produced and runs the crate's own +//! public API over it, with no fixtures and no shared code. +//! +//! Usage: `rvforge-rvf-check [--public-key ] [--allow-unsigned]` +//! +//! With `--public-key`, segment signatures are checked against that key rather +//! than skipped, so the run proves the CLI's Ed25519 signatures verify under +//! this crate's verifier — not merely that they are well-formed. +//! +//! Exits 0 when inspection and verification both succeed, 1 otherwise. + +use rvf_forge_core::{inspect_bytes, verify_bytes, VerifyOptions}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match run(&args) { + Ok(()) => { + println!("\nRVF PARITY OK — rvf-forge-core accepts the container the CLI wrote"); + ExitCode::SUCCESS + } + Err(message) => { + eprintln!("\nRVF PARITY FAILED — {message}"); + ExitCode::FAILURE + } + } +} + +fn run(args: &[String]) -> Result<(), String> { + let mut path: Option<&str> = None; + let mut public_key: Option<[u8; 32]> = None; + let mut allow_unsigned = false; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--public-key" => { + let hex = args.get(i + 1).ok_or("--public-key needs a hex value")?; + public_key = Some(parse_hex_key(hex)?); + i += 2; + } + "--allow-unsigned" => { + allow_unsigned = true; + i += 1; + } + other if other.starts_with('-') => return Err(format!("unknown flag {other}")), + other => { + path = Some(other); + i += 1; + } + } + } + + let path = path.ok_or("usage: rvforge-rvf-check [--public-key ]")?; + let data = std::fs::read(path).map_err(|e| format!("cannot read {path}: {e}"))?; + + let inspection = inspect_bytes(&data).map_err(|e| format!("inspection refused it: {e}"))?; + println!("inspected {path}"); + println!(" identity {}", inspection.rvf_identity); + println!(" bytes {}", inspection.byte_length); + println!(" segments {}", inspection.segments.len()); + for segment in &inspection.segments { + println!( + " {:<10} id={} payload={} signed={} checksum_algo={}", + segment.segment_type_name, + segment.segment_id, + segment.payload_length, + segment.signed, + segment.checksum_algo, + ); + } + println!( + " capabilities {}", + if inspection.declared_capabilities.is_empty() { + "none declared (default-deny)".to_string() + } else { + inspection + .declared_capabilities + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(", ") + } + ); + + let options = VerifyOptions { + trusted_keys: public_key.into_iter().collect(), + allow_unsigned_executable: allow_unsigned, + expected_identity: Some(inspection.rvf_identity.clone()), + }; + let report = + verify_bytes(&data, &options).map_err(|e| format!("verification refused it: {e}"))?; + + println!("\nverified {path}"); + for record in &report.records { + println!( + " {:?} {:?}: {}", + record.check, record.outcome, record.detail + ); + } + if !report.ok { + return Err(format!( + "{} check(s) failed: {}", + report.failures().len(), + report + .failures() + .iter() + .map(|r| r.detail.clone()) + .collect::>() + .join("; ") + )); + } + + // A skipped signature check is not a passed one. When the caller supplied a + // key, at least one signature must actually have been verified against it, + // or the run proves nothing about the CLI's signing. + if public_key.is_some() { + let verified = report + .records + .iter() + .filter(|r| { + r.check == rvf_forge_core::CheckKind::Signature + && r.outcome == rvf_forge_core::Outcome::Pass + }) + .count(); + if verified == 0 { + return Err( + "a public key was supplied but no segment signature verified against it".into(), + ); + } + println!("\n {verified} segment signature(s) verified against the supplied key"); + } + + Ok(()) +} + +fn parse_hex_key(hex: &str) -> Result<[u8; 32], String> { + let hex = hex.trim(); + if hex.len() != 64 { + return Err(format!( + "a public key is 32 bytes / 64 hex characters, got {}", + hex.len() + )); + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16) + .map_err(|_| "public key is not valid hex".to_string())?; + } + Ok(out) +} diff --git a/crates/rvf-forge-core/src/container.rs b/crates/rvf-forge-core/src/container.rs index dddf70340..b287149c2 100644 --- a/crates/rvf-forge-core/src/container.rs +++ b/crates/rvf-forge-core/src/container.rs @@ -16,11 +16,19 @@ //! The footer is present exactly when the header's `SIGNED` flag is set, and //! its own trailing `footer_length` field gives its size. The next segment //! begins at the next 64-byte boundary. +//! +//! A complete RVF file is that segment stream followed by a fixed 4096-byte +//! Level-0 root manifest page at EOF (`rvf-manifest/src/level0.rs`), which is +//! not itself a segment and carries `RVM0` rather than the segment magic. The +//! walk therefore trims that page before it starts; see +//! [`split_root_manifest`]. Containers that are a bare segment stream — the +//! shape [`crate::testkit`] built before authoring existed — have no such page +//! and are walked whole. use crate::error::{ForgeError, Result}; use rvf_types::{ - SegmentFlags, SegmentHeader, SegmentType, MAX_SEGMENT_PAYLOAD, SEGMENT_ALIGNMENT, - SEGMENT_HEADER_SIZE, + SegmentFlags, SegmentHeader, SegmentType, MAX_SEGMENT_PAYLOAD, ROOT_MANIFEST_MAGIC, + ROOT_MANIFEST_SIZE, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE, }; use std::ops::Range; @@ -80,14 +88,51 @@ impl ParsedSegment { } } +/// Split a container into its segment area and its trailing Level-0 root +/// manifest page. +/// +/// The page is identified by its `RVM0` magic at exactly `len - 4096`, which +/// is the only place the format permits it. Recognising it by magic rather +/// than by size alone means a bare segment stream that happens to be at least +/// 4096 bytes long is still walked whole. +pub(crate) fn split_root_manifest(data: &[u8]) -> (&[u8], Option>) { + // Require room for at least one segment ahead of the page: a file that is + // nothing but a root manifest has no segments, and reporting that as + // "holds no segments" is clearer than reporting a bad magic. + if data.len() < ROOT_MANIFEST_SIZE + SEGMENT_HEADER_SIZE { + return (data, None); + } + let start = data.len() - ROOT_MANIFEST_SIZE; + let magic = u32::from_le_bytes([ + data[start], + data[start + 1], + data[start + 2], + data[start + 3], + ]); + if magic == ROOT_MANIFEST_MAGIC { + (&data[..start], Some(start..data.len())) + } else { + (data, None) + } +} + /// Walk `data` and return every segment in stream order. /// +/// A trailing Level-0 root manifest page is trimmed first and is not reported +/// as a segment, because it is not one. +/// /// # Errors /// /// [`ForgeError::InvalidRvf`] if the stream is empty, truncated, declares an /// implausible payload length, carries a malformed signature footer, or has /// trailing bytes that are not a segment. pub(crate) fn walk(data: &[u8]) -> Result> { + let (body, _) = split_root_manifest(data); + walk_segments(body) +} + +/// Walk a segment area that has already had any root manifest page trimmed. +fn walk_segments(data: &[u8]) -> Result> { if data.len() < SEGMENT_HEADER_SIZE { return Err(ForgeError::invalid_rvf(format!( "container is {} bytes, shorter than one {SEGMENT_HEADER_SIZE}-byte segment header", @@ -320,6 +365,48 @@ mod tests { assert!(err.to_string().contains("trailing"), "{err}"); } + #[test] + fn a_trailing_root_manifest_page_is_trimmed_not_walked() { + let built = crate::author::ContainerBuilder::new() + .segment(SegmentType::Vec, b"vectors".to_vec()) + .build() + .unwrap(); + + let (body, page) = split_root_manifest(&built.bytes); + assert_eq!( + page, + Some(built.bytes.len() - ROOT_MANIFEST_SIZE..built.bytes.len()) + ); + assert_eq!(body.len(), built.bytes.len() - ROOT_MANIFEST_SIZE); + + // The page is not reported as a segment. + let segs = walk(&built.bytes).unwrap(); + assert_eq!(segs.len(), 2); + assert_eq!(segs[1].header.seg_type, SegmentType::Manifest as u8); + } + + #[test] + fn a_bare_segment_stream_has_no_root_manifest_page() { + let data = write_segment( + SegmentType::Meta as u8, + &[0u8; 8192], + SegmentFlags::empty(), + 1, + ); + let (body, page) = split_root_manifest(&data); + assert_eq!(page, None); + assert_eq!(body.len(), data.len()); + assert_eq!(walk(&data).unwrap().len(), 1); + } + + #[test] + fn trailing_bytes_that_are_not_a_root_manifest_are_still_rejected() { + let mut data = write_segment(SegmentType::Meta as u8, b"x", SegmentFlags::empty(), 1); + data.extend(std::iter::repeat_n(0xEE, ROOT_MANIFEST_SIZE)); + let err = walk(&data).unwrap_err(); + assert_eq!(err.code(), crate::ErrorCode::InvalidRvf); + } + #[test] fn executable_types_are_kernel_ebpf_and_wasm() { assert!(is_executable(SegmentType::Wasm as u8)); diff --git a/crates/rvf-forge-core/src/lib.rs b/crates/rvf-forge-core/src/lib.rs index 8f88c7410..c384cb96f 100644 --- a/crates/rvf-forge-core/src/lib.rs +++ b/crates/rvf-forge-core/src/lib.rs @@ -1,20 +1,25 @@ //! RVForge packaging and verification library (ADR-283, ADR-290, ADR-291). //! //! One canonical `.rvf` becomes installable packages for Windows, macOS, -//! Linux, and RVM. This crate is the part of that pipeline that reads, -//! verifies, and describes the artifact; it does not build installers, and it -//! never runs the agent inside one. +//! Linux, and RVM. This crate is the part of that pipeline that authors, +//! reads, verifies, and describes the artifact; it does not build installers, +//! and it never runs the agent inside one. //! -//! # The five operations +//! # The six operations //! //! | Function | Produces | Purpose | //! |---|---|---| +//! | [`ContainerBuilder`] | [`AuthoredContainer`] | A valid, signed container built from segments | //! | [`inspect`] | [`ForgeInspection`] | Identity, segment inventory, declared capabilities, signature metadata | //! | [`verify`] | [`VerificationReport`] | Root-manifest and per-segment checks, one witness record each | //! | [`build_manifest`] | [`CanonicalBuildManifest`] | The deterministic, canonical-JSON build request | //! | [`provenance`] | [`ProvenanceRecord`] | The trace from output back to input, runtime, revision, builder, and signer | //! | [`checksums`] | [`Sha256Manifest`] | SHA-256 of every produced artifact | //! +//! Authoring is the newest of the six and the only one that writes. It exists +//! because the rest of the pipeline consumes an artifact that, until it was +//! added, nothing in the toolchain could produce; see [`author`]. +//! //! # What this crate will not do //! //! Three refusals are load-bearing rather than incidental, and each traces to @@ -23,7 +28,8 @@ //! - **Nothing here executes an RVF.** Build workers must never run the //! submitted artifact (ADR-283 invariant 2, ADR-290 §1). Inspection reads //! 64-byte segment headers and byte ranges; the only time an executable -//! segment's payload is touched at all is to hash it. +//! segment's payload is touched at all is to hash it. Authoring is held to +//! the same line: it writes payload bytes without interpreting them. //! - **Unsigned executable segments are refused by default.** ADR-283 //! invariant 3 and the RVM loading requirements §4.3. The single opt-out, //! [`VerifyOptions::allow_unsigned_executable`], exists for the unsigned @@ -92,6 +98,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod author; pub mod canonical; pub mod capability; pub mod checksums; @@ -104,6 +111,7 @@ pub mod target; pub mod testkit; pub mod verify; +pub use author::{AuthoredContainer, ContainerBuilder, DEFAULT_CHECKSUM_ALGO}; pub use canonical::{canonical_sha256_hex, hex_lower, to_canonical_json}; pub use capability::{CapabilityClass, CapabilityGrant, CapabilityPolicy}; pub use checksums::{ @@ -124,6 +132,9 @@ pub use provenance::{ provenance, BuilderIdentity, BuilderKind, ProvenanceInputs, ProvenanceRecord, SigningIdentity, SigningPosture, PROVENANCE_SCHEMA, }; +/// The segment type discriminator, re-exported so callers of +/// [`author::ContainerBuilder`] need not depend on `rvf-types` directly. +pub use rvf_types::SegmentType; pub use target::Target; pub use verify::{ verify, verify_bytes, CheckKind, Outcome, VerificationRecord, VerificationReport, diff --git a/crates/rvf-forge-core/src/testkit.rs b/crates/rvf-forge-core/src/testkit.rs index cb511653d..1d1d1515f 100644 --- a/crates/rvf-forge-core/src/testkit.rs +++ b/crates/rvf-forge-core/src/testkit.rs @@ -1,18 +1,31 @@ //! Synthetic RVF construction, for tests and fixtures. //! -//! Forge never writes RVF files in production — it consumes them — so nothing -//! here is part of the packaging path. It exists so that a test can build a -//! container byte by byte and then tamper with it, which is the only way to -//! test that verification actually refuses a tampered artifact. +//! Production authoring lives in [`crate::author`]; this module is the +//! tamper-friendly counterpart. It builds *partial* containers — a lone +//! segment, a segment stream with no root manifest page — so that a test can +//! corrupt one field and confirm verification refuses the result. `author` +//! deliberately cannot produce those shapes, which is why both exist. //! -//! The segment layout written here is the one [`crate::container`] reads: +//! The segment encoder is shared rather than reimplemented: everything here +//! calls [`crate::author::write_segment_bytes`], so a change to the wire layout +//! cannot leave fixtures describing a format nothing else writes. +//! +//! The segment layout is the one [`crate::container`] reads: //! //! ```text //! [ 64-byte header ][ payload ][ signature footer, if SIGNED ][ zero pad to 64 ] //! ``` +use crate::author::write_segment_bytes; use ed25519_dalek::SigningKey; -use rvf_types::{SegmentFlags, SegmentType, SEGMENT_ALIGNMENT, SEGMENT_HEADER_SIZE}; +use rvf_types::SegmentType; + +/// Content-hash algorithm the fixtures use: XXH3-128, the format default. +/// +/// Authored containers use SHAKE-256 instead (see +/// [`crate::author::DEFAULT_CHECKSUM_ALGO`]). Fixtures keep the default so +/// that the reader is exercised against both algorithms. +const FIXTURE_CHECKSUM_ALGO: u8 = 1; /// An Ed25519 keypair derived deterministically from a seed, so that a test /// that signs a fixture produces the same bytes on every run. @@ -33,52 +46,9 @@ impl TestKeypair { } } -/// Serialize a 64-byte segment header. -fn header_bytes( - seg_type: u8, - payload: &[u8], - flags: SegmentFlags, - segment_id: u64, - alignment_pad: u32, -) -> [u8; SEGMENT_HEADER_SIZE] { - const CHECKSUM_ALGO: u8 = 1; // XXH3-128, the format default. - let content_hash = rvf_wire::hash::compute_content_hash(CHECKSUM_ALGO, payload); - - let mut b = [0u8; SEGMENT_HEADER_SIZE]; - b[0x00..0x04].copy_from_slice(&rvf_types::SEGMENT_MAGIC.to_le_bytes()); - b[0x04] = rvf_types::SEGMENT_VERSION; - b[0x05] = seg_type; - b[0x06..0x08].copy_from_slice(&flags.bits().to_le_bytes()); - b[0x08..0x10].copy_from_slice(&segment_id.to_le_bytes()); - b[0x10..0x18].copy_from_slice(&(payload.len() as u64).to_le_bytes()); - // timestamp_ns stays zero: fixtures must be byte-reproducible. - b[0x20] = CHECKSUM_ALGO; - b[0x28..0x38].copy_from_slice(&content_hash); - b[0x3C..0x40].copy_from_slice(&alignment_pad.to_le_bytes()); - b -} - -fn pad_to_alignment(buf: &mut Vec) { - let padded = buf.len().div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT; - buf.resize(padded, 0); -} - /// Build an unsigned segment. pub fn unsigned_segment(seg_type: SegmentType, payload: &[u8], segment_id: u64) -> Vec { - let unpadded = SEGMENT_HEADER_SIZE + payload.len(); - let pad = unpadded.div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT - unpadded; - - let mut out = Vec::new(); - out.extend_from_slice(&header_bytes( - seg_type as u8, - payload, - SegmentFlags::empty(), - segment_id, - pad as u32, - )); - out.extend_from_slice(payload); - pad_to_alignment(&mut out); - out + write_segment_bytes(seg_type, payload, segment_id, FIXTURE_CHECKSUM_ALGO, None) } /// Build a segment signed with `keypair`, carrying an Ed25519 signature footer. @@ -88,21 +58,13 @@ pub fn signed_segment( segment_id: u64, keypair: &TestKeypair, ) -> Vec { - let flags = SegmentFlags::empty().with(SegmentFlags::SIGNED); - let footer_len = rvf_types::SignatureFooter::compute_footer_length(64) as usize; - let unpadded = SEGMENT_HEADER_SIZE + payload.len() + footer_len; - let pad = unpadded.div_ceil(SEGMENT_ALIGNMENT) * SEGMENT_ALIGNMENT - unpadded; - - let head = header_bytes(seg_type as u8, payload, flags, segment_id, pad as u32); - let header = rvf_wire::read_segment_header(&head).expect("testkit built a valid header"); - let footer = rvf_crypto::sign_segment(&header, payload, &keypair.signing); - - let mut out = Vec::new(); - out.extend_from_slice(&head); - out.extend_from_slice(payload); - out.extend_from_slice(&rvf_crypto::encode_signature_footer(&footer)); - pad_to_alignment(&mut out); - out + write_segment_bytes( + seg_type, + payload, + segment_id, + FIXTURE_CHECKSUM_ALGO, + Some(&keypair.signing), + ) } /// Build a minimal but complete container: a `META` segment carrying the given @@ -117,6 +79,7 @@ pub fn minimal_container(capabilities: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + use rvf_types::SEGMENT_ALIGNMENT; #[test] fn unsigned_segment_is_aligned_and_parses() { diff --git a/scripts/rvforge-parity-check.sh b/scripts/rvforge-parity-check.sh index 147db45a7..5862f704f 100755 --- a/scripts/rvforge-parity-check.sh +++ b/scripts/rvforge-parity-check.sh @@ -1,21 +1,26 @@ #!/usr/bin/env bash -# CLI ↔ Rust registry parity check. +# CLI ↔ Rust parity check, in two halves. # -# `npm/packages/rvforge` (TypeScript) writes a local registry; `crates/ -# rvforge-registry` (Rust) reads one. Both implement -# `docs/research/rvf-forge/registry-model.md`, and they were built -# independently — so "both pass their own tests" says nothing about whether -# they interoperate. +# `npm/packages/rvforge` (TypeScript) writes `.rvf` containers and a local +# registry; `crates/rvf-forge-core` and `crates/rvforge-registry` (Rust) read +# them. Both sides implement the same specs and were built independently — so +# "both pass their own tests" says nothing about whether they interoperate. # -# This script closes that gap end to end: it drives the real CLI through -# init → pack → publish against a synthetic `.rvf`, publishes a *second* -# release so the run exercises lineage rather than just a single object, then -# hands the resulting directory to `rvforge-registry-check`, which re-derives -# every content address, recomputes the Merkle log, re-applies the publication -# rules, and walks the witness chains using nothing but the Rust crate's -# public API. +# **Container parity.** `rvforge create` writes a real, signed `.rvf`, and +# `rvforge-rvf-check` inspects and verifies it through `rvf-forge-core`'s public +# API, with the publisher's public key supplied so the Ed25519 signatures are +# actually checked rather than skipped. Nothing is shared between the two +# implementations but the format itself: the segment layout, the SHAKE-256 +# content hashes, the signature footers, and the Level-0 root manifest page all +# have to agree byte for byte or this fails. # -# Prints `PARITY OK` and exits 0 when the Rust reader accepts what the CLI +# **Registry parity.** The run then drives pack → publish against that same +# artifact, publishes a *second* release so it exercises lineage rather than a +# single object, and hands the directory to `rvforge-registry-check`, which +# re-derives every content address, recomputes the Merkle log, re-applies the +# publication rules, and walks the witness chains. +# +# Prints `PARITY OK` and exits 0 when the Rust readers accept what the CLI # wrote; otherwise prints the violation list and exits non-zero. # # Usage: scripts/rvforge-parity-check.sh [--keep] @@ -66,6 +71,34 @@ echo "==> rvforge init --keygen" (cd "$PROJECT_DIR" && node "$CLI" init --keygen --quiet >/dev/null) node "$REPO_ROOT/scripts/rvforge-parity-fixture.cjs" "$CLI_DIR/dist" "$PROJECT_DIR" >/dev/null +echo "==> rvforge create agent.rvf" +# The fixture no longer ships a synthetic .rvf: the CLI writes the artifact +# under test. A .wasm payload makes it carry an executable segment, so the +# run covers the signed-executable path rather than metadata alone. +mkdir -p "$PROJECT_DIR/payload" +printf '\0asm\1\0\0\0' > "$PROJECT_DIR/payload/agent.wasm" +(cd "$PROJECT_DIR" && node "$CLI" create agent.rvf \ + --from payload --key-file "$KEY_FILE" --quiet >/dev/null) + +echo "==> rvforge-rvf-check (Rust reads the container the CLI wrote)" +# The public key is passed as hex so the Rust side needs no base64 decoder; +# without it every signature check would record "skipped", which would prove +# nothing about whether the CLI's signatures verify. +PUBLIC_KEY_HEX="$(node -e ' + const { readFileSync } = require("node:fs"); + const key = JSON.parse(readFileSync(process.argv[1], "utf8")); + process.stdout.write(Buffer.from(key.publicKey, "base64").toString("hex")); +' "$KEY_FILE")" + +if ! (cd "$REPO_ROOT" && cargo run --quiet -p rvf-forge-core \ + --bin rvforge-rvf-check -- "$PROJECT_DIR/agent.rvf" \ + --public-key "$PUBLIC_KEY_HEX"); then + echo + echo "PARITY FAILED — crates/rvf-forge-core rejected the .rvf that" >&2 + echo "npm/packages/rvforge wrote; the two disagree about the RVF format" >&2 + exit 1 +fi + echo "==> rvforge pack agent.rvf" (cd "$PROJECT_DIR" && node "$CLI" pack agent.rvf --quiet >/dev/null) @@ -91,7 +124,9 @@ echo "==> rvforge-registry-check (Rust)" if (cd "$REPO_ROOT" && cargo run --quiet -p rvforge-registry \ --bin rvforge-registry-check -- "$REGISTRY_DIR"); then echo - echo "PARITY OK — the Rust registry reads everything the CLI wrote" + echo "PARITY OK — the Rust side reads everything the CLI wrote:" + echo " container rvf-forge-core inspected and verified agent.rvf" + echo " registry rvforge-registry replayed the whole publication log" exit 0 fi diff --git a/scripts/rvforge-parity-fixture.cjs b/scripts/rvforge-parity-fixture.cjs index 5807767b5..115895e3b 100755 --- a/scripts/rvforge-parity-fixture.cjs +++ b/scripts/rvforge-parity-fixture.cjs @@ -2,18 +2,18 @@ /** * Fixture generator for `scripts/rvforge-parity-check.sh`. * - * Writes the two inputs a publish needs — a synthetic `.rvf` and an - * `rvforge.json` that packs clean — into a directory given on the command line. + * Writes the one input a publish needs that the CLI cannot produce on its own: + * an `rvforge.json` that packs clean. The `.rvf` beside it is no longer written + * here — `rvforge create` writes it, which is the point of the parity run. + * Generating the artifact from literals would have tested the fixture rather + * than the writer that ships. * - * Both are built from the CLI's own compiled modules rather than from literals: - * the RVF layout comes from `dist/rvf.js` (the same constants and CRC the - * parser reads with) and the project scaffold from `dist/project.js`. A fixture - * assembled from hand-copied magic numbers would drift from the parser it is - * meant to feed, and the parity run would start failing for a reason that has - * nothing to do with the registry. + * The scaffold comes from the CLI's own compiled `dist/project.js` rather than + * from a hand-copied object, so it cannot drift from the validator that reads + * it back. * - * The shapes mirror `npm/packages/rvforge/tests/fixtures/{rvf,project}-fixture.ts`, - * which are TypeScript and therefore not loadable from a plain node process. + * The shape mirrors `npm/packages/rvforge/tests/fixtures/project-fixture.ts`, + * which is TypeScript and therefore not loadable from a plain node process. * * Usage: node scripts/rvforge-parity-fixture.cjs */ @@ -32,79 +32,8 @@ if (!distDirArg || !outDirArg) { const distDir = resolve(distDirArg); const outDir = resolve(outDirArg); -const { - ROOT_MANIFEST_MAGIC_BYTES, - ROOT_MANIFEST_SIZE, - SEGMENT_HEADER_SIZE, - SEGMENT_MAGIC_BYTES, - alignUp, - crc32c, -} = require(join(distDir, 'rvf.js')); const { defaultDenials, defaultProject } = require(join(distDir, 'project.js')); -/** The same minimal artifact the CLI's own tests pack: three segments, signed. */ -const FIXTURE = { - segments: [ - { type: 0x01, payloadLength: 128 }, - { type: 0x02, payloadLength: 64 }, - { type: 0x05, payloadLength: 32 }, - ], - signed: true, - formatVersion: 1, - dimension: 8, - vectorCount: 4, - fileId: '0123456789abcdef0123456789abcdef', -}; - -function buildSegments(segments) { - const chunks = []; - segments.forEach((segment, index) => { - const header = Buffer.alloc(SEGMENT_HEADER_SIZE); - SEGMENT_MAGIC_BYTES.copy(header, 0); - header[0x04] = 1; // version - header[0x05] = segment.type; - header.writeUInt16LE(0, 0x06); // flags - header.writeBigUInt64LE(BigInt(index), 0x08); // segment_id - header.writeBigUInt64LE(BigInt(segment.payloadLength), 0x10); - header.writeBigUInt64LE(0n, 0x18); // timestamp_ns — zero keeps the bytes stable - header[0x20] = 0; // checksum_algo: CRC32C - header[0x21] = 0; // compression: none - - const unpadded = SEGMENT_HEADER_SIZE + segment.payloadLength; - chunks.push(header, Buffer.alloc(alignUp(unpadded) - SEGMENT_HEADER_SIZE)); - }); - return Buffer.concat(chunks); -} - -function buildRootManifest(spec, bodyLength) { - const page = Buffer.alloc(ROOT_MANIFEST_SIZE); - ROOT_MANIFEST_MAGIC_BYTES.copy(page, 0x000); - page.writeUInt16LE(spec.formatVersion, 0x004); - page.writeUInt16LE(0, 0x006); // flags - page.writeBigUInt64LE(0n, 0x008); // l1_manifest_offset - page.writeBigUInt64LE(BigInt(Math.min(SEGMENT_HEADER_SIZE, bodyLength)), 0x010); - page.writeBigUInt64LE(BigInt(spec.vectorCount), 0x018); - page.writeUInt16LE(spec.dimension, 0x020); - page[0x022] = 1; // base_dtype: f32 - page[0x023] = 0; // profile_id - page.writeUInt32LE(1, 0x024); // epoch - page.writeBigUInt64LE(0n, 0x028); // created_ns - page.writeBigUInt64LE(0n, 0x030); // modified_ns - - if (spec.signed) { - page.writeUInt16LE(1, 0x098); // sig_algo: Ed25519 - page.writeUInt16LE(64, 0x09a); // sig_length - Buffer.alloc(64, 0xab).copy(page, 0x09c); // placeholder signature bytes - } - - Buffer.from(spec.fileId, 'hex').copy(page, 0xf00); - page.writeUInt32LE(crc32c(page.subarray(0, 0xffc)), 0xffc); - return page; -} - -const body = buildSegments(FIXTURE.segments); -const rvf = Buffer.concat([body, buildRootManifest(FIXTURE, body.length)]); - /** Narrow, concrete scopes: nothing here trips a manual-review trigger. */ const requests = [ { class: 'filesystem', scope: 'user-selected', rationale: 'reads only documents you choose' }, @@ -144,9 +73,7 @@ const project = defaultProject({ state: { checkpointing: true, recovery: true, rollbackSafeUntilStateSchema: 2 }, }); -const rvfPath = join(outDir, 'agent.rvf'); const projectPath = join(outDir, 'rvforge.json'); -writeFileSync(rvfPath, rvf); writeFileSync(projectPath, `${JSON.stringify(project, null, 2)}\n`, 'utf8'); -process.stdout.write(`${rvfPath}\n${projectPath}\n`); +process.stdout.write(`${projectPath}\n`);