diff --git a/crates/op-auth-bridge/README.md b/crates/op-auth-bridge/README.md index ad1b59385..fed2fee2e 100644 --- a/crates/op-auth-bridge/README.md +++ b/crates/op-auth-bridge/README.md @@ -21,6 +21,18 @@ been retroactively stripped, encrypted, or described as obfuscated because an in-place binary rewrite could break final linkage. Run `tools/check-op-auth-prebuilt.sh` for the current measured audit. +The Linux and MSVC artifacts were built as C-facing Rust `staticlib` archives, +so they also contain the producing toolchain's Rust runtime. Before a Rust host +link, `build.rs` validates the original archive, rechecks that the bytes being +staged have the validated digest, and creates a private `OUT_DIR` copy in which +the equal-length `rust_eh_personality` symbol name is namespaced to +`rust_eh_personalitx`. The one-byte suffix change also preserves the sorted +MSVC linker-member index. This updates the definition and its internal +references without changing archive offsets, keeps the committed SHA/signature +as the trust anchor, and avoids a broad linker multiple-definition exception. +Malformed archives, changed bytes, and archives containing both names fail +closed. + Production ABI-v2 artifacts fail closed unless their byte hash, target, ABI, source revision, build id, and `op-auth-hardened-v1` declaration are covered by an Ed25519 signature rooted in `prebuilt/PROVENANCE_PUBKEY`. The private release diff --git a/crates/op-auth-bridge/build.rs b/crates/op-auth-bridge/build.rs index 229da075e..2bb019821 100644 --- a/crates/op-auth-bridge/build.rs +++ b/crates/op-auth-bridge/build.rs @@ -5,8 +5,10 @@ use std::env; use std::ffi::OsStr; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +#[path = "prebuilt_link_compat.rs"] +mod prebuilt_link_compat; #[path = "prebuilt_provenance.rs"] mod prebuilt_provenance; @@ -19,6 +21,7 @@ fn main() { println!("cargo:rustc-check-cfg=cfg(op_auth_collab_ticket_prebuilt)"); println!("cargo:rustc-check-cfg=cfg(op_auth_development_prebuilt)"); println!("cargo:rerun-if-changed=prebuilt"); + println!("cargo:rerun-if-changed=prebuilt_link_compat.rs"); println!("cargo:rerun-if-env-changed={DEV_ARCHIVE_ENV}"); println!("cargo:rerun-if-env-changed={DEV_ABI_VERSION_ENV}"); @@ -33,14 +36,14 @@ fn main() { }; let development = development_prebuilt(artifact); - let (prebuilt_dir, abi_version, development_override, signed_provenance) = + let (prebuilt_dir, abi_version, development_override, signed_provenance, expected_sha256) = if let Some((directory, abi_version)) = development { println!( "cargo:warning=using unsigned local op-auth ABI {abi_version} \ archive for a debug build" ); println!("cargo:rustc-cfg=op_auth_development_prebuilt"); - (directory, abi_version, true, false) + (directory, abi_version, true, false, None) } else { let prebuilt_dir = manifest_dir.join("prebuilt").join(&target); let artifact_path = prebuilt_dir.join(artifact); @@ -65,6 +68,7 @@ fn main() { validated.abi_version, false, validated.signed_provenance, + Some(validated.archive_sha256), ) }; @@ -77,7 +81,8 @@ fn main() { println!("cargo:rustc-cfg=op_auth_collab_ticket_prebuilt"); } println!("cargo:rustc-env=OP_AUTH_PREBUILT_ABI_VERSION={abi_version}"); - println!("cargo:rustc-link-search=native={}", prebuilt_dir.display()); + let link_dir = rust_host_link_directory(&target, &prebuilt_dir, artifact, expected_sha256); + println!("cargo:rustc-link-search=native={}", link_dir.display()); // `-bundle`: keep the archive out of this crate's rlib and hand it to // the final link instead. Bundled foreign objects would otherwise be // fed to thin-LTO in release builds, which fails with "failed to get @@ -96,6 +101,36 @@ fn main() { } } +fn rust_host_link_directory( + target: &str, + prebuilt_dir: &Path, + artifact: &str, + expected_sha256: Option<[u8; 32]>, +) -> PathBuf { + if !target.contains("linux") && !target.ends_with("-pc-windows-msvc") { + return prebuilt_dir.to_path_buf(); + } + + let link_dir = PathBuf::from( + env::var("OUT_DIR").expect("Cargo provides OUT_DIR to the op-auth build script"), + ) + .join("rust-host-link"); + let report = prebuilt_link_compat::stage_archive_for_rust_host( + &prebuilt_dir.join(artifact), + &link_dir.join(artifact), + expected_sha256, + ) + .unwrap_or_else(|error| panic!("failed to stage op-auth for Rust host linking: {error}")); + if report.renamed_occurrences != 0 { + println!( + "cargo:warning=isolated {} bundled Rust personality symbol occurrence(s) \ + in the temporary op-auth link archive", + report.renamed_occurrences + ); + } + link_dir +} + fn development_prebuilt(artifact: &str) -> Option<(PathBuf, u32)> { let feature_enabled = env::var_os(DEV_FEATURE_ENV).is_some(); let archive = env::var_os(DEV_ARCHIVE_ENV); diff --git a/crates/op-auth-bridge/prebuilt/README.md b/crates/op-auth-bridge/prebuilt/README.md index bbe8e383b..c1490c201 100644 --- a/crates/op-auth-bridge/prebuilt/README.md +++ b/crates/op-auth-bridge/prebuilt/README.md @@ -22,6 +22,14 @@ Do not run `strip`, `objcopy`, or an obfuscator in place on these committed files: archive members and cross-object symbols may be required by the final link. +Linux and MSVC Rust hosts link a temporary Cargo `OUT_DIR` derivative, not +these committed bytes. The build bridge gives the archive's bundled +`rust_eh_personality` an equal-length private name, including its internal +references, because a C-facing Rust `staticlib` otherwise conflicts with the +host toolchain's own personality routine. The transformation happens only +after provenance validation, preserves archive layout, and rejects ambiguous +input; it never weakens the linker's duplicate-symbol checks. + ## ABI-v2 signed provenance Every production target directory must contain: @@ -55,6 +63,8 @@ full source revision. At minimum: temporary-directory prefixes; - use one narrow `extern "C"` wrapper and keep every other implementation symbol hidden from the final binary; +- namespace any Rust runtime symbols that must remain in the archive so they + cannot collide when the C ABI is linked into a different Rust toolchain; - enable fat LTO, one codegen unit, dead-code elimination, and symbol stripping at the final application link; - use `panic=abort` for the static library if the private implementation can diff --git a/crates/op-auth-bridge/prebuilt_link_compat.rs b/crates/op-auth-bridge/prebuilt_link_compat.rs new file mode 100644 index 000000000..73682d794 --- /dev/null +++ b/crates/op-auth-bridge/prebuilt_link_compat.rs @@ -0,0 +1,318 @@ +//! Deterministic compatibility staging for C-facing Rust static libraries. +//! +//! A Rust `staticlib` contains its own Rust runtime because it is normally +//! linked by a C application. When that archive is linked back into a Rust +//! executable, newer ELF/COFF linkers reject the archive's +//! `rust_eh_personality` alongside the host toolchain's definition. The +//! original, provenance-checked archive stays untouched. Only the private +//! Cargo `OUT_DIR` copy is rewritten, using an equal-length private symbol +//! name so archive offsets, object symbol indexes, and relocations remain +//! unchanged. + +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +const ARCHIVE_MAGIC: &[u8] = b"!\n"; +const HOST_PERSONALITY: &[u8] = b"rust_eh_personality"; +// Keep the alias adjacent to the original name. The MSVC archive's second +// linker member is lexically sorted and some linkers binary-search it; moving +// the entry to an `op_auth_*` prefix would invalidate that index. +const PRIVATE_PERSONALITY: &[u8] = b"rust_eh_personalitx"; +const _: [(); HOST_PERSONALITY.len()] = [(); PRIVATE_PERSONALITY.len()]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LinkCompatReport { + pub renamed_occurrences: usize, + pub existing_private_occurrences: usize, +} + +#[derive(Debug)] +pub enum LinkCompatError { + Io { + operation: &'static str, + source: io::Error, + }, + InvalidArchive(&'static str), + ConflictingPersonalitySymbols, + SourceDigestMismatch, + SourceEqualsDestination, +} + +impl fmt::Display for LinkCompatError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { operation, source } => write!(formatter, "{operation}: {source}"), + Self::InvalidArchive(reason) => { + write!(formatter, "static archive is malformed: {reason}") + } + Self::ConflictingPersonalitySymbols => formatter + .write_str("archive contains both host and private Rust personality symbol names"), + Self::SourceDigestMismatch => { + formatter.write_str("source archive changed after provenance validation") + } + Self::SourceEqualsDestination => { + formatter.write_str("compatibility staging must not modify the source archive") + } + } + } +} + +impl std::error::Error for LinkCompatError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +pub fn stage_archive_for_rust_host( + source: &Path, + destination: &Path, + expected_sha256: Option<[u8; 32]>, +) -> Result { + if source == destination { + return Err(LinkCompatError::SourceEqualsDestination); + } + + let mut archive = fs::read(source).map_err(|source| LinkCompatError::Io { + operation: "failed to read source archive", + source, + })?; + if expected_sha256 + .is_some_and(|expected| <[u8; 32]>::from(Sha256::digest(&archive)) != expected) + { + return Err(LinkCompatError::SourceDigestMismatch); + } + validate_archive(&archive)?; + + let host_count = count_occurrences(&archive, HOST_PERSONALITY); + let private_count = count_occurrences(&archive, PRIVATE_PERSONALITY); + if host_count != 0 && private_count != 0 { + return Err(LinkCompatError::ConflictingPersonalitySymbols); + } + + if host_count != 0 { + replace_equal_length(&mut archive, HOST_PERSONALITY, PRIVATE_PERSONALITY); + } + if count_occurrences(&archive, HOST_PERSONALITY) != 0 { + return Err(LinkCompatError::InvalidArchive( + "host Rust personality symbol remained after staging", + )); + } + validate_archive(&archive)?; + + let parent = destination.parent().ok_or(LinkCompatError::InvalidArchive( + "staged archive path has no parent", + ))?; + fs::create_dir_all(parent).map_err(|source| LinkCompatError::Io { + operation: "failed to create archive staging directory", + source, + })?; + fs::write(destination, archive).map_err(|source| LinkCompatError::Io { + operation: "failed to write staged archive", + source, + })?; + + Ok(LinkCompatReport { + renamed_occurrences: host_count, + existing_private_occurrences: private_count, + }) +} + +fn validate_archive(archive: &[u8]) -> Result<(), LinkCompatError> { + if !archive.starts_with(ARCHIVE_MAGIC) { + return Err(LinkCompatError::InvalidArchive( + "missing portable archive magic", + )); + } + + let mut offset = ARCHIVE_MAGIC.len(); + let mut member_count = 0_usize; + let mut linker_member_count = 0_usize; + while offset < archive.len() { + let header_end = offset + .checked_add(60) + .ok_or(LinkCompatError::InvalidArchive("member header overflow"))?; + let header = archive + .get(offset..header_end) + .ok_or(LinkCompatError::InvalidArchive("truncated member header"))?; + if &header[58..60] != b"`\n" { + return Err(LinkCompatError::InvalidArchive( + "invalid member header terminator", + )); + } + + let size = parse_decimal(&header[48..58])?; + let data_end = header_end + .checked_add(size) + .ok_or(LinkCompatError::InvalidArchive("member size overflow"))?; + if data_end > archive.len() { + return Err(LinkCompatError::InvalidArchive( + "member extends beyond archive", + )); + } + if is_linker_member_name(&header[..16]) { + linker_member_count += 1; + if linker_member_count == 2 { + validate_msvc_sorted_linker_member(&archive[header_end..data_end])?; + } + } + + offset = data_end + .checked_add(size & 1) + .ok_or(LinkCompatError::InvalidArchive("member padding overflow"))?; + if offset > archive.len() { + return Err(LinkCompatError::InvalidArchive("truncated member padding")); + } + member_count += 1; + } + + if member_count == 0 { + return Err(LinkCompatError::InvalidArchive("archive has no members")); + } + Ok(()) +} + +fn is_linker_member_name(field: &[u8]) -> bool { + let end = field + .iter() + .rposition(|byte| *byte != b' ') + .map_or(0, |index| index + 1); + &field[..end] == b"/" +} + +fn validate_msvc_sorted_linker_member(member: &[u8]) -> Result<(), LinkCompatError> { + let archive_member_count = read_u32_le(member, 0)? as usize; + if archive_member_count > u16::MAX as usize { + return Err(LinkCompatError::InvalidArchive( + "MSVC linker member has too many archive members", + )); + } + let offsets_end = + 4_usize + .checked_add(archive_member_count.checked_mul(4).ok_or( + LinkCompatError::InvalidArchive("MSVC linker member offset table overflow"), + )?) + .ok_or(LinkCompatError::InvalidArchive( + "MSVC linker member offset table overflow", + ))?; + let symbol_count = read_u32_le(member, offsets_end)? as usize; + let indices_start = offsets_end + .checked_add(4) + .ok_or(LinkCompatError::InvalidArchive( + "MSVC linker member symbol table overflow", + ))?; + let names_start = indices_start + .checked_add( + symbol_count + .checked_mul(2) + .ok_or(LinkCompatError::InvalidArchive( + "MSVC linker member index table overflow", + ))?, + ) + .ok_or(LinkCompatError::InvalidArchive( + "MSVC linker member index table overflow", + ))?; + if names_start > member.len() { + return Err(LinkCompatError::InvalidArchive( + "truncated MSVC linker member index table", + )); + } + + for index in 0..symbol_count { + let entry = indices_start + index * 2; + let member_index = u16::from_le_bytes([member[entry], member[entry + 1]]) as usize; + if member_index == 0 || member_index > archive_member_count { + return Err(LinkCompatError::InvalidArchive( + "MSVC linker member index is out of range", + )); + } + } + + let mut name_offset = names_start; + let mut previous_name: Option<&[u8]> = None; + for _ in 0..symbol_count { + let remaining = &member[name_offset..]; + let name_len = + remaining + .iter() + .position(|byte| *byte == 0) + .ok_or(LinkCompatError::InvalidArchive( + "unterminated MSVC linker member symbol", + ))?; + let name = &remaining[..name_len]; + if name.is_empty() { + return Err(LinkCompatError::InvalidArchive( + "empty MSVC linker member symbol", + )); + } + if previous_name.is_some_and(|previous| previous > name) { + return Err(LinkCompatError::InvalidArchive( + "MSVC linker member symbols are not sorted", + )); + } + previous_name = Some(name); + name_offset = + name_offset + .checked_add(name_len + 1) + .ok_or(LinkCompatError::InvalidArchive( + "MSVC linker member symbol table overflow", + ))?; + } + Ok(()) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(4) + .ok_or(LinkCompatError::InvalidArchive( + "MSVC linker member integer overflow", + ))?; + let encoded: [u8; 4] = bytes + .get(offset..end) + .ok_or(LinkCompatError::InvalidArchive( + "truncated MSVC linker member integer", + ))? + .try_into() + .map_err(|_| LinkCompatError::InvalidArchive("invalid MSVC linker member integer"))?; + Ok(u32::from_le_bytes(encoded)) +} + +fn parse_decimal(field: &[u8]) -> Result { + let text = std::str::from_utf8(field) + .map_err(|_| LinkCompatError::InvalidArchive("member size is not ASCII"))? + .trim(); + if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(LinkCompatError::InvalidArchive( + "member size is not decimal", + )); + } + text.parse() + .map_err(|_| LinkCompatError::InvalidArchive("member size is out of range")) +} + +fn count_occurrences(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .filter(|candidate| *candidate == needle) + .count() +} + +fn replace_equal_length(haystack: &mut [u8], needle: &[u8], replacement: &[u8]) { + debug_assert_eq!(needle.len(), replacement.len()); + let mut offset = 0_usize; + while let Some(relative) = haystack[offset..] + .windows(needle.len()) + .position(|candidate| candidate == needle) + { + let start = offset + relative; + let end = start + needle.len(); + haystack[start..end].copy_from_slice(replacement); + offset = end; + } +} diff --git a/crates/op-auth-bridge/prebuilt_provenance.rs b/crates/op-auth-bridge/prebuilt_provenance.rs index 6a520cf25..d31c797c0 100644 --- a/crates/op-auth-bridge/prebuilt_provenance.rs +++ b/crates/op-auth-bridge/prebuilt_provenance.rs @@ -19,6 +19,7 @@ pub const HARDENING_PROFILE_V1: &str = "op-auth-hardened-v1"; pub struct ValidatedPrebuilt { pub abi_version: u32, pub signed_provenance: bool, + pub archive_sha256: [u8; 32], } #[derive(Debug)] @@ -40,7 +41,9 @@ pub fn validate_prebuilt( let artifact_path = target_dir.join(artifact_name); let artifact = fs::read(&artifact_path) .map_err(|_| ProvenanceError("artifact is missing or unreadable"))?; - let actual_sha256 = format!("{:x}", Sha256::digest(&artifact)); + let digest = Sha256::digest(&artifact); + let actual_sha256 = format!("{digest:x}"); + let archive_sha256: [u8; 32] = digest.into(); let expected_sha256 = read_trimmed(&target_dir.join("SHA256"), "SHA256 is missing")?; if !valid_hex(&expected_sha256, 64) || !actual_sha256.eq_ignore_ascii_case(&expected_sha256) { return Err(ProvenanceError("artifact SHA-256 does not match")); @@ -56,6 +59,7 @@ pub fn validate_prebuilt( return Ok(ValidatedPrebuilt { abi_version, signed_provenance: false, + archive_sha256, }); } if version != package_version { @@ -98,6 +102,7 @@ pub fn validate_prebuilt( Ok(ValidatedPrebuilt { abi_version, signed_provenance: true, + archive_sha256, }) } diff --git a/crates/op-auth-bridge/tests/prebuilt_link_compat.rs b/crates/op-auth-bridge/tests/prebuilt_link_compat.rs new file mode 100644 index 000000000..75f30e852 --- /dev/null +++ b/crates/op-auth-bridge/tests/prebuilt_link_compat.rs @@ -0,0 +1,163 @@ +#[path = "../prebuilt_link_compat.rs"] +mod prebuilt_link_compat; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use prebuilt_link_compat::{stage_archive_for_rust_host, LinkCompatError}; +use sha2::{Digest, Sha256}; + +const HOST_PERSONALITY: &[u8] = b"rust_eh_personality"; +const PRIVATE_PERSONALITY: &[u8] = b"rust_eh_personalitx"; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1); + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "op-auth-link-compat-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn namespaces_personality_in_every_elf_and_coff_prebuilt() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let targets = [ + ("x86_64-unknown-linux-gnu", "libop_auth.a"), + ("aarch64-unknown-linux-gnu", "libop_auth.a"), + ("x86_64-pc-windows-msvc", "op_auth.lib"), + ("aarch64-pc-windows-msvc", "op_auth.lib"), + ]; + + for (target, artifact) in targets { + let source = manifest_dir.join("prebuilt").join(target).join(artifact); + let original = fs::read(&source).unwrap(); + let temp = TempDir::new(); + let staged = temp.0.join(artifact); + let expected_sha256 = Sha256::digest(&original).into(); + let report = stage_archive_for_rust_host(&source, &staged, Some(expected_sha256)).unwrap(); + let staged_bytes = fs::read(&staged).unwrap(); + + assert!( + report.renamed_occurrences > 0, + "{target} must exercise the compatibility transform" + ); + assert_eq!( + report.existing_private_occurrences, 0, + "{target} unexpectedly already contains the private symbol" + ); + assert_eq!(fs::read(&source).unwrap(), original); + assert_eq!(staged_bytes.len(), original.len()); + assert!(!contains(&staged_bytes, HOST_PERSONALITY)); + assert!(contains(&staged_bytes, PRIVATE_PERSONALITY)); + } +} + +#[test] +fn already_namespaced_archive_is_copied_without_further_changes() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = manifest_dir + .join("prebuilt") + .join("x86_64-unknown-linux-gnu") + .join("libop_auth.a"); + let first_temp = TempDir::new(); + let first = first_temp.0.join("libop_auth.a"); + stage_archive_for_rust_host(&source, &first, None).unwrap(); + + let second_temp = TempDir::new(); + let second = second_temp.0.join("libop_auth.a"); + let report = stage_archive_for_rust_host(&first, &second, None).unwrap(); + assert_eq!(report.renamed_occurrences, 0); + assert!(report.existing_private_occurrences > 0); + assert_eq!(fs::read(first).unwrap(), fs::read(second).unwrap()); +} + +#[test] +fn rejects_malformed_or_conflicting_archives_without_output() { + let temp = TempDir::new(); + let malformed = temp.0.join("malformed.a"); + fs::write(&malformed, b"not an archive").unwrap(); + let destination = temp.0.join("staged.a"); + assert!(matches!( + stage_archive_for_rust_host(&malformed, &destination, None), + Err(LinkCompatError::InvalidArchive(_)) + )); + assert!(!destination.exists()); + + let conflicting = temp.0.join("conflicting.a"); + fs::write( + &conflicting, + archive_with_member([HOST_PERSONALITY, b"\0", PRIVATE_PERSONALITY, b"\0"].concat()), + ) + .unwrap(); + assert!(matches!( + stage_archive_for_rust_host(&conflicting, &destination, None), + Err(LinkCompatError::ConflictingPersonalitySymbols) + )); + assert!(!destination.exists()); +} + +#[test] +fn refuses_to_rewrite_the_source_path() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = manifest_dir + .join("prebuilt") + .join("x86_64-unknown-linux-gnu") + .join("libop_auth.a"); + assert!(matches!( + stage_archive_for_rust_host(&source, &source, None), + Err(LinkCompatError::SourceEqualsDestination) + )); +} + +#[test] +fn rejects_source_bytes_that_do_not_match_validated_digest() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = manifest_dir + .join("prebuilt") + .join("x86_64-unknown-linux-gnu") + .join("libop_auth.a"); + let temp = TempDir::new(); + let destination = temp.0.join("libop_auth.a"); + assert!(matches!( + stage_archive_for_rust_host(&source, &destination, Some([0_u8; 32])), + Err(LinkCompatError::SourceDigestMismatch) + )); + assert!(!destination.exists()); +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|candidate| candidate == needle) +} + +fn archive_with_member(mut payload: Vec) -> Vec { + let payload_len = payload.len(); + let mut archive = b"!\n".to_vec(); + let header = format!( + "{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n", + "fixture.o/", 0, 0, 0, 0, payload_len + ); + assert_eq!(header.len(), 60); + archive.extend_from_slice(header.as_bytes()); + archive.append(&mut payload); + if !payload_len.is_multiple_of(2) { + archive.push(b'\n'); + } + archive +}