fix(auth): isolate bundled runtime symbol

This commit is contained in:
Kayshen-X 2026-07-29 22:38:54 +08:00
parent 91fa60898e
commit dc92e63019
6 changed files with 548 additions and 5 deletions

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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"!<arch>\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<LinkCompatReport, LinkCompatError> {
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<u32, LinkCompatError> {
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<usize, LinkCompatError> {
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;
}
}

View file

@ -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,
})
}

View file

@ -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<u8>) -> Vec<u8> {
let payload_len = payload.len();
let mut archive = b"!<arch>\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
}