Harden Proton Bridge release evidence privacy

ober

43035ebf6b7e679d160699469f7e9c66f922478c

diff --git a/.jerboa/security.json b/.jerboa/security.json
index bb37e8c..d08b849 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -25,6 +25,23 @@
     },
     "targetEvidence": {
       "protonYubikeyProof": "fail-closed",
+      "proofMaterialPolicy": {
+        "maxBytes": 65536,
+        "rejectBeforeCopy": true,
+        "forbiddenMaterial": [
+          "local-user-paths",
+          "ssh-urls",
+          "authorization-headers",
+          "proton-passwords",
+          "proton-mailbox-passwords",
+          "proton-access-or-refresh-tokens",
+          "fido2-or-yubikey-pins",
+          "private-keys",
+          "decrypted-keys",
+          "plaintext-mail",
+          "raw-account-output"
+        ]
+      },
       "requiredMarkers": [
         "fido2_payload_probe_status=target-evidence-recorded",
         "yubikey_assertion_status=target-evidence-recorded",
@@ -39,6 +56,25 @@
         "raw_account_output_status=not-preserved",
         "sensitive_artifact_policy=no-proton-passwords-mailbox-passwords-tokens-fido2-pins-decrypted-keys-plaintext-mail-or-raw-account-output-preserved"
       ]
+    },
+    "releaseEvidence": {
+      "privacy": "sanitized-host-neutral",
+      "sanitizer": "scripts/sanitize-evidence.sh",
+      "requiredResultMarkers": [
+        "release_evidence_status=pass",
+        "evidence_privacy_status=sanitized",
+        "private_path_scan_status=pass"
+      ],
+      "forbiddenMaterial": [
+        "local-user-paths",
+        "ssh-urls",
+        "authorization-headers",
+        "proton-or-yubikey-secret-values",
+        "private-keys",
+        "decrypted-keys",
+        "plaintext-mail",
+        "raw-account-output"
+      ]
     }
   },
   "suppressions": []
diff --git a/Makefile b/Makefile
index 28b49cc..3267c57 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,7 @@ endif
 LD_ENV = $(LD_VAR)="$(JERBOA_SSL_DIR):$(NATIVE_DIR)"
 NATIVE_LIB := $(NATIVE_DIR)/libproton_bridge_native$(SO_EXT)
 
-.PHONY: help ensure-jerboa-tools ensure-deps deps native run test audit verify security dependency-evidence sbom reproducibility-report target-evidence release-evidence clean
+.PHONY: help ensure-jerboa-tools ensure-deps deps native run test audit verify security sanitize-evidence dependency-evidence sbom reproducibility-report target-evidence release-evidence clean
 .DEFAULT_GOAL := help
 
 help:
@@ -108,7 +108,7 @@ audit:
 	  exit 1; \
 	fi
 
-verify: test audit
+verify: security test audit
 
 security:
 	@set -eu; \
@@ -119,8 +119,17 @@ security:
 	  echo "High-confidence secret pattern found."; \
 	  exit 1; \
 	fi; \
+	test -x scripts/sanitize-evidence.sh; \
+	grep -q 'privacy_status=sanitized' scripts/release-evidence.sh; \
+	grep -q 'evidence_privacy_status=$$privacy_status' scripts/release-evidence.sh; \
+	grep -q 'private_path_scan_status=pass' scripts/release-evidence.sh; \
+	grep -q 'target-proton-yubikey-proof-contains-sensitive-material' scripts/target-evidence.sh; \
+	grep -q 'proofMaterialPolicy' .jerboa/security.json; \
 	echo "secret_scan_status=pass"
 
+sanitize-evidence:
+	@sh scripts/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)"
+
 dependency-evidence: deps
 	@set -eu; \
 	status=0; \
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 28829bc..f86260d 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -18,6 +18,15 @@ The target records:
 - SBOM/dependency manifests, and
 - source and native-artifact hashes.
 
