From da2c90fe9ea75548ca51886d5f31f6555e124cd1 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Wed, 29 Jul 2026 16:46:03 +0800 Subject: [PATCH] build(collab): enable local ABI v2 auth debugging --- crates/op-auth-bridge/Cargo.toml | 4 + crates/op-auth-bridge/README.md | 23 +++ crates/op-auth-bridge/build.rs | 131 +++++++++++++++--- crates/op-auth-bridge/src/lib.rs | 3 + crates/op-host-desktop/Cargo.toml | 3 + tools/check-collab-security-boundaries.sh | 10 +- .../check-collab-security-boundaries.test.sh | 32 ++++- 7 files changed, 184 insertions(+), 22 deletions(-) diff --git a/crates/op-auth-bridge/Cargo.toml b/crates/op-auth-bridge/Cargo.toml index d9100552f..118ac669e 100644 --- a/crates/op-auth-bridge/Cargo.toml +++ b/crates/op-auth-bridge/Cargo.toml @@ -16,6 +16,10 @@ default = [] # Deterministic, non-production signing keys for downstream integration tests. # Production verifier configuration never trusts this fixture issuer. test-issuer = [] +# Explicit opt-in for an unsigned, external op-auth archive in local debug +# builds. `build.rs` still requires the absolute archive path and ABI version, +# and rejects this feature outside Cargo's debug profile. +dev-abi-v2 = [] [dependencies] base64 = "0.22" diff --git a/crates/op-auth-bridge/README.md b/crates/op-auth-bridge/README.md index 1cc41e2b1..ad1b59385 100644 --- a/crates/op-auth-bridge/README.md +++ b/crates/op-auth-bridge/README.md @@ -35,6 +35,29 @@ decryptor and key with the application adds obscurity, not a security boundary. Production trust never depends on client artifact secrecy: signing keys and ticket issuance remain server-side. +## Local ABI-v2 development + +Developers can exercise the collaboration UI against a private ABI-v2 archive +without replacing the committed ABI-v1 compatibility artifact: + +```sh +OPENPENCIL_DEV_OP_AUTH_ARCHIVE=/absolute/path/to/libop_auth.a \ +OPENPENCIL_DEV_OP_AUTH_ABI_VERSION=2 \ +cargo build -p op-host-desktop --features dev-op-auth-abi-v2 +``` + +Using the override requires the feature and both variables together; enabling +the feature without either variable is a no-op so workspace `--all-features` +checks keep using the committed artifact. The archive path must be absolute, +must select a regular non-symlink file using the artifact name expected by the +current target, and is watched for changes by Cargo. The build script copies it +into Cargo's private build-output directory before linking. This override is +accepted only in Cargo's debug profile when target debug assertions are +enabled; release, release-derived, and hardened profiles reject it. It +deliberately skips release provenance only for a local, non-shipping binary. +The runtime ABI handshake and required collaboration symbols still fail +closed. + ## Regional login and collaboration trust The credential-bearing login/ticket origin and the public collaboration trust diff --git a/crates/op-auth-bridge/build.rs b/crates/op-auth-bridge/build.rs index aeeb531d5..229da075e 100644 --- a/crates/op-auth-bridge/build.rs +++ b/crates/op-auth-bridge/build.rs @@ -3,19 +3,27 @@ //! UI stays hidden. Open-source checkouts therefore always build. use std::env; +use std::ffi::OsStr; +use std::fs; use std::path::PathBuf; #[path = "prebuilt_provenance.rs"] mod prebuilt_provenance; +const DEV_ARCHIVE_ENV: &str = "OPENPENCIL_DEV_OP_AUTH_ARCHIVE"; +const DEV_ABI_VERSION_ENV: &str = "OPENPENCIL_DEV_OP_AUTH_ABI_VERSION"; +const DEV_FEATURE_ENV: &str = "CARGO_FEATURE_DEV_ABI_V2"; + fn main() { println!("cargo:rustc-check-cfg=cfg(op_auth_prebuilt)"); 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-env-changed={DEV_ARCHIVE_ENV}"); + println!("cargo:rerun-if-env-changed={DEV_ABI_VERSION_ENV}"); let target = env::var("TARGET").unwrap_or_default(); let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let prebuilt_dir = manifest_dir.join("prebuilt").join(&target); // MSVC static libraries follow the `.lib` convention; every // other target uses the Unix `lib.a` archive name. let artifact = if target.ends_with("-pc-windows-msvc") { @@ -23,28 +31,49 @@ fn main() { } else { "libop_auth.a" }; - let artifact_path = prebuilt_dir.join(artifact); - if !artifact_path.is_file() { - return; - } - let validated = match prebuilt_provenance::validate_prebuilt( - &manifest_dir.join("prebuilt"), - &prebuilt_dir, - &target, - artifact, - &env::var("CARGO_PKG_VERSION").unwrap_or_default(), - ) { - Ok(validated) => validated, - Err(error) => { - println!("cargo:warning=ignoring op-auth prebuilt: {error}"); - return; - } - }; - let abi_version = validated.abi_version; + + let development = development_prebuilt(artifact); + let (prebuilt_dir, abi_version, development_override, signed_provenance) = + 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) + } else { + let prebuilt_dir = manifest_dir.join("prebuilt").join(&target); + let artifact_path = prebuilt_dir.join(artifact); + if !artifact_path.is_file() { + return; + } + let validated = match prebuilt_provenance::validate_prebuilt( + &manifest_dir.join("prebuilt"), + &prebuilt_dir, + &target, + artifact, + &env::var("CARGO_PKG_VERSION").unwrap_or_default(), + ) { + Ok(validated) => validated, + Err(error) => { + println!("cargo:warning=ignoring op-auth prebuilt: {error}"); + return; + } + }; + ( + prebuilt_dir, + validated.abi_version, + false, + validated.signed_provenance, + ) + }; println!("cargo:rustc-cfg=op_auth_prebuilt"); if abi_version == 2 { - debug_assert!(validated.signed_provenance); + assert!( + development_override || signed_provenance, + "production ABI-v2 archives require signed provenance" + ); println!("cargo:rustc-cfg=op_auth_collab_ticket_prebuilt"); } println!("cargo:rustc-env=OP_AUTH_PREBUILT_ABI_VERSION={abi_version}"); @@ -66,3 +95,65 @@ fn main() { println!("cargo:rustc-link-lib=ntdll"); } } + +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); + let abi_version = env::var_os(DEV_ABI_VERSION_ENV); + let (archive, abi_version) = match (feature_enabled, archive, abi_version) { + (_, None, None) => return None, + (true, Some(archive), Some(abi_version)) => (archive, abi_version), + (false, _, _) => { + panic!("{DEV_ARCHIVE_ENV} requires the op-auth-bridge/dev-abi-v2 feature"); + } + _ => { + panic!( + "op-auth-bridge/dev-abi-v2 requires {DEV_ARCHIVE_ENV} and {DEV_ABI_VERSION_ENV}" + ); + } + }; + + let debug_build = env::var("PROFILE").is_ok_and(|profile| profile == "debug"); + assert!( + debug_build, + "{DEV_ARCHIVE_ENV} is accepted only in Cargo's debug profile" + ); + + let requested_archive = PathBuf::from(archive); + assert!( + requested_archive.is_absolute(), + "{DEV_ARCHIVE_ENV} must be an absolute path" + ); + let metadata = fs::symlink_metadata(&requested_archive) + .unwrap_or_else(|_| panic!("{DEV_ARCHIVE_ENV} is missing or unreadable")); + assert!( + metadata.file_type().is_file(), + "{DEV_ARCHIVE_ENV} must select a regular non-symlink file" + ); + let archive = fs::canonicalize(&requested_archive) + .unwrap_or_else(|_| panic!("{DEV_ARCHIVE_ENV} is missing or unreadable")); + assert!( + archive.is_file(), + "{DEV_ARCHIVE_ENV} must select a regular file" + ); + assert_eq!( + archive.file_name(), + Some(OsStr::new(artifact)), + "{DEV_ARCHIVE_ENV} must select the target's {artifact}" + ); + + let abi_version = abi_version + .into_string() + .ok() + .filter(|value| value == "2") + .map(|_| 2) + .unwrap_or_else(|| panic!("{DEV_ABI_VERSION_ENV} must be exactly 2")); + let directory = PathBuf::from( + env::var("OUT_DIR").expect("Cargo provides OUT_DIR to the op-auth build script"), + ); + fs::copy(&archive, directory.join(artifact)) + .unwrap_or_else(|_| panic!("failed to stage {DEV_ARCHIVE_ENV} in OUT_DIR")); + println!("cargo:rerun-if-changed={}", requested_archive.display()); + println!("cargo:rerun-if-changed={}", archive.display()); + Some((directory, abi_version)) +} diff --git a/crates/op-auth-bridge/src/lib.rs b/crates/op-auth-bridge/src/lib.rs index 7064592f8..f1ec2eadc 100644 --- a/crates/op-auth-bridge/src/lib.rs +++ b/crates/op-auth-bridge/src/lib.rs @@ -16,6 +16,9 @@ //! validation, and bounded JWKS caching are entirely open source. The private //! library may only issue opaque tickets from its authenticated device session. +#[cfg(all(op_auth_development_prebuilt, not(debug_assertions)))] +compile_error!("the local op-auth archive override requires target debug assertions"); + mod collab_claims; mod collab_jwks; mod collab_jwks_cache; diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index 8113b8a92..c0d8b8adc 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -16,6 +16,9 @@ path = "src/main.rs" [features] default = [] mcp-debug-tools = ["op-mcp/debug-tools"] +# Local-only collaboration development against an external private ABI-v2 +# archive. Release builds are rejected by op-auth-bridge's build script. +dev-op-auth-abi-v2 = ["op-auth-bridge/dev-abi-v2"] # File-association metadata for `cargo-bundle` (`cargo bundle`). This # declares OpenPencil as the OS-level handler for `.op` / `.pen` diff --git a/tools/check-collab-security-boundaries.sh b/tools/check-collab-security-boundaries.sh index 7b7f2da3f..333a120b9 100644 --- a/tools/check-collab-security-boundaries.sh +++ b/tools/check-collab-security-boundaries.sh @@ -27,6 +27,7 @@ collab_scan_roots=( crates/op-editor-ui/src crates/op-host-native/src crates/op-host-desktop/src + crates/op-host-services/src/profile_avatar_fetch.rs crates/op-host-services/src/public_https_client.rs crates/op-host-services/src/provider_dial.rs crates/op-host-services/src/web_credentials.rs @@ -512,9 +513,16 @@ for avatar_anchor in \ "MAX_AVATAR_ENCODED_BYTES" \ "public_https_client" \ "REQUEST_TIMEOUT"; do - require_literal crates/op-host-desktop/src/collab_avatar_host.rs \ + require_literal crates/op-host-services/src/profile_avatar_fetch.rs \ "$avatar_anchor" "bounded collaboration avatar fetch" done +for desktop_avatar_anchor in \ + "request.is_current_account()" \ + "fetch_account_avatar_blocking(request.url())" \ + "fetch_profile_avatar_blocking(request.url())"; do + require_literal crates/op-host-desktop/src/collab_avatar_host.rs \ + "$desktop_avatar_anchor" "desktop avatar security-policy delegation" +done require_literal crates/op-host-services/src/provider_dial.rs \ ".no_proxy()" "public HTTPS proxy bypass prevention" require_literal crates/op-host-services/src/provider_dial.rs \ diff --git a/tools/check-collab-security-boundaries.test.sh b/tools/check-collab-security-boundaries.test.sh index a3c0e6a26..b5d50651a 100644 --- a/tools/check-collab-security-boundaries.test.sh +++ b/tools/check-collab-security-boundaries.test.sh @@ -332,11 +332,21 @@ EOF fn public_fixture_is_explicitly_enabled() {} EOF - cat > "$fixture_root/crates/op-host-desktop/src/collab_avatar_host.rs" <<'EOF' + cat > "$fixture_root/crates/op-host-services/src/profile_avatar_fetch.rs" <<'EOF' const MAX_REDIRECTS: usize = 3; const REQUEST_TIMEOUT: u64 = 5; const MAX_AVATAR_ENCODED_BYTES: usize = 1024; fn public_https_client() {} +EOF + + cat > "$fixture_root/crates/op-host-desktop/src/collab_avatar_host.rs" <<'EOF' +fn dispatch(request: AvatarRequest) { + if request.is_current_account() { + fetch_account_avatar_blocking(request.url()); + } else { + fetch_profile_avatar_blocking(request.url()); + } +} EOF cat > "$fixture_root/crates/op-host-services/src/public_https_client.rs" <<'EOF' @@ -634,6 +644,26 @@ new_fixture desktop-sensitive-file expect_failure "rejects sensitive files in desktop collaboration integration" \ "sensitive key/token-shaped files are forbidden" +new_fixture avatar-redirect-limit-removed +sed '/MAX_REDIRECTS/d' \ + "$fixture_root/crates/op-host-services/src/profile_avatar_fetch.rs" \ + > "$fixture_root/crates/op-host-services/src/profile_avatar_fetch.rs.next" +mv \ + "$fixture_root/crates/op-host-services/src/profile_avatar_fetch.rs.next" \ + "$fixture_root/crates/op-host-services/src/profile_avatar_fetch.rs" +expect_failure "requires the shared avatar redirect limit" \ + "bounded collaboration avatar fetch" + +new_fixture desktop-public-avatar-delegation-removed +sed '/fetch_profile_avatar_blocking(request.url())/d' \ + "$fixture_root/crates/op-host-desktop/src/collab_avatar_host.rs" \ + > "$fixture_root/crates/op-host-desktop/src/collab_avatar_host.rs.next" +mv \ + "$fixture_root/crates/op-host-desktop/src/collab_avatar_host.rs.next" \ + "$fixture_root/crates/op-host-desktop/src/collab_avatar_host.rs" +expect_failure "requires public-only desktop collaboration avatar delegation" \ + "desktop avatar security-policy delegation" + new_fixture avatar-proxy-bypass-removed : > "$fixture_root/crates/op-host-services/src/provider_dial.rs" expect_failure "requires proxy-free pinned avatar dialing" \