diff --git a/.github/workflows/cross-port-interop.yml b/.github/workflows/cross-port-interop.yml
new file mode 100644
index 0000000..5d7aa97
--- /dev/null
+++ b/.github/workflows/cross-port-interop.yml
@@ -0,0 +1,171 @@
+name: CI / Cross-Port Interop
+
+# Runs every PCF-SIG writer (Rust reference + .NET + PHP + TypeScript) and
+# asserts all four produce the byte-identical canonical 966-byte signed
+# container. The reference also checks the shipped testdata/canonical.bin in
+# each port directory (covered by the Rust workspace test
+# `cross_port_testdata`), but this job goes one step further: it regenerates
+# each writer's output from scratch and compares the bytes, catching writer-
+# side drift that would silently pass if a port happened to keep its own
+# committed testdata in sync with itself but produced different bytes at run
+# time.
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - 'reference/PCF-SIG-v1.0/**'
+ - 'implementations/**/pcf-sig/**'
+ - 'implementations/ts/package.json'
+ - 'implementations/ts/package-lock.json'
+ - 'implementations/dotnet/Directory.Build.props'
+ - '.github/workflows/cross-port-interop.yml'
+ pull_request:
+ branches: [master]
+ paths:
+ - 'reference/PCF-SIG-v1.0/**'
+ - 'implementations/**/pcf-sig/**'
+ - 'implementations/ts/package.json'
+ - 'implementations/ts/package-lock.json'
+ - 'implementations/dotnet/Directory.Build.props'
+ - '.github/workflows/cross-port-interop.yml'
+
+# Expected SHA-256 of the canonical 966-byte vector. Pinned here so the job
+# fails loudly if every writer drifts together (e.g. a regenerated reference
+# that propagated to all four ports but is no longer the spec test vector).
+env:
+ EXPECTED_SHA256: b158e2f5b160d72cea3226af2041f8d18aa75b3db6cb85faeca5df7879871307
+
+jobs:
+ cross-port-byte-exact:
+ name: all writers produce identical bytes
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: implementations/ts/package-lock.json
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: hash, mbstring, sodium
+ coverage: none
+ tools: composer:v2
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ # ---- Rust reference writer --------------------------------------------
+ - name: Generate Rust reference vector
+ run: |
+ cargo run -p pcf-sig --example gen_testvector -- /tmp/rust.bin
+
+ # ---- TypeScript writer ------------------------------------------------
+ - name: Install npm deps and build pcf
+ working-directory: implementations/ts
+ run: |
+ npm ci
+ npm run build -w @kduma-oss/pcf
+ - name: Generate TS vector
+ working-directory: implementations/ts
+ run: |
+ npm run gen-testvector -w @kduma-oss/pcf-sig -- /tmp/ts.bin
+
+ # ---- PHP writer -------------------------------------------------------
+ - name: Install composer deps
+ working-directory: implementations/php/pcf-sig
+ run: composer install --prefer-dist --no-progress --no-interaction
+ - name: Generate PHP vector
+ working-directory: implementations/php/pcf-sig
+ run: php examples/gen_testvector.php /tmp/php.bin
+
+ # ---- .NET writer ------------------------------------------------------
+ # The PCF-SIG .NET tests already include a CanonicalVectorTests suite
+ # that asserts the writer matches the shipped testdata; here we build a
+ # tiny CLI on the fly that writes its output to a path so the bytes can
+ # be compared with the other three.
+ - name: Generate .NET vector
+ run: |
+ mkdir -p /tmp/dotnet-gen
+ cat > /tmp/dotnet-gen/GenTestVector.csproj <<'EOF'
+
+
+ Exe
+ net8.0
+ disable
+
+
+
+
+
+ EOF
+ cat > /tmp/dotnet-gen/Program.cs <<'EOF'
+ using System;
+ using System.IO;
+ using System.Text;
+ using Pcf;
+ using Pcf.Sig;
+ var seed = new byte[32];
+ for (int i = 0; i < 32; i++) seed[i] = (byte)i;
+ var signer = SigningMaterial.Ed25519FromSeed(seed);
+ var ms = new MemoryStream();
+ var c = Container.CreateWith(ms, 8, HashAlgo.Sha256);
+ var alphaUid = new byte[16]; for (int i = 0; i < 16; i++) alphaUid[i] = 0x11;
+ var sigUid = new byte[16]; for (int i = 0; i < 16; i++) sigUid[i] = 0x33;
+ var keyUid = new byte[16]; for (int i = 0; i < 16; i++) keyUid[i] = 0x22;
+ c.AddPartition(0x10, alphaUid, "alpha", Encoding.UTF8.GetBytes("Hello, PCF-SIG!"), 0, HashAlgo.Sha256);
+ SignPartitions.Run(c, signer, new[] { alphaUid }, sigUid, keyUid, 0, "pcfsig", "pcfkey");
+ File.WriteAllBytes(args[0], c.CompactedImage());
+ EOF
+ dotnet run --project /tmp/dotnet-gen/GenTestVector.csproj -c Release -- /tmp/dotnet.bin
+
+ # ---- Compare ----------------------------------------------------------
+ - name: All four writers agree on byte-exact output
+ run: |
+ set -euo pipefail
+ ls -l /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin
+ declare -A digests
+ for f in /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do
+ d=$(sha256sum "$f" | awk '{print $1}')
+ echo "$f -> $d"
+ digests[$f]=$d
+ done
+ # Every digest must equal EXPECTED_SHA256
+ fail=0
+ for f in /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do
+ if [ "${digests[$f]}" != "$EXPECTED_SHA256" ]; then
+ echo "::error::$f sha256 = ${digests[$f]} (expected $EXPECTED_SHA256)"
+ fail=1
+ fi
+ done
+ # And byte-for-byte equal to each other (paranoia — sha256 collisions
+ # below 2^128 work are not credible, but cmp is free).
+ for f in /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do
+ if ! cmp -s /tmp/rust.bin "$f"; then
+ echo "::error::$f differs from /tmp/rust.bin"
+ fail=1
+ fi
+ done
+ if [ "$fail" != "0" ]; then
+ echo "Cross-port writer interop FAILED"
+ exit 1
+ fi
+ echo "All four writers produced sha256 = $EXPECTED_SHA256"
+
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: cross-port-vectors
+ path: |
+ /tmp/rust.bin
+ /tmp/ts.bin
+ /tmp/php.bin
+ /tmp/dotnet.bin
diff --git a/reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs b/reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs
new file mode 100644
index 0000000..dac5d7c
--- /dev/null
+++ b/reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs
@@ -0,0 +1,89 @@
+//! Cross-port test-vector parity check.
+//!
+//! Every PCF-SIG language port ships its own copy of the canonical 966-byte
+//! signed-container vector under `implementations//pcf-sig/testdata/
+//! canonical.bin`. Each port's own test suite asserts that its writer
+//! produces this byte sequence; this Rust workspace test additionally
+//! asserts that the four shipped *files* are byte-identical, so that any
+//! future regeneration of the reference vector cannot leave one port out of
+//! sync.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// The reference vector compiled into the test binary.
+const REFERENCE: &[u8] = include_bytes!("../testdata/canonical.bin");
+
+/// Locate the repository root from this crate's `CARGO_MANIFEST_DIR`.
+/// reference/PCF-SIG-v1.0 → repository root is two levels up.
+fn repo_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .expect("PCF-SIG-v1.0 crate has a parent (reference/)")
+ .parent()
+ .expect("reference/ has a parent (repo root)")
+ .to_path_buf()
+}
+
+fn read_port_vector(rel: &str) -> Vec {
+ let path = repo_root().join(rel);
+ fs::read(&path).unwrap_or_else(|e| {
+ panic!(
+ "failed to read {}: {e}\n\
+ every PCF-SIG language port MUST ship a copy of the canonical \
+ test vector identical to reference/PCF-SIG-v1.0/testdata/canonical.bin",
+ path.display(),
+ )
+ })
+}
+
+fn assert_byte_identical(label: &str, port: &[u8]) {
+ assert_eq!(
+ port.len(),
+ REFERENCE.len(),
+ "{label} ships canonical.bin of length {} bytes; reference is {} bytes",
+ port.len(),
+ REFERENCE.len(),
+ );
+ if port != REFERENCE {
+ // Find the first differing byte to give a precise diagnostic without
+ // dumping ~1 KiB of binary into the panic message.
+ let first_diff = port
+ .iter()
+ .zip(REFERENCE.iter())
+ .position(|(a, b)| a != b)
+ .unwrap_or(REFERENCE.len());
+ panic!(
+ "{label} canonical.bin diverges from reference at offset {first_diff}: \
+ port byte = 0x{:02x}, reference byte = 0x{:02x}",
+ port.get(first_diff).copied().unwrap_or(0),
+ REFERENCE.get(first_diff).copied().unwrap_or(0),
+ );
+ }
+}
+
+#[test]
+fn typescript_port_testdata_matches_reference() {
+ let port = read_port_vector("implementations/ts/pcf-sig/testdata/canonical.bin");
+ assert_byte_identical("TypeScript port", &port);
+}
+
+#[test]
+fn php_port_testdata_matches_reference() {
+ let port = read_port_vector("implementations/php/pcf-sig/testdata/canonical.bin");
+ assert_byte_identical("PHP port", &port);
+}
+
+#[test]
+fn dotnet_port_testdata_matches_reference() {
+ let port = read_port_vector("implementations/dotnet/pcf-sig/testdata/canonical.bin");
+ assert_byte_identical(".NET port", &port);
+}
+
+/// Sanity: the reference itself is the canonical 966-byte vector we expect.
+/// Catches a regenerated reference that drifted from the spec test-vector
+/// section.
+#[test]
+fn reference_has_canonical_length() {
+ assert_eq!(REFERENCE.len(), 966);
+}