+Before the final result is written, evidence is sanitized for host-neutral
+release review. The final `result.txt` must include:
+
+```text
+release_evidence_status=pass
+evidence_privacy_status=sanitized
+private_path_scan_status=pass
+```
+
 The target intentionally returns nonzero after collecting evidence when any
 production gate fails. A failed `make release-evidence` with populated evidence
 is therefore an actionable blocked release state.
@@ -59,8 +68,9 @@ make release-evidence
 ```
 
 The proof file must contain these exact marker lines and must not contain raw
-account output, auth JSON, tokens, passwords, FIDO2 PINs, decrypted keys, or
-plaintext mail:
+account output, auth JSON, tokens, passwords, FIDO2/YubiKey PINs, decrypted
+keys, plaintext mail, private keys, authorization headers, SSH URLs, or local
+host paths:
 
 ```text
 fido2_payload_probe_status=target-evidence-recorded
@@ -77,6 +87,8 @@ raw_account_output_status=not-preserved
 sensitive_artifact_policy=no-proton-passwords-mailbox-passwords-tokens-fido2-pins-decrypted-keys-plaintext-mail-or-raw-account-output-preserved
 ```
 
-Missing, empty, or marker-incomplete proof files fail closed as
-`status=blocked-target-proof`. Accepted proof files are copied to
+Missing, empty, oversized, binary, sensitive-material-containing, or
+marker-incomplete proof files fail closed as `status=blocked-target-proof`.
+Sensitive proof material is rejected before the proof file is copied into
+`dist/target-evidence/`. Accepted proof files are copied to
 `dist/target-evidence/target-proton-yubikey-proof.txt` and hashed.
diff --git a/scripts/release-evidence.sh b/scripts/release-evidence.sh
index f5cb1af..cae7619 100755
--- a/scripts/release-evidence.sh
+++ b/scripts/release-evidence.sh
@@ -5,6 +5,8 @@ repo_root=${REPO_ROOT:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
 dist_dir=${DIST_DIR:-"$repo_root/dist"}
 evidence_dir=${RELEASE_EVIDENCE_DIR:-"$dist_dir/release-evidence"}
 make_cmd=${MAKE:-make}
+sanitizer="$repo_root/scripts/sanitize-evidence.sh"
+sanitizer_log_tmp="$dist_dir/sanitize-evidence.log.tmp"
 mkdir -p "$dist_dir"
 rm -rf "$evidence_dir"
 rm -rf "$dist_dir/sbom" "$dist_dir/reproducibility" "$dist_dir/target-evidence"
@@ -13,6 +15,8 @@ mkdir -p "$evidence_dir"
 cd "$repo_root"
 
 fail=0
+privacy_status=not-run
+private_path_scan_status=not-run
 
 run_step() {
   name=$1
@@ -39,6 +43,17 @@ copy_if_present() {
 
 git status --short > "$evidence_dir/git-status.txt"
 git rev-parse HEAD > "$evidence_dir/git-head.txt" 2>/dev/null || true
+{
+  echo "repo=jerboa-proton-bridge"
+  echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+  echo "git_commit=$(git rev-parse HEAD 2>/dev/null || true)"
+  echo "platform=$(uname -srm)"
+  if command -v "$make_cmd" >/dev/null 2>&1; then
+    echo "make_status=present"
+  else
+    echo "make_status=unverified"
+  fi
+} > "$evidence_dir/build-environment.txt"
 
 run_step security "$make_cmd" --no-print-directory security
 run_step test "$make_cmd" --no-print-directory test
@@ -59,15 +74,57 @@ find main.ss Makefile README.md SECURITY.md NOTICE .jerboa proton-bridge proton-
   ! -path '*/dist/*' \
   -print | LC_ALL=C sort | xargs shasum -a 256 > "$evidence_dir/source-hashes.sha256"
 
+if ! grep -qx 'status=match' "$evidence_dir/reproducibility/report.txt" 2>/dev/null; then
+  fail=1
+  echo "reproducibility_match_status=fail" > "$evidence_dir/reproducibility-match.status"
+else
+  echo "reproducibility_match_status=pass" > "$evidence_dir/reproducibility-match.status"
+fi
+
+if [ -x "$sanitizer" ]; then
+  set +e
+  sh "$sanitizer" "$evidence_dir" > "$sanitizer_log_tmp" 2>&1
+  sanitizer_code=$?
+  set -e
+  {
+    echo "sanitize_evidence_exit_status=$sanitizer_code"
+    if [ "$sanitizer_code" -eq 0 ]; then
+      echo "sanitize_evidence_log_status=not-retained"
+    else
+      echo "sanitize_evidence_log_status=withheld-sensitive-output-risk"
+    fi
+  } > "$evidence_dir/sanitize-evidence.status"
+  rm -f "$sanitizer_log_tmp"
+  if [ "$sanitizer_code" -eq 0 ]; then
+    privacy_status=sanitized
+    private_path_scan_status=pass
+  else
+    privacy_status=failed
+    private_path_scan_status=fail
+    fail=1
+  fi
+else
+  echo "sanitize_evidence_exit_status=127" > "$evidence_dir/sanitize-evidence.status"
+  privacy_status=missing
+  private_path_scan_status=fail
+  fail=1
+fi
+
 if [ "$fail" -ne 0 ]; then
   {
     echo "release_evidence_status=blocked"
     echo "blocked_reason=one-or-more-production-gates-failed"
+    echo "evidence_privacy_status=$privacy_status"
+    echo "private_path_scan_status=$private_path_scan_status"
   } > "$evidence_dir/result.txt"
   echo "Release evidence written to $evidence_dir"
   echo "Production gates are blocked; see $evidence_dir/*.status and logs."
   exit 1
 fi
 
-echo "release_evidence_status=pass" > "$evidence_dir/result.txt"
+{
+  echo "release_evidence_status=pass"
+  echo "evidence_privacy_status=$privacy_status"
+  echo "private_path_scan_status=$private_path_scan_status"
+} > "$evidence_dir/result.txt"
 echo "Release evidence written to $evidence_dir"
diff --git a/scripts/reproducibility-report.sh b/scripts/reproducibility-report.sh
index 72f5b3c..6607c39 100755
--- a/scripts/reproducibility-report.sh
+++ b/scripts/reproducibility-report.sh
@@ -7,6 +7,7 @@ out_dir=${REPRO_DIR:-"$dist_dir/reproducibility"}
 make_cmd=${MAKE:-make}
 jerbuild=${JERBUILD:-jerbuild}
 native_lib=${NATIVE_LIB:-}
+sanitizer="$repo_root/scripts/sanitize-evidence.sh"
 rm -rf "$out_dir"
 mkdir -p "$out_dir"
 
@@ -46,13 +47,22 @@ build_once() {
 cd "$repo_root"
 
 {
-  echo "repo=$repo_root"
+  echo "repo=jerboa-proton-bridge"
   echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
-  echo "jerbuild=$jerbuild"
+  echo "platform=$(uname -srm)"
+  if "$jerbuild" --jerboa-home >/dev/null 2>&1; then
+    echo "jerbuild_status=present"
+  else
+    echo "jerbuild_status=unavailable"
+  fi
   "$jerbuild" --version 2>/dev/null || true
   rustc --version 2>/dev/null || true
   cargo --version 2>/dev/null || true
-  echo "native_lib=$native_lib"
+  if [ -n "$native_lib" ]; then
+    echo "native_lib_status=configured"
+  else
+    echo "native_lib_status=missing"
+  fi
 } > "$out_dir/build-env.txt"
 
 hash_source_manifest "$out_dir/source-first.sha256"
@@ -83,8 +93,8 @@ case "$native_status:$source_manifest_status" in
 esac
 
 {
-  echo "$first_native  $native_lib"
-  echo "$second_native  $native_lib"
+  echo "$first_native  first-native-helper"
+  echo "$second_native  second-native-helper"
 } > "$out_dir/native-builds.sha256"
 
 {
@@ -93,4 +103,8 @@ esac
   echo "status=$status"
 } | tee "$out_dir/report.txt"
 
+if [ -x "$sanitizer" ]; then
+  sh "$sanitizer" "$out_dir"
+fi
+
 test "$status" = "match"
diff --git a/scripts/sanitize-evidence.sh b/scripts/sanitize-evidence.sh
new file mode 100755
index 0000000..bcf4d6c
--- /dev/null
+++ b/scripts/sanitize-evidence.sh
@@ -0,0 +1,62 @@
+#!/bin/sh
+set -eu
+
+if [ "$#" -eq 0 ]; then
+  set -- dist/release-evidence
+fi
+
+sanitize_file() {
+  file=$1
+
+  if ! LC_ALL=C grep -Iq . "$file" 2>/dev/null; then
+    return 0
+  fi
+
+  perl -0pi -e '
+    s#/Users/[^[:space:]\x22\x27()]+#<local-path>#g;
+    s#/private/var/folders/[^[:space:]\x22\x27()]+#<local-temp-path>#g;
+    s#/var/folders/[^[:space:]\x22\x27()]+#<local-temp-path>#g;
+    if (defined $ENV{HOME} && length $ENV{HOME}) {
+      my $home = quotemeta($ENV{HOME});
+      s#$home#<home>#g;
+    }
+    s#~/mine#<local-path>#g;
+    s#git\@#git-at-#g;
+    s#ssh://[^[:space:]\x22\x27()]+#ssh://<redacted>#g;
+    s#Authorization:[^\x0a]*#Authorization: <redacted>#gi;
+    s#(otpauth://[^\x0a?]+\?[^[:space:]\x0a]*secret=)[^&[:space:]\x0a]+#${1}<redacted>#gi;
+    s#((proton[_-]?(password|mailbox[_-]?password|access[_-]?token|refresh[_-]?token|token)|mailbox[_-]?password|api[_-]?key|token|password|secret|passphrase|pin|puk|fido2[_-]?pin|yk_(pin|puk|oath|otp|piv|openpgp|fido2|secret)|private[_-]?key|decrypted[_-]?key|plaintext[_-]?mail|raw[_-]?account[_-]?output)[[:space:]]*[:=][[:space:]]*)[^[:space:]\x0a]+#${1}<redacted>#gi;
+  ' "$file"
+}
+
+scan_for_private_material() {
+  evidence_dir=$1
+
+  if command -v rg >/dev/null 2>&1; then
+    if rg -n -S --hidden \
+      -e '/Users/' \
+      -e '/private/var/folders/' \
+      -e '/var/folders/' \
+      -e 'git@' \
+      -e 'ssh://[^[:space:]]+' \
+      -e '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' \
+      -e 'Authorization:[[:space:]]*[^<[:space:]]' \
+      -e 'otpauth://[^[:space:]]*[?&]secret=[^<[:space:]&]+' \
+      -e '(?i)(proton[_-]?(password|mailbox[_-]?password|access[_-]?token|refresh[_-]?token|token)|mailbox[_-]?password|api[_-]?key|token|password|secret|passphrase|pin|puk|fido2[_-]?pin|yk_(pin|puk|oath|otp|piv|openpgp|fido2|secret)|private[_-]?key|decrypted[_-]?key|plaintext[_-]?mail|raw[_-]?account[_-]?output)[[:space:]]*[:=][[:space:]]*[^<[:space:]]' \
+      "$evidence_dir"; then
+      printf '[proton-bridge-sanitize] private or sensitive evidence material remains in %s\n' "$evidence_dir" >&2
+      return 1
+    fi
+  fi
+}
+
+for evidence_dir in "$@"; do
+  if [ ! -d "$evidence_dir" ]; then
+    continue
+  fi
+
+  find "$evidence_dir" -type f -print | while IFS= read -r file; do
+    sanitize_file "$file"
+  done
+  scan_for_private_material "$evidence_dir"
+done
diff --git a/scripts/sbom.sh b/scripts/sbom.sh
index c1f0389..2277771 100755
--- a/scripts/sbom.sh
+++ b/scripts/sbom.sh
@@ -19,19 +19,19 @@ hash_manifest() {
   dir=$1
   out=$2
   if [ -d "$dir" ]; then
-    find "$dir" -type f \
-      ! -path '*/.git/*' \
-      ! -path '*/dist/*' \
-      ! -path '*/.jerboa/*' \
-      ! -path '*/.deps/*' \
-      ! -path '*/target/*' \
+    (cd "$dir" && find . -type f \
+      ! -path './.git/*' \
+      ! -path './dist/*' \
+      ! -path './.jerboa/*' \
+      ! -path './.deps/*' \
+      ! -path './target/*' \
       -print | LC_ALL=C sort | while IFS= read -r file; do
         if command -v sha256sum >/dev/null 2>&1; then
           sha256sum "$file"
         else
           shasum -a 256 "$file"
         fi
-      done > "$out"
+      done) > "$out"
   else
     : > "$out"
   fi
@@ -49,13 +49,14 @@ git_state() {
   label=$1
   dir=$2
   {
-    echo "${label}_dir=$dir"
     if [ -d "$dir/.git" ]; then
+      echo "${label}_dir_status=present"
       echo "${label}_git_head=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true)"
       echo "${label}_git_status_begin"
       git -C "$dir" status --short 2>/dev/null || true
       echo "${label}_git_status_end"
     else
+      echo "${label}_dir_status=missing"
       echo "${label}_git_head=blocked"
     fi
   }
@@ -101,16 +102,22 @@ copy_release_evidence_dir() {
 }
 
 cd "$repo_root"
-jerboa_home=$("$jerbuild" --jerboa-home 2>/dev/null || true)
+if "$jerbuild" --jerboa-home >/dev/null 2>&1; then
+  jerbuild_status=present
+  jerboa_home_status=present
+else
+  jerbuild_status=unavailable
+  jerboa_home_status=unavailable
+fi
 
 {
-  echo "repo=$repo_root"
+  echo "repo=jerboa-proton-bridge"
   echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
   echo "git_head=$(git rev-parse HEAD 2>/dev/null || true)"
-  echo "uname=$(uname -a)"
-  echo "jerbuild=$jerbuild"
+  echo "platform=$(uname -srm)"
+  echo "jerbuild_status=$jerbuild_status"
   "$jerbuild" --version 2>/dev/null || true
-  echo "jerboa_home=$jerboa_home"
+  echo "jerboa_home_status=$jerboa_home_status"
   rustc --version 2>/dev/null || true
   cargo --version 2>/dev/null || true
   cargo audit --version 2>/dev/null || true
@@ -127,6 +134,7 @@ jerboa_home=$("$jerbuild" --jerboa-home 2>/dev/null || true)
   echo "password_authenticated_imap=disabled"
   echo "plaintext_cache=disabled"
   echo "credential_inputs=interactive-or-env-name-only"
+  echo "evidence_privacy=sanitized-host-neutral"
   echo "mailbox_password_backend=proton-srp"
   echo "srp_proof_backend=disabled-until-rustsec-clean-signed-modulus-verifier"
   echo "openpgp_backend=disabled-until-rustsec-clean-backend"
@@ -185,19 +193,35 @@ hash_manifest "$mail_dir" "$out_dir/jerboa-mail-source.sha256"
 hash_manifest "$https_dir/lib" "$out_dir/jerboa-https-lib.sha256"
 hash_manifest "$ssl_dir/lib" "$out_dir/jerboa-ssl-lib.sha256"
 
-find "$repo_root" -maxdepth 1 -type f \
+find . -maxdepth 1 -type f \
   \( -name '*.ss' -o -name 'Makefile' -o -name 'README.md' -o -name 'SECURITY.md' -o -name 'LICENSE' -o -name 'NOTICE' \) \
   -print | LC_ALL=C sort | while IFS= read -r file; do
     sha256_file "$file"
   done > "$out_dir/top-level.sha256"
 
+{
+  for file in \
+    Makefile \
+    .jerboa/security.json \
+    docs/release-evidence.md \
+    scripts/release-evidence.sh \
+    scripts/reproducibility-report.sh \
+    scripts/sbom.sh \
+    scripts/sanitize-evidence.sh \
+    scripts/target-evidence.sh; do
+    if [ -f "$file" ]; then
+      sha256_file "$file"
+    fi
+  done
+} > "$out_dir/release-inputs.sha256"
+
 if [ -n "$native_lib" ] && [ -f "$native_lib" ]; then
-  sha256_file "$native_lib" > "$out_dir/native-lib.sha256"
-  file "$native_lib" > "$out_dir/native-lib.file" 2>&1 || true
+  sha256_file "$native_lib" | awk '{print $1 "  native-helper-library"}' > "$out_dir/native-lib.sha256"
+  file "$native_lib" 2>&1 | sed 's/^[^:]*:/native-helper-library:/' > "$out_dir/native-lib.file" || true
   if command -v otool >/dev/null 2>&1; then
-    otool -L "$native_lib" > "$out_dir/native-lib.linkage" 2>&1 || true
+    otool -L "$native_lib" 2>&1 | sed '1s#.*#native-helper-library:#' > "$out_dir/native-lib.linkage" || true
   elif command -v ldd >/dev/null 2>&1; then
-    ldd "$native_lib" > "$out_dir/native-lib.linkage" 2>&1 || true
+    ldd "$native_lib" 2>&1 | sed '1s#.*#native-helper-library:#' > "$out_dir/native-lib.linkage" || true
   else
     echo "No native linkage inspector found." > "$out_dir/native-lib.linkage"
   fi
diff --git a/scripts/target-evidence.sh b/scripts/target-evidence.sh
index 3eb3a90..907a80e 100644
--- a/scripts/target-evidence.sh
+++ b/scripts/target-evidence.sh
@@ -6,6 +6,7 @@ dist_dir=${DIST_DIR:-"$repo_root/dist"}
 out_dir=${TARGET_EVIDENCE_DIR:-"$dist_dir/target-evidence"}
 proof_file=${JPROTON_BRIDGE_TARGET_PROOF_FILE:-}
 require_proof=${JPROTON_BRIDGE_REQUIRE_TARGET_PROOF:-0}
+max_proof_bytes=${JPROTON_BRIDGE_MAX_PROOF_BYTES:-65536}
 
 rm -rf "$out_dir"
 mkdir -p "$out_dir"
@@ -57,6 +58,23 @@ require_marker() {
   fi
 }
 
+reject_sensitive_proof_material() {
+  proof_size=$(wc -c < "$proof_file" | tr -d '[:space:]')
+
+  if [ "$proof_size" -gt "$max_proof_bytes" ]; then
+    return 1
+  fi
+
+  if ! LC_ALL=C grep -Iq . "$proof_file" 2>/dev/null; then
+    return 1
+  fi
+
+  sensitive_pattern='(/Users/|/private/var/folders/|/var/folders/|git@|ssh://|-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----|Authorization:[[:space:]]*[^[:space:]]|otpauth://[^[:space:]]*[?&]secret=|proton[_-]?(password|mailbox[_-]?password|access[_-]?token|refresh[_-]?token|token)[[:space:]]*[:=]|mailbox[_-]?password[[:space:]]*[:=]|(api[_-]?key|token|password|secret|passphrase|pin|puk|fido2[_-]?pin|yk_(pin|puk|oath|otp|piv|openpgp|fido2|secret)|private[_-]?key|decrypted[_-]?key|plaintext[_-]?mail|raw[_-]?account[_-]?output)[[:space:]]*[:=])'
+  if grep -I -E -i -q "$sensitive_pattern" "$proof_file"; then
+    return 1
+  fi
+}
+
 if [ -z "$proof_file" ]; then
   if [ "$require_proof" = "1" ]; then
     write_blocked_status "target-proton-yubikey-proof-required-but-not-provided"
@@ -71,6 +89,11 @@ if [ ! -s "$proof_file" ]; then
   exit 1
 fi
 
+if ! reject_sensitive_proof_material; then
+  write_blocked_status "target-proton-yubikey-proof-contains-sensitive-material"
+  exit 1
+fi
+
 : > "$out_dir/missing-markers.txt"
 require_marker "fido2_payload_probe_status=target-evidence-recorded"
 require_marker "yubikey_assertion_status=target-evidence-recorded"