Harden Proton Bridge release evidence privacy
ober
43035ebf6b7e679d160699469f7e9c66f922478c
--- 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": [] --- 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; \ --- 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. --- 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" --- 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" new file mode 100755 --- /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 --- 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 --- 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"