From bf1138a0130571f3192d2fecb937c359de5b7196 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Sun, 19 Jul 2026 21:42:25 -0400 Subject: [PATCH 1/2] ci: bind equality evidence to exact release binaries Inspect production comparison paths after final linking and bind call closure, timing, formal results, provenance, and artifacts to the exact release identity. Reject ancestor evidence for a different release commit. --- .../constant-time-release-evidence-1e18.md | 7 + .github/workflows/ct.yaml | 2 +- ct.toml | 97 ++++- docs/constant-time.md | 24 +- docs/release.md | 11 +- scripts/check/check.sh | 8 + scripts/check/zig-cc.sh | 8 + scripts/ci/release-evidence-check-test.sh | 8 +- scripts/ci/release-evidence-check.sh | 82 +---- scripts/ci/release-identity-test.sh | 14 +- scripts/ci/write-release-manifest.sh | 6 +- scripts/ct/artifacts.sh | 134 ++++++- scripts/ct/asm_heuristics.py | 155 +++++++- scripts/ct/asm_heuristics_test.py | 13 +- scripts/ct/binsec.py | 27 ++ scripts/ct/dudect.sh | 67 +++- scripts/ct/dudect_report.py | 120 ++++++- scripts/ct/evidence_validation_test.py | 190 ++++++++++ scripts/ct/full.py | 112 +++++- scripts/ct/provenance.py | 104 +++++- scripts/ct/symbolize_linked_binary.py | 233 ++++++++++++ scripts/ct/validate.py | 330 ++++++++++++++++- scripts/ct/validate_release_evidence.py | 337 +++++++++++++++++- tools/ct-dudect/Cargo.lock | 8 + tools/ct-dudect/Cargo.toml | 1 + tools/ct-dudect/src/main.rs | 30 +- tools/ct-harness/Cargo.toml | 7 +- tools/ct-harness/src/lib.rs | 19 +- tools/ct-harness/src/main.rs | 33 ++ 29 files changed, 1998 insertions(+), 189 deletions(-) create mode 100644 .changes/constant-time-release-evidence-1e18.md create mode 100644 scripts/ct/evidence_validation_test.py create mode 100644 scripts/ct/symbolize_linked_binary.py create mode 100644 tools/ct-harness/src/main.rs diff --git a/.changes/constant-time-release-evidence-1e18.md b/.changes/constant-time-release-evidence-1e18.md new file mode 100644 index 00000000..10de4cbe --- /dev/null +++ b/.changes/constant-time-release-evidence-1e18.md @@ -0,0 +1,7 @@ +--- +"rscrypto" = "patch" +--- + +Constant-time release evidence now inspects production equality paths in final +linked binaries and binds disassembly, symbols, timing, formal results, +provenance, and hashes to the exact release commit and build configuration. diff --git a/.github/workflows/ct.yaml b/.github/workflows/ct.yaml index 8bc04dcf..dd03e446 100644 --- a/.github/workflows/ct.yaml +++ b/.github/workflows/ct.yaml @@ -170,7 +170,7 @@ jobs: if [[ "$raw_artifacts" == "true" ]]; then package_args+=(--raw) fi - python3 scripts/ct/package_evidence.py "${package_args[@]}" + scripts/ct/python.sh scripts/ct/package_evidence.py "${package_args[@]}" exit "$status" artifact_name: ct-${{ matrix.artifact_suffix }} diff --git a/ct.toml b/ct.toml index 8d532a96..36aff6a0 100644 --- a/ct.toml +++ b/ct.toml @@ -8,6 +8,61 @@ release_report = "ct-report.json" harness = "tools/ct-harness" artifact_script = "scripts/ct/artifacts.sh" +[equality_evidence.release_binary] +name = "rscrypto-ct-evidence" +kind = "executable" +profile = "release" +backend = "llvm" +features = ["std", "full", "parallel", "diag"] +default_features = false +owner_widths = [16, 28, 32, 48, 64, 1632, 2400, 3168] +owner_symbols = [ + "ct_entry_owner_eq_16", + "ct_entry_owner_eq_28", + "ct_entry_owner_eq_32", + "ct_entry_owner_eq_48", + "ct_entry_owner_eq_64", + "ct_entry_owner_eq_1632", + "ct_entry_owner_eq_2400", + "ct_entry_owner_eq_3168", +] +public_len_symbols = [ + "ct_entry_kmac256_verify", + "ct_entry_mlkem512_decapsulate", + "ct_entry_mlkem768_decapsulate", + "ct_entry_mlkem1024_decapsulate", + "ct_entry_argon2i_verify", + "ct_entry_argon2d_verify", + "ct_entry_argon2id_verify", + "ct_entry_scrypt_verify", + "ct_entry_rsa_pkcs1v15_sign_fixed_blinding", + "ct_entry_rsa_pss_sign_fixed_blinding", + "ct_entry_rsa_oaep_decrypt_fixed_blinding", + "ct_entry_rsa_pkcs1v15_decrypt_fixed_blinding", + "ct_entry_rsa_private_key_pkcs8_roundtrip", +] +formal_owner_widths = [16, 32, 48, 64] +formal_limitation = "BINSEC owner kernels cover the representative 16-, 32-, 48-, and 64-byte production monomorphizations. The 28- and ML-KEM-sized owner monomorphizations require linked-binary disassembly/closure and heuristics on every release lane; BINSEC proof kernels are deferred because they add no caller-selectable shape and the large copies exceed the present proof budget." +downstream_limitation = "Evidence binds this final linked harness executable and its exact compiler/linker configuration. It does not claim constant-time behavior for arbitrary downstream binaries that rebuild or inline rscrypto differently. Returning bool remains the T3.4 declassification limitation." + +[[asm_public_operand]] +primitive = "password.argon2i" +root = "ct_entry_argon2i_verify" +symbol = "rscrypto::auth::argon2::argon2_hash_with_kernel_inner" +kind = "variable_latency_division" +max_count = 2 +source = "src/auth/argon2/mod.rs:1751" +rationale = "The divisions derive matrix dimensions and iteration bounds from validated public Argon2Params and the caller-visible output length. Password and salt contents do not feed the divisors." + +[[asm_public_operand]] +primitive = "password.argon2i" +root = "ct_entry_argon2i_verify" +symbol = "rscrypto::auth::argon2::fill_segment_inner" +kind = "variable_latency_division" +max_count = 2 +source = "src/auth/argon2/mod.rs:1434" +rationale = "For the Argon2i retained root, address generation and segment positions are data-independent; these divisions use public lane, segment, and matrix dimensions rather than password-derived contents." + [operation_inventory] schema_version = 1 authority = "ct.toml" @@ -33,6 +88,9 @@ operations = ["kmac.variable_output_and_verify"] public_length = "The caller selects KMAC output length and supplies an expected buffer of that public length." secret_contents = "The computed KMAC output and expected bytes are traversed fully for each public-length chunk." tests = "KMAC NIST/Wycheproof/differential tests plus kmac256_valid_vs_invalid_tag DudeCT." +evidence_symbols = ["ct_entry_kmac256_verify"] +evidenced_call_count = 1 +limited_call_count = 0 [[public_len_comparison]] id = "rsa.public_modulus_and_profile_shapes" @@ -42,6 +100,16 @@ operations = ["rsa.private_key_import_export_and_generation", "rsa.private_sign_ public_length = "Widths derive from the public modulus, digest algorithm, DER/padding profile, or caller-visible encoded length." secret_contents = "Private representatives, decoded plaintext/hash fields, or validation intermediates are compared without content-dependent exits after public shape checks." tests = "RSA official/Wycheproof/differential/private-operation tests and RSA CT harness/DudeCT cases." +evidence_symbols = [ + "ct_entry_rsa_pkcs1v15_sign_fixed_blinding", + "ct_entry_rsa_pss_sign_fixed_blinding", + "ct_entry_rsa_oaep_decrypt_fixed_blinding", + "ct_entry_rsa_pkcs1v15_decrypt_fixed_blinding", + "ct_entry_rsa_private_key_pkcs8_roundtrip", +] +evidenced_call_count = 7 +limited_call_count = 5 +limitation = "The two Miller-Rabin key-generation comparisons and three public verification comparisons are not reachable from the retained release equality roots. Their lengths remain public and their source traversal is audited, but T3.3 does not convert that source review into linked-binary evidence." [[public_len_comparison]] id = "mlkem.parameter_set_shapes" @@ -51,6 +119,13 @@ operations = ["kem.mlkem512", "kem.mlkem768", "kem.mlkem1024"] public_length = "Encoded key/ciphertext widths are fixed by the public ML-KEM parameter set and validated before comparison." secret_contents = "Decapsulation implicit-rejection ciphertext/key material is traversed fully at that public parameter-set width." tests = "ML-KEM ACVP, differential, property, operation, CT harness, and DudeCT cases for all parameter sets." +evidence_symbols = [ + "ct_entry_mlkem512_decapsulate", + "ct_entry_mlkem768_decapsulate", + "ct_entry_mlkem1024_decapsulate", +] +evidenced_call_count = 1 +limited_call_count = 0 [[public_len_comparison]] id = "argon2.caller_selected_output" @@ -60,6 +135,9 @@ operations = ["password.argon2i", "password.argon2d_and_argon2id"] public_length = "Primitive verification derives exactly the caller-supplied expected output length after public bounds checks." secret_contents = "The freshly derived password verifier and expected bytes are traversed fully before the temporary is cleared." tests = "Argon2 vectors/differentials/parallel tests and variant CT harness/DudeCT cases." +evidence_symbols = ["ct_entry_argon2i_verify", "ct_entry_argon2d_verify", "ct_entry_argon2id_verify"] +evidenced_call_count = 1 +limited_call_count = 0 [[public_len_comparison]] id = "scrypt.caller_selected_output" @@ -69,6 +147,9 @@ operations = ["password.scrypt"] public_length = "Primitive verification derives exactly the caller-supplied expected output length after public minimum-length checks." secret_contents = "The freshly derived password verifier and expected bytes are traversed fully before the temporary is cleared." tests = "scrypt vectors/differentials and verification CT harness/DudeCT cases." +evidence_symbols = ["ct_entry_scrypt_verify"] +evidenced_call_count = 1 +limited_call_count = 0 [status] ct_claimed = "all required evidence has passed for the exact primitive/configuration pair" @@ -113,9 +194,13 @@ kind = "staticlib" status = "covered" symbols = [ "ct_entry_owner_eq_16", + "ct_entry_owner_eq_28", "ct_entry_owner_eq_32", "ct_entry_owner_eq_48", "ct_entry_owner_eq_64", + "ct_entry_owner_eq_1632", + "ct_entry_owner_eq_2400", + "ct_entry_owner_eq_3168", "ct_entry_secret_bytes32_debug_masked", "ct_entry_hmac_sha256_verify", "ct_entry_hmac_sha384_verify", @@ -3110,6 +3195,7 @@ tier = "A" claim = "ct-intended" features = ["always"] variants = ["Owner16", "Owner32", "Owner48", "Owner64"] +release_owner_widths = [16, 28, 32, 48, 64, 1632, 2400, 3168] entrypoints = [ "Aes128GcmKey::eq", "X25519SecretKey::eq", @@ -3125,7 +3211,16 @@ required = ["tier_a"] notes = "Public callers select a semantic owner, never a raw secret length. Source structure and black_box are not machine-code proofs; T3.3 owns exact release-binary evidence. Returning bool remains an explicit T3.4 limitation." [primitive.harness] status = "covered" -symbols = ["ct_entry_owner_eq_16", "ct_entry_owner_eq_32", "ct_entry_owner_eq_48", "ct_entry_owner_eq_64"] +symbols = [ + "ct_entry_owner_eq_16", + "ct_entry_owner_eq_28", + "ct_entry_owner_eq_32", + "ct_entry_owner_eq_48", + "ct_entry_owner_eq_64", + "ct_entry_owner_eq_1632", + "ct_entry_owner_eq_2400", + "ct_entry_owner_eq_3168", +] [[primitive]] id = "secret_wrappers.exposure_and_display" diff --git a/docs/constant-time.md b/docs/constant-time.md index b8c064ef..ba2d428b 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -146,9 +146,12 @@ evidence set includes: - Stable harness entrypoints. - Build and host provenance. -- LLVM IR, assembly, object, and symbol artifacts. +- LLVM IR, assembly, pre-link objects, and symbol artifacts. +- A fat-LTO final linked equality evidence executable, its exact linker + command and linker identity, and post-link disassembly, symbols, and size. - Automated checks for suspicious generated-code patterns. -- Empirical timing tests on native executable targets. +- Empirical timing tests on native executable targets, bound to the hashed + timing executable, disassembly, symbol map, and linker command. - ML-KEM DudeCT cases for key generation secret noise, encapsulation coins, decapsulation secret keys, implicit rejection, NTT, inverse NTT, product-domain conversion, basemul/dot products, and compress/decompress @@ -157,6 +160,20 @@ evidence set includes: paths. - Miri and unsafe-code validation where the CT path uses unsafe Rust. +The linked equality executable retains production owner comparisons at the +distinct 16-, 28-, 32-, 48-, 64-, 1632-, 2400-, and 3168-byte owner widths. +The required owner timing cases remain the manifest-declared 16-, 32-, 48-, +and 64-byte cases. Public-length internal comparisons are mapped in `ct.toml` +to retained production entrypoints or to an explicit limitation; an uncovered +call is not silently treated as binary evidence. + +This executable is an unpublished evidence surface. It proves only its exact +source, toolchain, backend, target, target features, feature set, profile, and +linker configuration. It is not the crate's public API, it is not a sealed +decision type, and it does not generalize to arbitrary downstream binaries. +Equality still returns `bool`; that declassification limitation remains until +T3.4. + Assembly triage is grouped by primitive, reachable symbol, finding kind, and artifact. Register-indexed memory is presented first, then conditional control flow, then indirect calls. `needs-binsec` means operand provenance remains @@ -168,7 +185,8 @@ date. Source or disassembly movement invalidates it. BINSEC is required on the GNU Linux targets supported by the workflow. Every manifest-required kernel must report `secure`. Other target reports record BINSEC as `not_applicable` with the target policy reason; that status is not -binary proof. +binary proof. Each formal result is bound to its hashed proof driver, +disassembly, configuration, solver log, candidate identity, and toolchain. Statistical timing checks must be described precisely: diff --git a/docs/release.md b/docs/release.md index 2ce25f6d..ce6d172f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -167,7 +167,7 @@ receive anything, the workflow: 2. Revalidates configuration, the unified Cargo graph, dependency policy, audit results, SemVer, and successful CI for the exact commit. 3. Requires complete Weekly CT/RSA and RISC-V native/CT evidence from that - commit or a mechanically proven release-tooling-only ancestor. + exact commit and crate version. 4. Builds the `.crate` once, reproduces the source archive from the tag, and rejects dirty, private, local-only, or mismatched package contents. 5. Captures repository controls and writes provenance attestations, an identity @@ -176,11 +176,10 @@ receive anything, the workflow: crates.io token through OIDC, publishes the same crate, then downloads it from crates.io and verifies its SHA-256. -A release-tooling-only repair may reuse the newest successful ancestor with -paired Weekly and RISC-V evidence. The checker permits only changelog, -release/CT tooling, root-package version, and normalized local CT lockfile -version changes. Runtime, dependency, feature, build, or test changes invalidate -that exception. +Any change after the evidence run—including a version-only or release-tooling +change—creates a new release candidate and requires fresh paired Weekly and +RISC-V evidence. Ancestor binaries are never promoted into an exact-commit +constant-time claim. ## Recovery diff --git a/scripts/check/check.sh b/scripts/check/check.sh index aa3639ec..ba4c7acc 100755 --- a/scripts/check/check.sh +++ b/scripts/check/check.sh @@ -83,6 +83,14 @@ if ! "$SCRIPT_DIR/../ct/python.sh" "$SCRIPT_DIR/../ct/asm_heuristics_test.py" >" fi ok +step "Checking CT evidence validation" +if ! "$SCRIPT_DIR/../ct/python.sh" "$SCRIPT_DIR/../ct/evidence_validation_test.py" >"$LOG_DIR/ct-evidence-validation.log" 2>&1; then + fail + show_error "$LOG_DIR/ct-evidence-validation.log" + exit 1 +fi +ok + # Check step "Checking" # shellcheck disable=SC2086 diff --git a/scripts/check/zig-cc.sh b/scripts/check/zig-cc.sh index a2b12dcc..70c2035b 100755 --- a/scripts/check/zig-cc.sh +++ b/scripts/check/zig-cc.sh @@ -20,11 +20,15 @@ set -euo pipefail # ZIG_CC_TARGET explicitly when this script is used as the linker. TARGET="${ZIG_CC_TARGET:-}" ARGS=() +PRINT_CT_MAP=false for arg in "$@"; do if [[ "$arg" == --target=* ]]; then TARGET="${arg#--target=}" else ARGS+=("$arg") + if [[ "$arg" == "-Wl,--print-map" ]]; then + PRINT_CT_MAP=true + fi fi done @@ -69,4 +73,8 @@ x86_64-unknown-none) ZIG_TARGET="x86_64-freestanding" ;; ;; esac +if [[ -n "${RSCRYPTO_CT_LINK_MAP:-}" && "$PRINT_CT_MAP" == true ]]; then + exec zig cc -target "$ZIG_TARGET" "${ARGS[@]}" >"$RSCRYPTO_CT_LINK_MAP" +fi + exec zig cc -target "$ZIG_TARGET" "${ARGS[@]}" diff --git a/scripts/ci/release-evidence-check-test.sh b/scripts/ci/release-evidence-check-test.sh index 32228c28..fd274e8f 100755 --- a/scripts/ci/release-evidence-check-test.sh +++ b/scripts/ci/release-evidence-check-test.sh @@ -177,10 +177,10 @@ git -C "$fixture" commit -qm "release-only delta" release_sha=$(git -C "$fixture" rev-parse HEAD) : >"$output" -GITHUB_OUTPUT="$output" "$CHECKER" --root "$fixture" --commit "$release_sha" --repo loadingalias/rscrypto >/dev/null -grep -Fxq "weekly_commit=$evidence_sha" "$output" -grep -Fxq 'weekly_version=0.7.3' "$output" -grep -Fxq 'weekly_evidence_mode=release_only_delta' "$output" +if GITHUB_OUTPUT="$output" "$CHECKER" --root "$fixture" --commit "$release_sha" --repo loadingalias/rscrypto >/dev/null 2>&1; then + echo "release evidence check promoted evidence across a release-only delta" >&2 + exit 1 +fi "$REPO_ROOT/scripts/ct/python.sh" - "$REPO_ROOT" "$fixture" "$evidence_sha" <<'PY' import hashlib diff --git a/scripts/ci/release-evidence-check.sh b/scripts/ci/release-evidence-check.sh index 2c124dde..c75a0e12 100755 --- a/scripts/ci/release-evidence-check.sh +++ b/scripts/ci/release-evidence-check.sh @@ -93,86 +93,16 @@ select_pair() { [[ -n "$selected_weekly" && -n "$selected_riscv" ]] } -release_only_delta() { - local evidence_commit=$1 - local release_commit=$2 - local path - - git -C "$root" merge-base --is-ancestor "$evidence_commit" "$release_commit" || return 1 - - while IFS= read -r path; do - case "$path" in - CHANGELOG.md|Cargo.toml|Cargo.lock|docs/release.md|.changes/*|.github/workflows/release.yaml|scripts/ci/*|\ - scripts/ct/validate_release_evidence.py|\ - tools/ct-binsec-harness/Cargo.lock|tools/ct-dudect/Cargo.lock|tools/ct-harness/Cargo.lock) ;; - *) return 1 ;; - esac - done < <(git -C "$root" diff --name-only "$evidence_commit..$release_commit") - - ( - cd "$root" - "$SCRIPT_DIR/../ct/python.sh" - "$evidence_commit" "$release_commit" <<'PY' -import copy -import subprocess -import sys -import tomllib - -evidence_commit, release_commit = sys.argv[1:] -lockfiles = ( - "Cargo.lock", - "tools/ct-binsec-harness/Cargo.lock", - "tools/ct-dudect/Cargo.lock", - "tools/ct-harness/Cargo.lock", -) - - -def load(commit: str, path: str) -> dict: - raw = subprocess.check_output(["git", "show", f"{commit}:{path}"], text=True) - return tomllib.loads(raw) - - -def normalized_manifest(commit: str) -> dict: - manifest = copy.deepcopy(load(commit, "Cargo.toml")) - manifest["package"]["version"] = "" - return manifest - - -def normalized_lock(commit: str, path: str) -> dict: - lock = copy.deepcopy(load(commit, path)) - packages = [package for package in lock.get("package", []) if package.get("name") == "rscrypto" and "source" not in package] - if len(packages) != 1: - raise SystemExit(f"{path} must contain exactly one local rscrypto package, found {len(packages)}") - packages[0]["version"] = "" - return lock - - -if normalized_manifest(evidence_commit) != normalized_manifest(release_commit): - raise SystemExit("Cargo.toml changed beyond package.version") - -for path in lockfiles: - if normalized_lock(evidence_commit, path) != normalized_lock(release_commit, path): - raise SystemExit(f"{path} changed beyond the local rscrypto package version") -PY - ) -} - evidence_commit="$commit" evidence_mode="exact_commit" if ! select_pair "$commit"; then - while IFS= read -r candidate; do - [[ "$candidate" =~ ^[0-9a-fA-F]{40}$ ]] || continue - git -C "$root" cat-file -e "$candidate^{commit}" 2>/dev/null || continue - if release_only_delta "$candidate" "$commit" && select_pair "$candidate"; then - evidence_commit="$candidate" - evidence_mode="release_only_delta" - break - fi - done < <(jq -r 'sort_by(.createdAt) | reverse | .[].headSha' <<<"$weekly_runs" | awk '!seen[$0]++') + selected_weekly="" + selected_riscv="" fi if [[ -z "$selected_weekly" || -z "$selected_riscv" ]]; then echo "No paired successful Weekly and RISC-V evidence is valid for release commit $commit." >&2 - echo "Both workflows must cover the same commit; an ancestor may be promoted only across a provably release-only delta." >&2 + echo "Both workflows must cover the exact release commit; ancestor evidence cannot be promoted." >&2 exit 1 fi @@ -218,8 +148,4 @@ if [[ -n ${GITHUB_OUTPUT:-} ]]; then } >>"$GITHUB_OUTPUT" fi -if [[ "$evidence_mode" == "exact_commit" ]]; then - echo "Exact-commit release evidence passed: Weekly $weekly_run_url; RISC-V $riscv_run_url" -else - echo "Release-only delta proven; promoted paired evidence from $evidence_commit: Weekly $weekly_run_url; RISC-V $riscv_run_url" -fi +echo "Exact-commit release evidence passed: Weekly $weekly_run_url; RISC-V $riscv_run_url" diff --git a/scripts/ci/release-identity-test.sh b/scripts/ci/release-identity-test.sh index efde9a4a..692f0cd0 100755 --- a/scripts/ci/release-identity-test.sh +++ b/scripts/ci/release-identity-test.sh @@ -80,8 +80,8 @@ write_manifest() { --crate "$artifacts/rscrypto-1.2.3.crate" \ --ct-evidence "${CT_PATH:-$artifacts/rscrypto-1.2.3-ct-evidence.tar.gz}" \ --repository-controls "${CONTROLS_PATH:-$artifacts/rscrypto-1.2.3-repository-controls.json}" \ - --evidence-commit "$commit" \ - --evidence-mode exact_commit \ + --evidence-commit "${EVIDENCE_COMMIT:-$commit}" \ + --evidence-mode "${EVIDENCE_MODE:-exact_commit}" \ --output "${MANIFEST_PATH:-$artifacts/rscrypto-1.2.3-release-manifest.json}" } @@ -108,6 +108,16 @@ grep -Fxq "manifest_path=$manifest" "$github_output" grep -Fxq "manifest_name=$(basename "$manifest")" "$github_output" grep -Eq '^manifest_sha256=[0-9a-f]{64}$' "$github_output" +if EVIDENCE_MODE=release_only_delta MANIFEST_PATH="$TMP_ROOT/ancestor-mode.json" write_manifest >/dev/null 2>&1; then + echo "release manifest accepted ancestor evidence mode" >&2 + exit 1 +fi +if EVIDENCE_COMMIT=0000000000000000000000000000000000000000 \ + MANIFEST_PATH="$TMP_ROOT/other-evidence-commit.json" write_manifest >/dev/null 2>&1; then + echo "release manifest accepted evidence for another commit" >&2 + exit 1 +fi + tampered_source="$TMP_ROOT/rscrypto-1.2.3-source.tar.gz" cp "$artifacts/rscrypto-1.2.3-source.tar.gz" "$tampered_source" printf 'tampered' >> "$tampered_source" diff --git a/scripts/ci/write-release-manifest.sh b/scripts/ci/write-release-manifest.sh index 9b15e3a9..8ed9a1a8 100755 --- a/scripts/ci/write-release-manifest.sh +++ b/scripts/ci/write-release-manifest.sh @@ -14,7 +14,7 @@ Required: --ct-evidence PATH --repository-controls PATH --evidence-commit SHA - --evidence-mode exact_commit|release_only_delta + --evidence-mode exact_commit --output PATH Optional: @@ -61,8 +61,8 @@ fail() { [[ "$tag" == "v$version" ]] || fail "tag $tag does not match version $version" [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "release commit must be a full lowercase Git commit" [[ "$evidence_commit" =~ ^[0-9a-f]{40}$ ]] || fail "evidence commit must be a full lowercase Git commit" -[[ "$evidence_mode" == "exact_commit" || "$evidence_mode" == "release_only_delta" ]] \ - || fail "invalid evidence mode: $evidence_mode" +[[ "$evidence_mode" == "exact_commit" ]] || fail "invalid evidence mode: $evidence_mode" +[[ "$evidence_commit" == "$commit" ]] || fail "evidence commit must equal the release commit" [[ -n "$output" ]] || fail "--output is required" for path in "$source_archive" "$crate_package" "$ct_evidence" "$repository_controls"; do diff --git a/scripts/ct/artifacts.sh b/scripts/ct/artifacts.sh index 89621022..b3beff05 100755 --- a/scripts/ct/artifacts.sh +++ b/scripts/ct/artifacts.sh @@ -5,8 +5,9 @@ usage() { cat <<'EOF' Usage: scripts/ct/artifacts.sh [--target ] [--profile release] -Build the CT harness and capture provenance, LLVM IR, assembly, object files, -object disassembly, symbol maps, and artifact hashes under target/ct/. +Build the CT harness and capture provenance, LLVM IR, assembly, pre-link objects, +the final linked equality binary, disassembly, symbol maps, and artifact hashes +under target/ct/. EOF } @@ -112,15 +113,15 @@ fi OUT_DIR="$ROOT/target/ct/$TARGET/$PROFILE" ARTIFACT_DIR="$OUT_DIR/artifacts" -BUILD_TARGET_DIR="$ROOT/target/ct-build" +BUILD_TARGET_DIR="$ROOT/target/ct-build/$TARGET/$PROFILE" DEPS_DIR="$BUILD_TARGET_DIR/$TARGET/$PROFILE/deps" rm -rf "$OUT_DIR" +rm -rf "$BUILD_TARGET_DIR" mkdir -p "$ARTIFACT_DIR" -# Cargo keeps content-hashed compiler outputs from older source revisions in -# `deps/`. The scanner must consume exactly one fresh harness build or duplicate -# symbols make the production call closure ambiguous. -rm -f "$DEPS_DIR"/rscrypto_ct_harness*.{ll,s,o,obj} +# The dedicated target/profile build directory was removed above. Cargo does +# not fingerprint extra `--emit` outputs, so reusing it could report a fresh +# build after those outputs were removed and silently consume stale evidence. echo "building CT harness for $TARGET ($PROFILE)" cargo rustc \ @@ -133,9 +134,9 @@ cargo rustc \ -- \ --emit=llvm-ir,asm,obj -EMITTED=() +LIB_EMITTED=() while IFS= read -r artifact; do - EMITTED+=("$artifact") + LIB_EMITTED+=("$artifact") done < <( find "$DEPS_DIR" -maxdepth 1 -type f \ \( -name 'rscrypto_ct_harness*.ll' \ @@ -145,26 +146,95 @@ done < <( | sort ) -if [[ ${#EMITTED[@]} -eq 0 ]]; then +if [[ ${#LIB_EMITTED[@]} -eq 0 ]]; then echo "no CT harness emitted artifacts found in $DEPS_DIR" >&2 exit 1 fi -if [[ ${#EMITTED[@]} -ne 3 ]]; then - echo "expected one fresh CT harness each for LLVM IR, assembly, and object output; found ${#EMITTED[@]}" >&2 - printf ' %s\n' "${EMITTED[@]}" >&2 +if [[ ${#LIB_EMITTED[@]} -ne 3 ]]; then + echo "expected one fresh CT harness each for LLVM IR, assembly, and object output; found ${#LIB_EMITTED[@]}" >&2 + printf ' %s\n' "${LIB_EMITTED[@]}" >&2 exit 1 fi -for artifact in "${EMITTED[@]}"; do +for artifact in "${LIB_EMITTED[@]}"; do cp "$artifact" "$ARTIFACT_DIR/" done +LINK_LOG="$ARTIFACT_DIR/linker-command.txt" +LINK_MAP="$ARTIFACT_DIR/rscrypto-ct-evidence.link-map.txt" +FINAL_LINK_ARGS=() +if [[ "$TARGET" == *linux* ]]; then + linker_env="$(target_env_name LINKER)" + if [[ "${!linker_env:-}" == "$ROOT/scripts/check/zig-cc.sh" ]]; then + export RSCRYPTO_CT_LINK_MAP="$LINK_MAP" + FINAL_LINK_ARGS+=("-C" "link-arg=-Wl,--print-map") + else + FINAL_LINK_ARGS+=("-C" "link-arg=-Wl,--Map=$LINK_MAP") + fi +fi +echo "building final linked equality evidence binary for $TARGET ($PROFILE)" +cargo rustc \ + --color never \ + --locked \ + --manifest-path tools/ct-harness/Cargo.toml \ + --target-dir "$BUILD_TARGET_DIR" \ + --target "$TARGET" \ + --release \ + --bin rscrypto-ct-evidence \ + -- \ + --emit=llvm-ir,asm,obj,link \ + --print link-args \ + "${FINAL_LINK_ARGS[@]}" \ + 2>&1 | tee "$LINK_LOG" + +link_command_count=$(grep -c '"-o"' "$LINK_LOG" || true) +if [[ "$link_command_count" -ne 1 ]]; then + echo "expected exactly one final equality linker command; found $link_command_count" >&2 + exit 1 +fi +if [[ "$TARGET" == *linux* && ! -s "$LINK_MAP" ]]; then + echo "final equality linker map is missing or empty: $LINK_MAP" >&2 + exit 1 +fi + +BIN_EMITTED=() +while IFS= read -r artifact; do + BIN_EMITTED+=("$artifact") +done < <( + find "$DEPS_DIR" -maxdepth 1 -type f \ + \( -name 'rscrypto_ct_evidence*.ll' \ + -o -name 'rscrypto_ct_evidence*.s' \ + -o -name 'rscrypto_ct_evidence*.o' \ + -o -name 'rscrypto_ct_evidence*.obj' \) \ + | sort +) + +if [[ ${#BIN_EMITTED[@]} -ne 3 ]]; then + echo "expected one fresh equality binary each for LLVM IR, assembly, and object output; found ${#BIN_EMITTED[@]}" >&2 + printf ' %s\n' "${BIN_EMITTED[@]}" >&2 + exit 1 +fi + +for artifact in "${BIN_EMITTED[@]}"; do + cp "$artifact" "$ARTIFACT_DIR/" +done + +FINAL_BINARY="$BUILD_TARGET_DIR/$TARGET/$PROFILE/rscrypto-ct-evidence" +if [[ -f "$FINAL_BINARY.exe" ]]; then + FINAL_BINARY="$FINAL_BINARY.exe" +fi +if [[ ! -f "$FINAL_BINARY" ]]; then + echo "final linked equality evidence binary missing: $FINAL_BINARY" >&2 + exit 1 +fi +cp "$FINAL_BINARY" "$ARTIFACT_DIR/" + OBJECTS=() while IFS= read -r obj; do OBJECTS+=("$obj") done < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f \( -name '*.o' -o -name '*.obj' \) | sort) -if [[ ${#OBJECTS[@]} -ne 1 ]]; then - echo "expected exactly one fresh CT harness object file in $ARTIFACT_DIR; found ${#OBJECTS[@]}" >&2 +if [[ ${#OBJECTS[@]} -ne 2 ]]; then + echo "expected exactly two fresh CT harness object files in $ARTIFACT_DIR; found ${#OBJECTS[@]}" >&2 exit 1 fi @@ -175,10 +245,39 @@ for obj in "${OBJECTS[@]}"; do objdump_args+=(--mattr=+vector) fi "$LLVM_OBJDUMP" "${objdump_args[@]}" "$obj" > "$ARTIFACT_DIR/$base.disasm.txt" + "$LLVM_NM" --defined-only "$obj" > "$ARTIFACT_DIR/$base.raw-symbols.txt" "$LLVM_NM" --defined-only --demangle "$obj" > "$ARTIFACT_DIR/$base.symbols.txt" "$LLVM_SIZE" "$obj" > "$ARTIFACT_DIR/$base.size.txt" done +linked_binary="$ARTIFACT_DIR/$(basename "$FINAL_BINARY")" +linked_base="$(basename "$linked_binary")" +raw_disassembly="$ARTIFACT_DIR/$linked_base.binary.raw-disasm.txt" +nm_symbols="$ARTIFACT_DIR/$linked_base.binary.nm-symbols.txt" +"$LLVM_OBJDUMP" --disassemble --reloc --demangle "$linked_binary" > "$raw_disassembly" +"$LLVM_NM" --defined-only --demangle "$linked_binary" > "$nm_symbols" || true +"$LLVM_SIZE" "$linked_binary" > "$ARTIFACT_DIR/$linked_base.binary.size.txt" +indirect_symbols="" +if [[ "$TARGET" == *apple-darwin ]]; then + indirect_symbols="$ARTIFACT_DIR/$linked_base.binary.indirect-symbols.txt" + "$LLVM_OBJDUMP" --macho --indirect-symbols "$linked_binary" > "$indirect_symbols" +fi + +symbolizer_args=( + --artifact-dir "$ARTIFACT_DIR" + --raw-disassembly "$raw_disassembly" + --nm-symbols "$nm_symbols" + --out-disassembly "$ARTIFACT_DIR/$linked_base.binary.disasm.txt" + --out-symbols "$ARTIFACT_DIR/$linked_base.binary.symbols.txt" +) +if [[ -f "$LINK_MAP" ]]; then + symbolizer_args+=(--link-map "$LINK_MAP") +fi +if [[ -n "$indirect_symbols" ]]; then + symbolizer_args+=(--indirect-symbols "$indirect_symbols") +fi +"$PYTHON" scripts/ct/symbolize_linked_binary.py "${symbolizer_args[@]}" + if command -v rustfilt >/dev/null 2>&1; then for symbols in "$ARTIFACT_DIR"/*.symbols.txt; do rustfilt < "$symbols" > "$symbols.rustfilt.txt" @@ -198,6 +297,7 @@ fi --out-dir "$OUT_DIR" \ --build-target-dir "$BUILD_TARGET_DIR" \ --backend llvm \ - --features std,full,parallel + --features std,full,parallel,diag \ + --linker-command-log "$LINK_LOG" echo "CT artifacts written to $OUT_DIR" diff --git a/scripts/ct/asm_heuristics.py b/scripts/ct/asm_heuristics.py index 9a2034a5..5eb1d6ed 100755 --- a/scripts/ct/asm_heuristics.py +++ b/scripts/ct/asm_heuristics.py @@ -292,11 +292,72 @@ def ct_intended_roots_by_primitive(root: Path) -> dict[str, set[str]]: return roots +def equality_release_roots(root: Path) -> set[str]: + ct = load_toml(root / "ct.toml") + release_binary = ct.get("equality_evidence", {}).get("release_binary", {}) + return set(release_binary.get("owner_symbols", [])) | set(release_binary.get("public_len_symbols", [])) + + def waivers(root: Path) -> list[dict[str, Any]]: ct = load_toml(root / "ct.toml") return list(ct.get("asm_waiver", [])) +def public_operand_rules(root: Path) -> list[dict[str, Any]]: + ct = load_toml(root / "ct.toml") + return list(ct.get("asm_public_operand", [])) + + +def apply_public_operand_rules(findings: list[dict[str, Any]], configured: list[dict[str, Any]]) -> list[str]: + required = {"primitive", "root", "symbol", "kind", "max_count", "source", "rationale"} + errors: list[str] = [] + match_counts = [0] * len(configured) + for index, rule in enumerate(configured): + if set(rule) != required: + errors.append(f"asm_public_operand[{index}] must contain exactly: {', '.join(sorted(required))}") + continue + if any(not isinstance(rule.get(field), str) or not rule.get(field, "").strip() for field in required - {"max_count"}): + errors.append(f"asm_public_operand[{index}] string fields must be non-empty") + max_count = rule.get("max_count") + if isinstance(max_count, bool) or not isinstance(max_count, int) or max_count <= 0: + errors.append(f"asm_public_operand[{index}] max_count must be a positive integer") + + if errors: + return errors + + for finding in findings: + matches = [ + index + for index, rule in enumerate(configured) + if rule["symbol"] == finding["symbol"] + and rule["kind"] == finding["kind"] + and rule["primitive"] in finding.get("primitive_ids", []) + and rule["root"] in finding.get("roots", []) + ] + if len(matches) > 1: + errors.append(f"multiple asm_public_operand rules match {finding['locator']}") + continue + if not matches: + continue + index = matches[0] + rule = configured[index] + match_counts[index] += 1 + finding["operand_class"] = "public" + finding["disposition"] = "accepted" + finding["public_classification"] = { + "primitive": rule["primitive"], + "root": rule["root"], + "source": rule["source"], + "rationale": rule["rationale"], + } + + for index, count in enumerate(match_counts): + max_count = configured[index]["max_count"] + if count > max_count: + errors.append(f"asm_public_operand[{index}] matched {count} findings, exceeding max_count {max_count}") + return errors + + def validate_waivers(configured: list[dict[str, Any]]) -> list[str]: errors = [] identities = set() @@ -482,7 +543,7 @@ def is_call_relocation(line: str) -> bool: return bool(re.search(r"\b(?:R_[A-Z0-9_]*(?:CALL(?:_PLT)?|PLT32)|ARM64_RELOC_BRANCH26|X86_64_RELOC_BRANCH)\b", line)) -def direct_callees(body: FunctionBody, functions: dict[str, FunctionBody]) -> set[str]: +def all_direct_callees(body: FunctionBody) -> set[str]: callees: set[str] = set() for index, (_, line) in enumerate(body.lines): inst = mnemonic(line) @@ -490,16 +551,26 @@ def direct_callees(body: FunctionBody, functions: dict[str, FunctionBody]) -> se continue targets = direct_call_targets(line) if inst in DIRECT_CALL_MNEMONICS and index + 1 < len(body.lines): - targets.update(direct_call_targets(body.lines[index + 1][1])) - callees.update( - target - for target in targets - if target in functions and target.startswith(("rscrypto::", "rscrypto_", "ct_entry_", " set[str]: + return { + target + for target in all_direct_callees(body) + if target in functions and target.startswith(("rscrypto::", "rscrypto_", "ct_entry_", " bool: + return symbol.startswith(("rscrypto::", "rscrypto_", "ct_entry_", " dict[str, set[str]]: return {symbol: direct_callees(body, functions) for symbol, body in functions.items()} @@ -526,6 +597,33 @@ def closure_roots( return by_primitive +def final_equality_closure(roots: set[str], functions: dict[str, FunctionBody], artifact: Path) -> dict[str, Any]: + call_graph = build_call_graph(functions) + by_root = {root: reachable_closure(root, call_graph) for root in sorted(roots) if root in functions} + reachable = sorted({symbol for symbols in by_root.values() for symbol in symbols}) + terminal_calls: dict[str, list[str]] = {} + unresolved_internal_calls = [] + for symbol in reachable: + callees = all_direct_callees(functions[symbol]) + terminal = sorted(target for target in callees if not is_ct_internal_symbol(target)) + if terminal: + terminal_calls[symbol] = terminal + for target in callees: + if is_ct_internal_symbol(target) and target not in functions: + unresolved_internal_calls.append({"caller": symbol, "target": target}) + return { + "artifact": f"artifacts/{artifact.name}", + "artifact_sha256": sha256_file(artifact), + "root_symbols": sorted(roots), + "missing_root_symbols": sorted(roots - set(functions)), + "reachable_symbols_by_root": {root: sorted(symbols) for root, symbols in by_root.items()}, + "reachable_symbol_count": len(reachable), + "reachable_symbols": reachable, + "terminal_calls_by_symbol": terminal_calls, + "unresolved_internal_calls": unresolved_internal_calls, + } + + def is_s390x_return(target: str, inst: str, line: str) -> bool: return target.startswith("s390x-") and inst == "br" and re.search(r"\bbr\s+%r14\b", line.lower()) is not None @@ -860,7 +958,7 @@ def scan_symbol( ) elif inst in DIRECT_CALL_MNEMONICS: callees = direct_call_targets(line) - if callees and callees <= local_symbols: + if callees and callees <= local_symbols and all(is_ct_internal_symbol(callee) for callee in callees): continue findings.append( finding( @@ -1064,6 +1162,8 @@ def main() -> int: symbol_primitives = primitive_symbols(root) ct_roots_by_primitive = ct_intended_roots_by_primitive(root) configured_waivers = waivers(root) + configured_public_operands = public_operand_rules(root) + equality_roots = equality_release_roots(root) waiver_errors = validate_waivers(configured_waivers) if waiver_errors: for error in waiver_errors: @@ -1072,6 +1172,11 @@ def main() -> int: functions: dict[str, FunctionBody] = {} findings: list[dict[str, Any]] = [] disassembly_files = sorted([*args.artifact_dir.glob("*.o.disasm.txt"), *args.artifact_dir.glob("*.obj.disasm.txt")]) + final_disassembly_files = sorted(args.artifact_dir.glob("*.binary.disasm.txt")) + if len(final_disassembly_files) != 1: + print(f"expected exactly one final binary disassembly, found {len(final_disassembly_files)}") + return 2 + disassembly_files.extend(final_disassembly_files) symbol_addresses = parse_symbol_addresses(args.artifact_dir) aliases = parse_asm_aliases(args.artifact_dir) @@ -1087,6 +1192,15 @@ def main() -> int: functions[symbol] = bodies[0] apply_alias_bodies(functions, aliases) + final_functions, _ = parse_disassembly(final_disassembly_files[0]) + final_equality = final_equality_closure(equality_roots, final_functions, final_disassembly_files[0]) + if final_equality["missing_root_symbols"] or final_equality["unresolved_internal_calls"]: + for symbol in final_equality["missing_root_symbols"]: + print(f"final equality binary is missing root {symbol}") + for call in final_equality["unresolved_internal_calls"]: + print(f"final equality closure cannot resolve {call['caller']} -> {call['target']}") + return 2 + call_graph = build_call_graph(functions) closures = closure_roots(ct_roots_by_primitive, call_graph) closure_symbols = {symbol for roots in closures.values() for symbols_for_root in roots.values() for symbol in symbols_for_root} @@ -1117,6 +1231,11 @@ def main() -> int: ) try: + public_operand_errors = apply_public_operand_rules(findings, configured_public_operands) + if public_operand_errors: + for error in public_operand_errors: + print(error) + return 2 waiver_match_errors = apply_waivers(findings, configured_waivers, args.target) except ValueError as exc: print(f"invalid asm waiver set: {exc}") @@ -1126,7 +1245,9 @@ def main() -> int: print(error) return 2 missing_symbols = sorted(symbol for symbol in symbols if symbol not in functions) - unwaived_failures = [item for item in findings if item["severity"] == "fail" and not item.get("waived")] + unwaived_failures = [ + item for item in findings if item["severity"] == "fail" and item["disposition"] != "accepted" and not item.get("waived") + ] unwaived_warnings = [item for item in findings if item["severity"] == "warn" and not item.get("waived")] needs_fix = [item for item in findings if item["disposition"] == "needs-fix" and not item.get("waived")] needs_binsec = [item for item in findings if item["disposition"] == "needs-binsec" and not item.get("waived")] @@ -1137,6 +1258,21 @@ def main() -> int: if item.get("operand_class") not in {"public", "secret", "unproven"} or item.get("disposition") not in {"accepted", "needs-fix", "needs-binsec"} ] + final_artifact = final_disassembly_files[0].name + final_reachable = set(final_equality["reachable_symbols"]) + final_equality["terminal_call_sites"] = [ + { + "symbol": item["symbol"], + "locator": item["locator"], + "text": item["text"], + "kind": item["kind"], + "function_sha256": item["function_sha256"], + } + for item in findings + if Path(item["file"]).name == final_artifact + and item["symbol"] in final_reachable + and item["kind"] in {"call", "indirect_call"} + ] report = { "schema_version": 2, @@ -1177,6 +1313,7 @@ def main() -> int: "root_symbol_count": sum(len(roots) for roots in ct_roots_by_primitive.values()), "reachable_symbol_count": len(closure_symbols), }, + "final_equality_call_closure": final_equality, "missing_symbols": missing_symbols, "finding_count": len(findings), "needs_fix_count": len(needs_fix), diff --git a/scripts/ct/asm_heuristics_test.py b/scripts/ct/asm_heuristics_test.py index 802acfdc..3cee2469 100755 --- a/scripts/ct/asm_heuristics_test.py +++ b/scripts/ct/asm_heuristics_test.py @@ -3,7 +3,7 @@ from pathlib import Path -from asm_heuristics import FunctionBody, direct_callees, is_call_relocation, is_riscv_conditional_branch +from asm_heuristics import FunctionBody, all_direct_callees, direct_callees, is_call_relocation, is_riscv_conditional_branch def main() -> None: @@ -28,6 +28,17 @@ def main() -> None: } assert direct_callees(root, functions) == {callee} + final_root = FunctionBody( + symbol="ct_entry_owner_eq_16", + path=Path("final.binary.disasm.txt"), + address=0, + lines=[ + (1, "1000: e8 0b 00 00 00\tcallq\t0x1010 "), + (2, "1005: 90\tadrp\tx0, 0x2000 "), + ], + ) + assert all_direct_callees(final_root) == {"rscrypto::fixed_eq"} + if __name__ == "__main__": main() diff --git a/scripts/ct/binsec.py b/scripts/ct/binsec.py index 9a3a9e16..f554c12e 100755 --- a/scripts/ct/binsec.py +++ b/scripts/ct/binsec.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys +from functools import lru_cache from pathlib import Path from toml_compat import tomllib @@ -28,6 +29,31 @@ def sha256_file(path: Path) -> str: return h.hexdigest() +@lru_cache(maxsize=1) +def candidate_identity() -> dict[str, object]: + with (ROOT / "Cargo.toml").open("rb") as source: + crate = tomllib.load(source) + with HARNESS_MANIFEST.open("rb") as source: + harness = tomllib.load(source) + dependency = harness["dependencies"]["rscrypto"] + rustc_verbose = run(["rustc", "-vV"]).stdout.strip() + status = run(["git", "status", "--short", "--untracked-files=all"]).stdout.splitlines() + return { + "crate_version": crate["package"]["version"], + "git_commit": run(["git", "rev-parse", "HEAD"]).stdout.strip(), + "git_dirty": bool(status), + "git_status": status, + "ct_manifest_sha256": sha256_file(ROOT / "ct.toml"), + "harness_manifest_sha256": sha256_file(HARNESS_MANIFEST), + "harness_lockfile_sha256": sha256_file(ROOT / "tools" / "ct-binsec-harness" / "Cargo.lock"), + "rustc_verbose": rustc_verbose, + "cargo": run(["cargo", "-V"]).stdout.strip(), + "features": dependency["features"], + "default_features": dependency.get("default-features", True), + "profile_settings": harness.get("profile", {}).get("release", {}), + } + + def run( cmd: list[str], *, @@ -365,6 +391,7 @@ def write_report( "schema_version": 1, "kind": "rscrypto.ct.binsec", "crate": "rscrypto", + **candidate_identity(), "backend": "llvm", "target": target, "target_triple": target, diff --git a/scripts/ct/dudect.sh b/scripts/ct/dudect.sh index 272f9312..56cab757 100755 --- a/scripts/ct/dudect.sh +++ b/scripts/ct/dudect.sh @@ -104,26 +104,77 @@ OUT_DIR="$ROOT/target/ct/$TARGET/$PROFILE/dudect" STDOUT_PATH="$OUT_DIR/dudect.stdout.txt" CSV_PATH="$OUT_DIR/dudect-raw.csv" REPORT_PATH="$OUT_DIR/dudect-report.json" +BINARY_PATH="$OUT_DIR/rscrypto-ct-dudect" +LINKER_COMMAND_PATH="$OUT_DIR/dudect-linker-command.txt" +BINARY_DISASM_PATH="$OUT_DIR/rscrypto-ct-dudect.binary.disasm.txt" +BINARY_SYMBOLS_PATH="$OUT_DIR/rscrypto-ct-dudect.binary.symbols.txt" mkdir -p "$OUT_DIR" rm -f "$STDOUT_PATH" "$CSV_PATH" "$REPORT_PATH" -CARGO_ARGS=(run --locked --manifest-path "$ROOT/tools/ct-dudect/Cargo.toml" --target-dir "$ROOT/target/ct-dudect-build" --target "$TARGET") +BUILD_TARGET_DIR="$ROOT/target/ct-dudect-build/$TARGET/$PROFILE" +if [[ ! -s "$LINKER_COMMAND_PATH" ]]; then + rm -rf "$BUILD_TARGET_DIR" +fi +CARGO_ARGS=(--locked --manifest-path "$ROOT/tools/ct-dudect/Cargo.toml" --target-dir "$BUILD_TARGET_DIR" --target "$TARGET") if [[ "$PROFILE" == "release" ]]; then CARGO_ARGS+=(--release) elif [[ "$PROFILE" != "debug" ]]; then echo "unsupported dudect profile: $PROFILE" >&2 exit 2 fi -CARGO_ARGS+=(-- --out "$CSV_PATH") + +linker_log_candidate="$(mktemp "$OUT_DIR/.dudect-linker-command.XXXXXXXX")" +cargo rustc "${CARGO_ARGS[@]}" --bin rscrypto-ct-dudect -- --print link-args 2>&1 | tee "$linker_log_candidate" +link_command_count=$(grep -c '"-o"' "$linker_log_candidate" || true) +if [[ "$link_command_count" -gt 1 ]]; then + echo "expected at most one DudeCT linker command; found $link_command_count" >&2 + rm -f "$linker_log_candidate" + exit 1 +fi +if [[ "$link_command_count" -eq 1 ]]; then + mv "$linker_log_candidate" "$LINKER_COMMAND_PATH" +else + rm -f "$linker_log_candidate" +fi +if [[ ! -s "$LINKER_COMMAND_PATH" ]]; then + echo "DudeCT linker command was not captured" >&2 + exit 1 +fi + +BUILT_BINARY="$BUILD_TARGET_DIR/$TARGET/$PROFILE/rscrypto-ct-dudect" +if [[ -f "$BUILT_BINARY.exe" ]]; then + BUILT_BINARY="$BUILT_BINARY.exe" + BINARY_PATH="$BINARY_PATH.exe" +fi +if [[ ! -f "$BUILT_BINARY" ]]; then + echo "DudeCT executable missing: $BUILT_BINARY" >&2 + exit 1 +fi +cp "$BUILT_BINARY" "$BINARY_PATH" + +SYSROOT="$(rustc --print sysroot)" +LLVM_BIN="$SYSROOT/lib/rustlib/$HOST_TARGET/bin" +LLVM_OBJDUMP="${LLVM_OBJDUMP:-$LLVM_BIN/llvm-objdump}" +LLVM_NM="${LLVM_NM:-$LLVM_BIN/llvm-nm}" +for tool in "$LLVM_OBJDUMP" "$LLVM_NM"; do + if [[ ! -x "$tool" ]]; then + echo "missing LLVM tool: $tool" >&2 + exit 1 + fi +done +"$LLVM_OBJDUMP" --disassemble --reloc --demangle "$BINARY_PATH" > "$BINARY_DISASM_PATH" +"$LLVM_NM" --defined-only --demangle "$BINARY_PATH" > "$BINARY_SYMBOLS_PATH" + +RUNNER_ARGS=(--out "$CSV_PATH") if [[ -n "$FILTER" ]]; then - CARGO_ARGS+=(--filter "$FILTER") + RUNNER_ARGS+=(--filter "$FILTER") fi -COMMAND="RSCRYPTO_CT_DUDECT_SAMPLES=$SAMPLES cargo ${CARGO_ARGS[*]}" +COMMAND="RSCRYPTO_CT_DUDECT_SAMPLES=$SAMPLES $BINARY_PATH ${RUNNER_ARGS[*]}" echo "$COMMAND" ( cd "$ROOT" - RSCRYPTO_CT_DUDECT_SAMPLES="$SAMPLES" cargo "${CARGO_ARGS[@]}" + RSCRYPTO_CT_DUDECT_SAMPLES="$SAMPLES" "$BINARY_PATH" "${RUNNER_ARGS[@]}" ) | tee "$STDOUT_PATH" PYTHON="$("$ROOT/scripts/ct/python.sh" --print)" @@ -141,4 +192,8 @@ fi --profile "$PROFILE" \ --threshold "$THRESHOLD" \ --samples "$SAMPLES" \ - --command "$COMMAND" + --command "$COMMAND" \ + --binary "$BINARY_PATH" \ + --binary-disassembly "$BINARY_DISASM_PATH" \ + --binary-symbols "$BINARY_SYMBOLS_PATH" \ + --linker-command-log "$LINKER_COMMAND_PATH" diff --git a/scripts/ct/dudect_report.py b/scripts/ct/dudect_report.py index 73beaae9..c912b9e6 100755 --- a/scripts/ct/dudect_report.py +++ b/scripts/ct/dudect_report.py @@ -9,10 +9,15 @@ import json import platform import re +import shlex +import shutil import subprocess from datetime import datetime, timezone from pathlib import Path +from toml_compat import tomllib +from provenance import cfg_target_features, codegen_value, codegen_values, resolved_rustflags + CASE_METADATA = { "owner_eq_16_equal_vs_first_diff": { @@ -559,8 +564,76 @@ def main() -> int: parser.add_argument("--threshold", type=float, default=10.0) parser.add_argument("--samples", type=int, required=True) parser.add_argument("--command", default="") + parser.add_argument("--binary", required=True, type=Path) + parser.add_argument("--binary-disassembly", required=True, type=Path) + parser.add_argument("--binary-symbols", required=True, type=Path) + parser.add_argument("--linker-command-log", required=True, type=Path) args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + with (root / "Cargo.toml").open("rb") as source: + crate_version = tomllib.load(source)["package"]["version"] + with (root / "ct.toml").open("rb") as source: + release_binary = tomllib.load(source)["equality_evidence"]["release_binary"] + dudect_manifest_path = root / "tools" / "ct-dudect" / "Cargo.toml" + harness_manifest_path = root / "tools" / "ct-harness" / "Cargo.toml" + dudect_lockfile_path = root / "tools" / "ct-dudect" / "Cargo.lock" + with dudect_manifest_path.open("rb") as source: + dudect_manifest = tomllib.load(source) + configured_rustflags, environment_rustflags, effective_rustflags, rustflags_source = resolved_rustflags( + root, args.target + ) + target_cfg = subprocess.check_output( + ["rustc", "--print", "cfg", "--target", args.target, *effective_rustflags], + cwd=root, + text=True, + ) + expected_owner_symbols = {f"ct_entry_owner_eq_{width}" for width in release_binary["formal_owner_widths"]} + for path in (args.binary, args.binary_disassembly, args.binary_symbols, args.linker_command_log): + if not path.is_file(): + raise ValueError(f"DudeCT evidence artifact missing: {path}") + + symbol_counts = {symbol: 0 for symbol in expected_owner_symbols} + for line in args.binary_symbols.read_text().splitlines(): + if match := re.search(r"\b_?(ct_entry_owner_eq_[0-9]+)\b", line): + if match.group(1) in symbol_counts: + symbol_counts[match.group(1)] += 1 + wrong_symbol_counts = {symbol: count for symbol, count in symbol_counts.items() if count != 1} + if wrong_symbol_counts: + raise ValueError(f"DudeCT binary owner equality symbols must occur exactly once: {wrong_symbol_counts}") + owner_call_sites = {symbol: 0 for symbol in expected_owner_symbols} + current_symbol = "" + function_label = re.compile(r"^[0-9a-fA-F]+ <(.+)>:$") + for line in args.binary_disassembly.read_text(errors="replace").splitlines(): + if match := function_label.match(line.strip()): + current_symbol = match.group(1).removeprefix("_") + continue + instruction = re.search(r"\b(?:bl|brasl|call|callq|jal)\b", line) + if instruction is None: + continue + for symbol in expected_owner_symbols: + if current_symbol != symbol and re.search(rf"<_?{re.escape(symbol)}(?:\+[^>]*)?>", line): + owner_call_sites[symbol] += 1 + missing_call_sites = {symbol: count for symbol, count in owner_call_sites.items() if count < 1} + if missing_call_sites: + raise ValueError(f"DudeCT binary does not call every owner equality symbol: {missing_call_sites}") + + linker_command = next( + (line for line in args.linker_command_log.read_text().splitlines() if '"-o"' in line), + "", + ) + linker_tokens = shlex.split(linker_command) + first_object = next((index for index, token in enumerate(linker_tokens) if token.endswith((".o", ".obj"))), None) + if first_object is None or first_object == 0: + raise ValueError("DudeCT linker command does not identify the linker driver") + linker = linker_tokens[first_object - 1] + linker_path_text = shutil.which(linker) + if linker_path_text is None: + raise ValueError(f"DudeCT linker driver is not resolvable: {linker}") + linker_path = Path(linker_path_text).resolve() + linker_version = subprocess.check_output([str(linker_path), "--version"], cwd=root, text=True, stderr=subprocess.STDOUT).strip() + git_status = subprocess.check_output(["git", "status", "--short", "--untracked-files=all"], cwd=root, text=True).splitlines() + seeds, results = parse_stdout(args.stdout) raw_rows = raw_csv_rows(args.csv) cases = [] @@ -588,13 +661,58 @@ def main() -> int: ) report = { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.dudect", "crate": "rscrypto", + "crate_version": crate_version, + "git_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip(), + "git_dirty": bool(git_status), + "git_status": git_status, "generated_at_utc": datetime.now(timezone.utc).isoformat(), "target": args.target, "target_triple": args.target, "profile": args.profile, + "profile_settings": dudect_manifest.get("profile", {}).get(args.profile, {}), + "features": release_binary["features"], + "default_features": release_binary["default_features"], + "backend": release_binary["backend"], + "dudect_manifest_sha256": sha256_file(dudect_manifest_path), + "harness_manifest_sha256": sha256_file(harness_manifest_path), + "dudect_lockfile_sha256": sha256_file(dudect_lockfile_path), + "cargo": subprocess.check_output(["cargo", "-V"], cwd=root, text=True).strip(), + "configured_rustflags": configured_rustflags, + "environment_rustflags": environment_rustflags, + "effective_rustflags": effective_rustflags, + "rustflags_source": rustflags_source, + "target_cpu": codegen_value(effective_rustflags, "target-cpu"), + "target_features": codegen_values(effective_rustflags, "target-feature"), + "target_cfg_features": cfg_target_features(target_cfg), + "linker": linker, + "linker_path": str(linker_path), + "linker_sha256": sha256_file(linker_path), + "linker_version": linker_version, + "binary": { + "path": str(args.binary), + "sha256": sha256_file(args.binary), + "bytes": args.binary.stat().st_size, + "owner_symbols": sorted(expected_owner_symbols), + "owner_call_sites": owner_call_sites, + }, + "binary_disassembly": { + "path": str(args.binary_disassembly), + "sha256": sha256_file(args.binary_disassembly), + "bytes": args.binary_disassembly.stat().st_size, + }, + "binary_symbols": { + "path": str(args.binary_symbols), + "sha256": sha256_file(args.binary_symbols), + "bytes": args.binary_symbols.stat().st_size, + }, + "linker_command_log": { + "path": str(args.linker_command_log), + "sha256": sha256_file(args.linker_command_log), + "bytes": args.linker_command_log.stat().st_size, + }, "threshold_abs_max_t": args.threshold, "requested_samples": args.samples, "command": args.command, diff --git a/scripts/ct/evidence_validation_test.py b/scripts/ct/evidence_validation_test.py new file mode 100644 index 00000000..481673be --- /dev/null +++ b/scripts/ct/evidence_validation_test.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Regression tests for fail-closed CT evidence identity checks.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from asm_heuristics import apply_public_operand_rules +from provenance import codegen_value +from symbolize_linked_binary import Symbol, parse_indirect_symbols, parse_link_map, symbolize +from validate_release_evidence import ( + parse_hashes, + records_by_path, + records_by_name, + validate_equality_index, + validate_exact_candidate, + validate_heuristics, +) + + +def expect_failure(action) -> None: + try: + action() + except ValueError: + return + raise AssertionError("evidence mutation unexpectedly passed") + + +def equality_fixture() -> tuple[dict, dict, dict]: + owner = "ct_entry_owner_eq_16" + public = "ct_entry_kmac256_verify" + ct = { + "equality_evidence": { + "release_binary": { + "owner_symbols": [owner], + "public_len_symbols": [public], + } + } + } + symbols = [] + for name in (owner, public): + symbols.append( + { + "name": name, + "locations": [ + {"object": "rscrypto_ct_evidence-deadbeef.o"}, + {"object": "rscrypto-ct-evidence.binary"}, + ], + } + ) + evidence = {"primitives": [{"harness": {"symbols": symbols}}]} + heuristics = { + "schema_version": 2, + "kind": "rscrypto.ct.asm-heuristics", + "target": "x86_64-unknown-linux-gnu", + "profile": "release", + "needs_fix_count": 0, + "needs_binsec_count": 0, + "accepted_count": 0, + "unclassified_count": 0, + "unwaived_fail_count": 0, + "missing_symbols": [], + "finding_count": 0, + "symbol_summary": {owner: {"present": True}, public: {"present": True}}, + "ct_intended_call_closure": { + "primitive_summary": { + "owner_equality.fixed": { + "root_symbols": [owner], + "missing_root_symbols": [], + } + } + }, + "disassembly_files": [ + { + "path": "artifacts/rscrypto-ct-evidence.binary.disasm.txt", + "sha256": "a" * 64, + } + ], + "final_equality_call_closure": { + "artifact": "artifacts/rscrypto-ct-evidence.binary.disasm.txt", + "artifact_sha256": "a" * 64, + "root_symbols": [owner, public], + "missing_root_symbols": [], + "unresolved_internal_calls": [], + "terminal_call_sites": [], + }, + } + return ct, evidence, heuristics + + +def main() -> None: + assert codegen_value(["-C", "target-cpu=native", "-C", "target-cpu=x86-64"], "target-cpu") == "x86-64" + + commit = "a" * 40 + validate_exact_candidate("1.2.3", commit, "1.2.3", commit) + expect_failure(lambda: validate_exact_candidate("1.2.3", commit, "1.2.4", commit)) + expect_failure(lambda: validate_exact_candidate("1.2.3", commit, "1.2.3", "b" * 40)) + + expect_failure(lambda: records_by_name([{"name": "same"}, {"name": "same"}], "fixture")) + expect_failure(lambda: records_by_path([{"path": "same"}, {"path": "same"}], "fixture")) + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + hashes = temporary_path / "hashes.txt" + hashes.write_text(f"{'0' * 64} artifact\n{'1' * 64} artifact\n") + expect_failure(lambda: parse_hashes(hashes)) + + link_map = temporary_path / "link-map.txt" + link_map.write_text( + " VMA LMA Size Align Out In Symbol\n" + " 1000 1000 10 16 input.o:(.text.ct_entry_owner_eq_16)\n" + " 1010 1010 10 16 input.o:(.text._RNv_test)\n" + ) + mapped = parse_link_map(link_map, {"_RNv_test": "rscrypto::fixed_eq"}) + assert mapped == [Symbol(0x1000, 0x10, "ct_entry_owner_eq_16"), Symbol(0x1010, 0x10, "rscrypto::fixed_eq")] + + gnu_link_map = temporary_path / "gnu-link-map.txt" + gnu_link_map.write_text( + " .text.ct_entry_owner_eq_16\n" + " 0x0000000000002000 0x10 input.o\n" + " .text._RNv_test\n" + " 0x0000000000002010 0x10 input.o\n" + ) + assert parse_link_map(gnu_link_map, {"_RNv_test": "rscrypto::fixed_eq"}) == [ + Symbol(0x2000, 0x10, "ct_entry_owner_eq_16"), + Symbol(0x2010, 0x10, "rscrypto::fixed_eq"), + ] + + indirect_symbols = temporary_path / "indirect-symbols.txt" + indirect_symbols.write_text( + "Indirect symbols for (__TEXT,__stubs) 1 entries\n" + "address index name\n" + "0x00000001000719d8 864 _memcpy\n" + ) + assert parse_indirect_symbols(indirect_symbols) == [Symbol(0x1000719D8, 1, "_memcpy")] + + raw_disassembly = temporary_path / "raw.disasm.txt" + raw_disassembly.write_text( + "Disassembly of section .text:\n" + " 1000: e8 0b 00 00 00 callq 0x1010 <.text+0x10>\n" + " 1005: e8 06 00 00 00 callq 0x1010 <::verify::<128>>\n" + " 1010: c3 retq\n" + ) + symbolized = temporary_path / "symbolized.disasm.txt" + symbolize(raw_disassembly, symbolized, mapped) + output = symbolized.read_text() + assert ":" in output and "" in output + assert "0x1010 " in output + assert "Owner" not in output + expect_failure(lambda: symbolize(raw_disassembly, symbolized, [Symbol(0x1020, 1, "missing")])) + + ct, evidence, heuristics = equality_fixture() + validate_equality_index(evidence, "fixture", ct) + validate_heuristics(heuristics, "fixture", "x86_64-unknown-linux-gnu", ct) + evidence["primitives"][0]["harness"]["symbols"][0]["locations"].pop() + expect_failure(lambda: validate_equality_index(evidence, "fixture", ct)) + _, _, missing_terminal_calls = equality_fixture() + missing_terminal_calls["final_equality_call_closure"].pop("terminal_call_sites") + expect_failure( + lambda: validate_heuristics(missing_terminal_calls, "fixture", "x86_64-unknown-linux-gnu", ct) + ) + heuristics["missing_symbols"] = ["ct_entry_owner_eq_16"] + expect_failure(lambda: validate_heuristics(heuristics, "fixture", "x86_64-unknown-linux-gnu", ct)) + + finding = { + "symbol": "rscrypto::auth::argon2::fill_segment_inner", + "kind": "variable_latency_division", + "primitive_ids": ["password.argon2i"], + "roots": ["ct_entry_argon2i_verify"], + "locator": "fixture", + "operand_class": "unproven", + "disposition": "needs-fix", + } + rule = { + "primitive": "password.argon2i", + "root": "ct_entry_argon2i_verify", + "symbol": finding["symbol"], + "kind": finding["kind"], + "max_count": 1, + "source": "src/auth/argon2/mod.rs:1434", + "rationale": "public addressing inputs", + } + assert apply_public_operand_rules([finding], [rule]) == [] + assert finding["operand_class"] == "public" and finding["disposition"] == "accepted" + extra = dict(finding, locator="fixture-2", operand_class="unproven", disposition="needs-fix") + assert apply_public_operand_rules([finding, extra], [rule]) + + +if __name__ == "__main__": + main() diff --git a/scripts/ct/full.py b/scripts/ct/full.py index 2424208a..4484b02f 100755 --- a/scripts/ct/full.py +++ b/scripts/ct/full.py @@ -343,6 +343,26 @@ def load_json_if_exists(path: Path) -> dict[str, Any] | None: return None +def candidate_identity(out_dir: Path) -> dict[str, Any]: + provenance_path = out_dir / "provenance.json" + provenance = load_json_if_exists(provenance_path) + if provenance is None: + return { + "crate_version": None, + "git_commit": None, + "git_dirty": None, + "provenance": "provenance.json", + "provenance_sha256": None, + } + return { + "crate_version": provenance.get("crate_version"), + "git_commit": provenance.get("git_commit"), + "git_dirty": provenance.get("git_dirty"), + "provenance": "provenance.json", + "provenance_sha256": sha256_file(provenance_path), + } + + def collect_artifact_records(out_dir: Path) -> list[dict[str, Any]]: records = [] for relative, kind in ( @@ -355,11 +375,39 @@ def collect_artifact_records(out_dir: Path) -> list[dict[str, Any]]: if path.exists(): records.append(file_record(path, out_dir, kind)) - for path in sorted((out_dir / "dudect" / "cases").glob("*/dudect-report.json")): - records.append(file_record(path, out_dir, "dudect_report")) + for path in sorted((out_dir / "dudect" / "cases").glob("*/*")): + if not path.is_file(): + continue + kind = { + "dudect-report.json": "dudect_report", + "dudect-raw.csv": "dudect_raw_samples", + "dudect.stdout.txt": "dudect_stdout", + }.get(path.name, "dudect_component") + records.append(file_record(path, out_dir, kind)) + + for relative, kind in ( + ("dudect/rscrypto-ct-dudect", "dudect_binary"), + ("dudect/rscrypto-ct-dudect.exe", "dudect_binary"), + ("dudect/rscrypto-ct-dudect.binary.disasm.txt", "dudect_binary_disassembly"), + ("dudect/rscrypto-ct-dudect.binary.symbols.txt", "dudect_binary_symbol_map"), + ("dudect/dudect-linker-command.txt", "dudect_linker_command"), + ): + path = out_dir / relative + if path.exists(): + records.append(file_record(path, out_dir, kind)) - for path in sorted((out_dir / "binsec").glob("*/binsec-report.json")): - records.append(file_record(path, out_dir, "binsec_report")) + for path in sorted((out_dir / "binsec").glob("*/*")): + if not path.is_file(): + continue + kind = { + "binsec-report.json": "binsec_report", + "driver.elf": "binsec_driver", + "driver.disasm": "binsec_driver_disassembly", + "checkct.cfg": "binsec_configuration", + "binsec.log": "binsec_log", + "binsec-stats.toml": "binsec_statistics", + }.get(path.name, "binsec_component") + records.append(file_record(path, out_dir, kind)) return records @@ -443,6 +491,38 @@ def dudect_case_result( ): if key in case_report: row[key] = case_report[key] + if report is not None: + for key in ( + "crate_version", + "git_commit", + "git_dirty", + "features", + "default_features", + "backend", + "profile_settings", + "dudect_manifest_sha256", + "harness_manifest_sha256", + "dudect_lockfile_sha256", + "rustc_verbose", + "cargo", + "configured_rustflags", + "environment_rustflags", + "effective_rustflags", + "rustflags_source", + "target_cpu", + "target_features", + "target_cfg_features", + "linker", + "linker_path", + "linker_sha256", + "linker_version", + "binary", + "binary_disassembly", + "binary_symbols", + "linker_command_log", + ): + if key in report: + row[key] = report[key] row["primitive"] = case["primitive"] row["gate"] = case.get("gate", "required") row["diagnostic_reason"] = case.get("reason") or case.get("notes") or row.get("diagnostic_reason") @@ -1009,6 +1089,7 @@ def main() -> int: timeout=None, ) steps.append(result_record(validate_result)) + identity = candidate_identity(out_dir) if artifacts_result.status != "pass" or validate_result.status != "pass": for step in steps: if step["status"] != "pass": @@ -1019,6 +1100,7 @@ def main() -> int: "schema_version": 1, "kind": "rscrypto.ct.full-report", "crate": "rscrypto", + **identity, "generated_at_utc": now_utc(), "target": target, "target_triple": target, @@ -1148,6 +1230,27 @@ def main() -> int: "report": str(report_path), "reason": report.get("reason"), "category": binsec_result_category(report), + "backend": report.get("backend"), + "target": report.get("target"), + "target_triple": report.get("target_triple"), + "profile": report.get("profile"), + "crate_version": report.get("crate_version"), + "git_commit": report.get("git_commit"), + "git_dirty": report.get("git_dirty"), + "ct_manifest_sha256": report.get("ct_manifest_sha256"), + "harness_manifest_sha256": report.get("harness_manifest_sha256"), + "harness_lockfile_sha256": report.get("harness_lockfile_sha256"), + "rustc_verbose": report.get("rustc_verbose"), + "cargo": report.get("cargo"), + "features": report.get("features"), + "default_features": report.get("default_features"), + "profile_settings": report.get("profile_settings"), + "rustflags": report.get("rustflags"), + "harness_elf_type": report.get("harness_elf_type"), + "load_sections": report.get("load_sections"), + "binsec_version": report.get("binsec_version"), + "artifacts": report.get("artifacts", {}), + "artifact_dir": str(report_path.parent.relative_to(out_dir)), } ) else: @@ -1203,6 +1306,7 @@ def main() -> int: "schema_version": 1, "kind": "rscrypto.ct.full-report", "crate": "rscrypto", + **identity, "generated_at_utc": now_utc(), "target": target, "target_triple": target, diff --git a/scripts/ct/provenance.py b/scripts/ct/provenance.py index 877b3b8e..47007429 100755 --- a/scripts/ct/provenance.py +++ b/scripts/ct/provenance.py @@ -8,6 +8,7 @@ import json import os import re +import shlex import shutil import socket import subprocess @@ -88,23 +89,35 @@ def target_rustflags(root: Path, target: str) -> list[str]: return list(flags) -def env_rustflags() -> list[str]: - flags: list[str] = [] - if value := os.environ.get("RUSTFLAGS"): - flags.extend(value.split()) +def resolved_rustflags(root: Path, target: str) -> tuple[list[str], list[str], list[str], str]: + configured = target_rustflags(root, target) if value := os.environ.get("CARGO_ENCODED_RUSTFLAGS"): - flags.extend(part for part in value.split("\x1f") if part) - return flags + environment = [part for part in value.split("\x1f") if part] + return configured, environment, environment, "CARGO_ENCODED_RUSTFLAGS" + if value := os.environ.get("RUSTFLAGS"): + environment = shlex.split(value) + return configured, environment, environment, "RUSTFLAGS" + target_key = target_env_key(target, "RUSTFLAGS") + if value := os.environ.get(target_key): + environment = shlex.split(value) + return configured, environment, configured + environment, target_key + if configured: + return configured, [], configured, ".cargo/config.toml" + if value := os.environ.get("CARGO_BUILD_RUSTFLAGS"): + environment = shlex.split(value) + return configured, environment, environment, "CARGO_BUILD_RUSTFLAGS" + return configured, [], [], "none" def codegen_value(flags: list[str], key: str) -> str: prefix = f"{key}=" + value = "unspecified" for idx, flag in enumerate(flags): if flag == "-C" and idx + 1 < len(flags) and flags[idx + 1].startswith(prefix): - return flags[idx + 1][len(prefix) :] - if flag.startswith(f"-C{prefix}"): - return flag[len(f"-C{prefix}") :] - return "unspecified" + value = flags[idx + 1][len(prefix) :] + elif flag.startswith(f"-C{prefix}"): + value = flag[len(f"-C{prefix}") :] + return value def codegen_values(flags: list[str], key: str) -> list[str]: @@ -140,11 +153,54 @@ def resolve_linker(root: Path, target: str) -> tuple[str, str]: if linker: return str(linker), ".cargo/config.toml" - return "platform-default-unpinned", "default" + return "cc", "rustc-target-default" + + +def resolve_executable(command: str) -> Path | None: + parts = shlex.split(command) + if not parts: + return None + executable = "zig" if Path(parts[0]).name == "zig-cc.sh" else parts[0] + found = shutil.which(executable) + if found is None: + return None + return Path(found).resolve() + + +def resolved_linker_version(path: Path | None, configured_linker: str, root: Path) -> str | None: + if path is None: + return None + parts = shlex.split(configured_linker) + if parts and Path(parts[0]).name == "zig-cc.sh": + zig_version = optional_version([str(path), "version"], cwd=root) + return f"zig {zig_version}" if zig_version else None + return optional_version([str(path), "--version"], cwd=root) def artifact_kind(path: Path) -> str: name = path.name + if name == "rscrypto-ct-evidence.link-map.txt": + return "linked_binary_link_map" + if name.endswith(".binary.raw-disasm.txt"): + return "linked_binary_raw_disassembly" + if name.endswith(".binary.nm-symbols.txt"): + return "linked_binary_nm_symbol_map" + if name.endswith(".binary.indirect-symbols.txt"): + return "linked_binary_indirect_symbol_map" + if name.endswith(".binary.disasm.txt"): + return "linked_binary_disassembly" + if name.endswith(".binary.symbols.txt"): + return "linked_binary_symbol_map" + if name.endswith(".binary.symbols.txt.rustfilt.txt"): + return "linked_binary_demangled_symbol_map" + if name.endswith(".binary.size.txt"): + return "linked_binary_size" + if name == "linker-command.txt": + return "linker_command" + if name in {"rscrypto-ct-evidence", "rscrypto-ct-evidence.exe"}: + return "linked_binary" + if name.endswith((".o.raw-symbols.txt", ".obj.raw-symbols.txt")): + return "raw_symbol_map" if name.endswith((".o.disasm.txt", ".obj.disasm.txt")): return "object_disassembly" if name.endswith((".o.symbols.txt.rustfilt.txt", ".obj.symbols.txt.rustfilt.txt")): @@ -216,7 +272,9 @@ def report_records(out_dir: Path) -> list[dict[str, Any]]: def symbol_objects(artifact_dir: Path) -> dict[str, list[dict[str, str]]]: symbols: dict[str, list[dict[str, str]]] = {} - for path in sorted([*artifact_dir.glob("*.o.symbols.txt"), *artifact_dir.glob("*.obj.symbols.txt")]): + symbol_maps = sorted([*artifact_dir.glob("*.o.symbols.txt"), *artifact_dir.glob("*.obj.symbols.txt")]) + symbol_maps.extend(sorted(artifact_dir.glob("*.binary.symbols.txt"))) + for path in symbol_maps: object_name = path.name.removesuffix(".symbols.txt") for line in path.read_text().splitlines(): if match := re.search(r"\b_?(ct_entry_[A-Za-z0-9_]+)\b", line): @@ -278,6 +336,7 @@ def main() -> int: parser.add_argument("--build-target-dir", required=True, type=Path) parser.add_argument("--backend", default="llvm") parser.add_argument("--features", default="std,full") + parser.add_argument("--linker-command-log", required=True, type=Path) args = parser.parse_args() root = Path(__file__).resolve().parents[2] @@ -287,24 +346,25 @@ def main() -> int: rustc_verbose_text = run(["rustc", "-vV"], cwd=root) rustc_verbose = parse_rustc_verbose(rustc_verbose_text) - target_cfg_text = run(["rustc", "--print", "cfg", "--target", args.target], cwd=root) - configured_rustflags = target_rustflags(root, args.target) - environment_rustflags = env_rustflags() - effective_rustflags = configured_rustflags + environment_rustflags + configured_rustflags, environment_rustflags, effective_rustflags, rustflags_source = resolved_rustflags( + root, args.target + ) + target_cfg_text = run(["rustc", "--print", "cfg", "--target", args.target, *effective_rustflags], cwd=root) linker, linker_source = resolve_linker(root, args.target) + linker_path = resolve_executable(linker) artifacts = artifact_records(args.artifact_dir) reports = report_records(args.out_dir) write_artifact_hashes(args.out_dir, artifacts) dependency_lockfile = root / "tools" / "ct-harness" / "Cargo.lock" workspace_lockfile = root / "Cargo.lock" - profile = cargo.get("profile", {}).get(args.profile, {}) + profile = harness_manifest.get("profile", {}).get(args.profile, {}) status_entries = git_status(root) feature_list = [feature for feature in args.features.split(",") if feature] provenance = { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.provenance", "generated_at_utc": datetime.now(UTC).isoformat(timespec="seconds"), "crate": cargo.get("package", {}).get("name"), @@ -347,8 +407,14 @@ def main() -> int: "configured_rustflags": configured_rustflags, "environment_rustflags": environment_rustflags, "effective_rustflags": effective_rustflags, + "rustflags_source": rustflags_source, "linker": linker, "linker_source": linker_source, + "linker_path": str(linker_path) if linker_path is not None else None, + "linker_sha256": sha256_file(linker_path) if linker_path is not None and linker_path.is_file() else None, + "linker_version": resolved_linker_version(linker_path, linker, root), + "linker_command": f"artifacts/{args.linker_command_log.name}", + "linker_command_sha256": sha256_file(args.linker_command_log), "link_args": codegen_values(effective_rustflags, "link-arg"), "profile": args.profile, "opt_level": str(profile.get("opt-level", "unknown")), @@ -380,7 +446,7 @@ def main() -> int: symbols = symbol_objects(args.artifact_dir) expected_symbols = {symbol for harness in ct.get("harness", []) for symbol in harness.get("symbols", [])} evidence_index = { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.evidence-index", "provenance": "provenance.json", "provenance_sha256": sha256_file(provenance_path), diff --git a/scripts/ct/symbolize_linked_binary.py b/scripts/ct/symbolize_linked_binary.py new file mode 100644 index 00000000..86e040f0 --- /dev/null +++ b/scripts/ct/symbolize_linked_binary.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Reconstruct named final-binary disassembly from linker and object evidence.""" + +from __future__ import annotations + +import argparse +import bisect +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Symbol: + address: int + size: int + name: str + + +def parse_nm_line(line: str) -> tuple[int, str, str] | None: + match = re.match(r"^([0-9a-fA-F]+)\s+(\S)\s+(.+)$", line.strip()) + if match is None: + return None + return int(match.group(1), 16), match.group(2), match.group(3) + + +def demangle_table(artifact_dir: Path) -> dict[str, str]: + names: dict[str, str] = {} + for raw_path in sorted(artifact_dir.glob("*.o.raw-symbols.txt")) + sorted( + artifact_dir.glob("*.obj.raw-symbols.txt") + ): + demangled_path = raw_path.with_name(raw_path.name.replace(".raw-symbols.txt", ".symbols.txt")) + if not demangled_path.is_file(): + raise ValueError(f"demangled object symbol map missing for {raw_path.name}") + raw_rows = [parse_nm_line(line) for line in raw_path.read_text(errors="replace").splitlines()] + demangled_rows = [parse_nm_line(line) for line in demangled_path.read_text(errors="replace").splitlines()] + if len(raw_rows) != len(demangled_rows): + raise ValueError(f"raw and demangled symbol maps differ in length for {raw_path.name}") + for raw, demangled in zip(raw_rows, demangled_rows, strict=True): + if raw is None and demangled is None: + continue + if raw is None or demangled is None or raw[:2] != demangled[:2]: + raise ValueError(f"raw and demangled symbol maps are inconsistent for {raw_path.name}") + names[raw[2]] = demangled[2] + return names + + +def section_symbol(section: str, known_names: dict[str, str]) -> str | None: + if not section.startswith(".text."): + return None + name = section.removeprefix(".text.") + for prefix in ("unlikely.", "hot.", "cold."): + if name.startswith(prefix): + name = name.removeprefix(prefix) + break + if name.startswith("ct_entry_") or name in known_names or name.startswith(("_R", "_ZN")): + return name + return None + + +def parse_link_map(path: Path, known_names: dict[str, str]) -> list[Symbol]: + symbols: list[Symbol] = [] + table_row = re.compile(r"^\s*([0-9a-fA-F]+)\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)\s+\d+\s+(.+)$") + gnu_section = re.compile(r"^\s*(\.text(?:\.[^ ]+)?)\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)(?:\s+.*)?$") + gnu_wrapped_section = re.compile(r"^\s*(\.text\.[^ ]+)\s*$") + gnu_wrapped_address = re.compile(r"^\s*0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)(?:\s+.*)?$") + pending_gnu_section: str | None = None + for line in path.read_text(errors="replace").splitlines(): + match = table_row.match(line) + if match is not None: + pending_gnu_section = None + address = int(match.group(1), 16) + size = int(match.group(3), 16) + section_match = re.search(r":\((\.text(?:\.[^)]+)?)\)$", match.group(4)) + raw_name = section_symbol(section_match.group(1), known_names) if section_match else None + if raw_name is not None and size > 0: + symbols.append(Symbol(address, size, known_names.get(raw_name, raw_name))) + continue + match = gnu_section.match(line) + if match is not None: + pending_gnu_section = None + raw_name = section_symbol(match.group(1), known_names) + size = int(match.group(3), 16) + if raw_name is not None and size > 0: + symbols.append(Symbol(int(match.group(2), 16), size, known_names.get(raw_name, raw_name))) + continue + match = gnu_wrapped_section.match(line) + if match is not None: + pending_gnu_section = match.group(1) + continue + match = gnu_wrapped_address.match(line) + if match is not None and pending_gnu_section is not None: + raw_name = section_symbol(pending_gnu_section, known_names) + size = int(match.group(2), 16) + if raw_name is not None and size > 0: + symbols.append(Symbol(int(match.group(1), 16), size, known_names.get(raw_name, raw_name))) + pending_gnu_section = None + return symbols + + +def parse_final_nm(path: Path) -> list[Symbol]: + rows = [row for line in path.read_text(errors="replace").splitlines() if (row := parse_nm_line(line)) is not None] + text_rows = sorted((address, name) for address, kind, name in rows if kind in {"t", "T", "w", "W"}) + symbols: list[Symbol] = [] + for index, (address, name) in enumerate(text_rows): + next_address = next((row[0] for row in text_rows[index + 1 :] if row[0] > address), address) + symbols.append(Symbol(address, max(0, next_address - address), name)) + return symbols + + +def parse_indirect_symbols(path: Path) -> list[Symbol]: + symbols = [] + in_text_stubs = False + for line in path.read_text(errors="replace").splitlines(): + if line.startswith("Indirect symbols for "): + in_text_stubs = line.startswith("Indirect symbols for (__TEXT,__stubs)") + continue + if not in_text_stubs: + continue + match = re.match(r"^0x([0-9a-fA-F]+)\s+\d+\s+(.+)$", line.strip()) + if match is not None: + symbols.append(Symbol(int(match.group(1), 16), 1, match.group(2))) + return symbols + + +def merged_symbols(nm_symbols: list[Symbol], map_symbols: list[Symbol]) -> list[Symbol]: + rows: dict[tuple[int, str], Symbol] = {} + for symbol in [*map_symbols, *nm_symbols]: + key = (symbol.address, symbol.name) + prior = rows.get(key) + if prior is None or symbol.size > prior.size: + rows[key] = symbol + return sorted(rows.values(), key=lambda row: (row.address, row.name)) + + +def canonical_symbols(symbols: list[Symbol]) -> list[Symbol]: + by_address: dict[int, list[Symbol]] = {} + for symbol in symbols: + if ( + symbol.size > 0 + and symbol.name != "__mh_execute_header" + and not symbol.name.startswith((".", "Ltmp", "LBB")) + ): + by_address.setdefault(symbol.address, []).append(symbol) + + def priority(symbol: Symbol) -> tuple[int, str]: + if symbol.name.startswith("ct_entry_"): + return 0, symbol.name + if "rscrypto::" in symbol.name or "rscrypto_ct_" in symbol.name: + return 1, symbol.name + return 2, symbol.name + + return [min(rows, key=priority) for _, rows in sorted(by_address.items())] + + +def symbolized_target(address: int, starts: list[int], symbols: list[Symbol]) -> str | None: + index = bisect.bisect_right(starts, address) - 1 + if index < 0: + return None + symbol = symbols[index] + if address >= symbol.address + symbol.size: + return None + offset = address - symbol.address + return symbol.name if offset == 0 else f"{symbol.name}+0x{offset:x}" + + +def symbolize(raw_disassembly: Path, out_path: Path, symbols: list[Symbol]) -> None: + canonical = canonical_symbols(symbols) + if not canonical: + raise ValueError("no final linked text symbols found") + by_address = {symbol.address: symbol for symbol in canonical} + starts = [symbol.address for symbol in canonical] + target_re = re.compile(r"0x([0-9a-fA-F]+)\s+<.*>") + output: list[str] = [] + seen: set[int] = set() + for line in raw_disassembly.read_text(errors="replace").splitlines(): + existing_label = re.match(r"^\s*([0-9a-fA-F]+) <.+>:$", line) + if existing_label is not None: + address = int(existing_label.group(1), 16) + if address in by_address: + output.append(f"{address:016x} <{by_address[address].name}>:") + seen.add(address) + continue + instruction = re.match(r"^\s*([0-9a-fA-F]+):", line) + if instruction is not None: + address = int(instruction.group(1), 16) + if address in by_address and address not in seen: + output.append(f"{address:016x} <{by_address[address].name}>:") + seen.add(address) + + def replace_target(match: re.Match[str]) -> str: + address = int(match.group(1), 16) + name = symbolized_target(address, starts, canonical) + return match.group(0) if name is None else f"0x{address:x} <{name}>" + + output.append(target_re.sub(replace_target, line)) + missing = sorted(symbol.name for symbol in canonical if symbol.address not in seen) + if missing: + raise ValueError(f"final disassembly missing {len(missing)} mapped function start(s), first: {missing[0]}") + out_path.write_text("\n".join(output) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-dir", required=True, type=Path) + parser.add_argument("--raw-disassembly", required=True, type=Path) + parser.add_argument("--nm-symbols", required=True, type=Path) + parser.add_argument("--link-map", type=Path) + parser.add_argument("--indirect-symbols", type=Path) + parser.add_argument("--out-disassembly", required=True, type=Path) + parser.add_argument("--out-symbols", required=True, type=Path) + args = parser.parse_args() + + known_names = demangle_table(args.artifact_dir) + nm_symbols = parse_final_nm(args.nm_symbols) + map_symbols = parse_link_map(args.link_map, known_names) if args.link_map and args.link_map.is_file() else [] + indirect_symbols = ( + parse_indirect_symbols(args.indirect_symbols) + if args.indirect_symbols and args.indirect_symbols.is_file() + else [] + ) + symbols = merged_symbols(nm_symbols, [*map_symbols, *indirect_symbols]) + if not symbols: + raise ValueError("neither the final binary nor the linker map contains text symbols") + args.out_symbols.write_text( + "".join(f"{symbol.address:016x} {symbol.size:016x} T {symbol.name}\n" for symbol in symbols) + ) + symbolize(args.raw_disassembly, args.out_disassembly, symbols) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ct/validate.py b/scripts/ct/validate.py index e92a667e..a1855fac 100755 --- a/scripts/ct/validate.py +++ b/scripts/ct/validate.py @@ -57,8 +57,14 @@ "configured_rustflags", "environment_rustflags", "effective_rustflags", + "rustflags_source", "linker", "linker_source", + "linker_path", + "linker_sha256", + "linker_version", + "linker_command", + "linker_command_sha256", "link_args", "profile", "opt_level", @@ -180,6 +186,15 @@ def generated_symbols(artifact_dir: Path) -> set[str]: return symbols +def symbol_counts(path: Path) -> dict[str, int]: + counts: dict[str, int] = {} + for line in path.read_text().splitlines(): + if match := re.search(r"\b_?(ct_entry_[A-Za-z0-9_]+)\b", line): + symbol = match.group(1) + counts[symbol] = counts.get(symbol, 0) + 1 + return counts + + def recorded_hashes(path: Path) -> dict[str, str]: recorded: dict[str, str] = {} for line in path.read_text().splitlines(): @@ -336,6 +351,84 @@ def validate_manifest(root: Path, errors: list[str], warnings: list[str]) -> dic if not harness_sections: fail(errors, "ct.toml has no [[harness]] sections") + release_binary = ct.get("equality_evidence", {}).get("release_binary", {}) + expected_release_binary = { + "name": "rscrypto-ct-evidence", + "kind": "executable", + "profile": "release", + "backend": "llvm", + "features": ["std", "full", "parallel", "diag"], + "default_features": False, + } + for field, expected in expected_release_binary.items(): + if release_binary.get(field) != expected: + fail(errors, f"equality release binary {field} expected {expected!r}, got {release_binary.get(field)!r}") + + harness_manifest_path = root / "tools" / "ct-harness" / "Cargo.toml" + dudect_manifest_path = root / "tools" / "ct-dudect" / "Cargo.toml" + with harness_manifest_path.open("rb") as source: + harness_manifest = tomllib.load(source) + with dudect_manifest_path.open("rb") as source: + dudect_manifest = tomllib.load(source) + harness_dependency = harness_manifest.get("dependencies", {}).get("rscrypto", {}) + dudect_dependency = dudect_manifest.get("dependencies", {}).get("rscrypto", {}) + for label, dependency in (("CT harness", harness_dependency), ("DudeCT", dudect_dependency)): + if dependency.get("features") != release_binary.get("features"): + fail(errors, f"{label} rscrypto features do not match the equality release binary") + if dependency.get("default-features") != release_binary.get("default_features"): + fail(errors, f"{label} rscrypto default features do not match the equality release binary") + harness_bins = [row for row in harness_manifest.get("bin", []) if row.get("name") == release_binary.get("name")] + if len(harness_bins) != 1: + fail(errors, "CT harness must define exactly one equality release binary") + if harness_manifest.get("profile", {}).get("release") != dudect_manifest.get("profile", {}).get("release"): + fail(errors, "CT harness and DudeCT release profiles must match") + owner_widths = release_binary.get("owner_widths", []) + owner_symbols = release_binary.get("owner_symbols", []) + public_len_symbols = release_binary.get("public_len_symbols", []) + if ( + not isinstance(owner_widths, list) + or not owner_widths + or any(isinstance(width, bool) or not isinstance(width, int) or width <= 0 for width in owner_widths) + or owner_widths != sorted(set(owner_widths)) + ): + fail(errors, f"equality release binary owner_widths must be positive, unique, and ordered: {owner_widths!r}") + required_owner_widths = {16, 32, 48, 64} + if not required_owner_widths.issubset(owner_widths): + fail(errors, "equality release binary must cover the required 16-, 32-, 48-, and 64-byte owners") + expected_owner_symbols = [f"ct_entry_owner_eq_{width}" for width in owner_widths] + if owner_symbols != expected_owner_symbols: + fail(errors, "equality release binary owner symbols do not match owner widths") + equality_symbols = owner_symbols + public_len_symbols + if not equality_symbols or len(equality_symbols) != len(set(equality_symbols)): + fail(errors, "equality release binary symbols must be non-empty and unique") + unknown_equality_symbols = sorted(set(equality_symbols) - all_harness_symbols) + if unknown_equality_symbols: + fail(errors, f"equality release binary references unknown harness symbol(s): {', '.join(unknown_equality_symbols)}") + if release_binary.get("formal_owner_widths") != [16, 32, 48, 64]: + fail(errors, "equality release binary formal_owner_widths must cover 16, 32, 48, and 64 bytes") + for field in ("formal_limitation", "downstream_limitation"): + if not isinstance(release_binary.get(field), str) or not release_binary.get(field, "").strip(): + fail(errors, f"equality release binary requires {field}") + + owner_primitives = [row for row in ct.get("primitive", []) if row.get("id") == "owner_equality.fixed"] + if len(owner_primitives) != 1: + fail(errors, "ct.toml must contain exactly one owner_equality.fixed primitive") + else: + owner_primitive = owner_primitives[0] + if owner_primitive.get("release_owner_widths") != owner_widths: + fail(errors, "owner_equality.fixed release widths do not match the equality release binary") + if owner_primitive.get("harness", {}).get("symbols") != owner_symbols: + fail(errors, "owner_equality.fixed harness symbols do not match the equality release binary") + + evidence_main = root / "tools" / "ct-harness" / "src" / "main.rs" + retain_match = re.search(r"retain!\((.*?)\n \);", evidence_main.read_text(), re.DOTALL) + if retain_match is None: + fail(errors, "CT harness lacks the final equality root retention list") + else: + retained_symbols = re.findall(r"\bct_entry_[A-Za-z0-9_]+\b", retain_match.group(1)) + if retained_symbols != equality_symbols: + fail(errors, "CT harness equality retention list does not exactly match ct.toml") + primitive_ids: set[str] = set() for primitive in ct.get("primitive", []): primitive_id = primitive.get("id", "") @@ -392,6 +485,29 @@ def validate_manifest(root: Path, errors: list[str], warnings: list[str]) -> dic if covered not in primitive_ids: fail(errors, f"harness {harness.get('name', '')} covers unknown primitive {covered}") + for index, rule in enumerate(ct.get("asm_public_operand", [])): + required_fields = {"primitive", "root", "symbol", "kind", "max_count", "source", "rationale"} + if set(rule) != required_fields: + fail(errors, f"asm_public_operand[{index}] has incomplete or unknown fields") + continue + primitive_id = rule.get("primitive") + if primitive_id not in primitive_ids: + fail(errors, f"asm_public_operand[{index}] references unknown primitive {primitive_id!r}") + continue + primitive = next(row for row in ct.get("primitive", []) if row.get("id") == primitive_id) + if rule.get("root") not in primitive.get("harness", {}).get("symbols", []): + fail(errors, f"asm_public_operand[{index}] root is not owned by primitive {primitive_id}") + if rule.get("kind") not in {"variable_latency_division", "variable_latency_multiply"}: + fail(errors, f"asm_public_operand[{index}] kind is not a public-operand-classifiable instruction") + max_count = rule.get("max_count") + if isinstance(max_count, bool) or not isinstance(max_count, int) or max_count <= 0: + fail(errors, f"asm_public_operand[{index}] max_count must be a positive integer") + source, separator, line = str(rule.get("source", "")).rpartition(":") + if not separator or not line.isdigit() or not (root / source).is_file(): + fail(errors, f"asm_public_operand[{index}] source must identify an existing source line") + if not isinstance(rule.get("rationale"), str) or not rule.get("rationale", "").strip(): + fail(errors, f"asm_public_operand[{index}] requires a rationale") + dudect_names: set[str] = set() dudect_by_name: dict[str, dict] = {} for case in ct.get("dudect_case", []): @@ -671,6 +787,30 @@ def validate_manifest(root: Path, errors: list[str], warnings: list[str]) -> dic for field in ("public_length", "secret_contents", "tests"): if not isinstance(comparison.get(field), str) or not comparison.get(field, "").strip(): fail(errors, f"public-length comparison {comparison_id} requires {field}") + evidence_symbols = comparison.get("evidence_symbols") + if not isinstance(evidence_symbols, list) or not evidence_symbols: + fail(errors, f"public-length comparison {comparison_id} requires release evidence symbols") + evidence_symbols = [] + for symbol in evidence_symbols: + if symbol not in public_len_symbols: + fail(errors, f"public-length comparison {comparison_id} references unknown release symbol {symbol!r}") + evidenced_call_count = comparison.get("evidenced_call_count") + limited_call_count = comparison.get("limited_call_count") + if any(isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in (evidenced_call_count, limited_call_count)): + fail(errors, f"public-length comparison {comparison_id} evidence counts must be non-negative integers") + elif evidenced_call_count + limited_call_count != call_count: + fail(errors, f"public-length comparison {comparison_id} evidence counts do not equal call_count") + limitation = comparison.get("limitation") + if isinstance(limited_call_count, int) and limited_call_count > 0 and ( + not isinstance(limitation, str) or not limitation.strip() + ): + fail(errors, f"public-length comparison {comparison_id} limited calls require an explicit limitation") + + mapped_public_len_symbols = { + symbol for comparison in ct.get("public_len_comparison", []) for symbol in comparison.get("evidence_symbols", []) + } + if mapped_public_len_symbols != set(public_len_symbols): + fail(errors, "public-length comparison evidence does not exactly cover the release binary public-length symbols") actual_public_len_calls: dict[str, int] = {} for source_path in (root / "src").rglob("*.rs"): @@ -793,6 +933,9 @@ def validate_artifacts(root: Path, target: str, profile: str, ct: dict, errors: (".o.disasm.txt", ".obj.disasm.txt"), (".o.symbols.txt", ".obj.symbols.txt"), (".o.size.txt", ".obj.size.txt"), + (".binary.disasm.txt",), + (".binary.symbols.txt",), + (".binary.size.txt",), ] for suffixes in required_suffix_groups: if not any(path.name.endswith(suffixes) for path in files): @@ -818,8 +961,8 @@ def validate_artifacts(root: Path, target: str, profile: str, ct: dict, errors: for key in sorted(PROVENANCE_REQUIRED_KEYS): if key not in provenance: fail(errors, f"provenance missing {key}") - if provenance.get("schema_version") != 1: - fail(errors, f"provenance schema_version expected 1, got {provenance.get('schema_version')!r}") + if provenance.get("schema_version") != 2: + fail(errors, f"provenance schema_version expected 2, got {provenance.get('schema_version')!r}") if provenance.get("kind") != "rscrypto.ct.provenance": fail(errors, f"provenance kind expected 'rscrypto.ct.provenance', got {provenance.get('kind')!r}") if provenance.get("target_triple") != target: @@ -830,12 +973,28 @@ def validate_artifacts(root: Path, target: str, profile: str, ct: dict, errors: fail(errors, "provenance features must be a non-empty list") if not isinstance(provenance.get("effective_rustflags"), list): fail(errors, "provenance effective_rustflags must be a list") + if not isinstance(provenance.get("rustflags_source"), str) or not provenance.get("rustflags_source"): + fail(errors, "provenance rustflags_source must be a non-empty string") if not isinstance(provenance.get("target_cfg_features"), list): fail(errors, "provenance target_cfg_features must be a list") if not isinstance(provenance.get("target_features"), list): fail(errors, "provenance target_features must be a list") if not isinstance(provenance.get("link_args"), list): fail(errors, "provenance link_args must be a list") + for field in ("linker", "linker_source", "linker_path", "linker_sha256", "linker_version"): + if not isinstance(provenance.get(field), str) or not provenance.get(field): + fail(errors, f"provenance {field} must be a non-empty string") + if provenance.get("linker_command") != "artifacts/linker-command.txt": + fail(errors, "provenance linker_command must name the archived linker command") + linker_log = artifact_dir / "linker-command.txt" + if not linker_log.is_file() or provenance.get("linker_command_sha256") != sha256_file(linker_log): + fail(errors, "provenance linker_command_sha256 mismatch") + release_binary = ct.get("equality_evidence", {}).get("release_binary", {}) + if provenance.get("features") != release_binary.get("features"): + fail(errors, "provenance features do not match the equality release binary feature set") + for field in ("opt_level", "lto", "panic", "codegen_units", "overflow_checks", "debug", "strip"): + if provenance.get(field) in (None, "unknown"): + fail(errors, f"provenance {field} must identify the resolved harness profile") if not isinstance(provenance.get("artifacts"), list) or not provenance.get("artifacts"): fail(errors, "provenance artifacts must be a non-empty list") if not isinstance(provenance.get("reports"), list): @@ -898,6 +1057,41 @@ def validate_artifacts(root: Path, target: str, profile: str, ct: dict, errors: if extra_symbols: warn(warnings, f"generated artifacts include unmanifested ct_entry symbol(s): {', '.join(extra_symbols)}") + equality_symbols = set(ct.get("equality_evidence", {}).get("release_binary", {}).get("owner_symbols", [])) + equality_symbols.update(ct.get("equality_evidence", {}).get("release_binary", {}).get("public_len_symbols", [])) + equality_object_maps = sorted(artifact_dir.glob("rscrypto_ct_evidence*.o.symbols.txt")) + equality_object_maps.extend(sorted(artifact_dir.glob("rscrypto_ct_evidence*.obj.symbols.txt"))) + binary_maps = sorted(artifact_dir.glob("*.binary.symbols.txt")) + binary_disassemblies = sorted(artifact_dir.glob("*.binary.disasm.txt")) + binary_raw_disassemblies = sorted(artifact_dir.glob("*.binary.raw-disasm.txt")) + binary_nm_maps = sorted(artifact_dir.glob("*.binary.nm-symbols.txt")) + binary_indirect_maps = sorted(artifact_dir.glob("*.binary.indirect-symbols.txt")) + binary_link_maps = sorted(artifact_dir.glob("rscrypto-ct-evidence.link-map.txt")) + linked_binaries = sorted( + path for path in artifact_dir.iterdir() if path.name in {"rscrypto-ct-evidence", "rscrypto-ct-evidence.exe"} + ) + for role, paths in ( + ("equality pre-link object symbol map", equality_object_maps), + ("final linked equality binary symbol map", binary_maps), + ("final linked equality binary disassembly", binary_disassemblies), + ("final linked equality binary raw disassembly", binary_raw_disassemblies), + ("final linked equality binary nm symbol map", binary_nm_maps), + ("final linked equality binary", linked_binaries), + ): + if len(paths) != 1: + fail(errors, f"expected exactly one {role}; found {len(paths)}") + if "linux" in target and len(binary_link_maps) != 1: + fail(errors, f"expected exactly one final linked equality binary linker map; found {len(binary_link_maps)}") + if "apple-darwin" in target and len(binary_indirect_maps) != 1: + fail(errors, f"expected exactly one final linked equality binary indirect symbol map; found {len(binary_indirect_maps)}") + for role, paths in (("equality pre-link object", equality_object_maps), ("final linked equality binary", binary_maps)): + if len(paths) != 1: + continue + counts = symbol_counts(paths[0]) + missing = sorted(symbol for symbol in equality_symbols if counts.get(symbol) != 1) + if missing: + fail(errors, f"{role} missing or duplicating equality symbol(s): {', '.join(missing)}") + heuristics = {} if heuristics_path.exists(): try: @@ -955,7 +1149,7 @@ def validate_artifacts(root: Path, target: str, profile: str, ct: dict, errors: fail(errors, f"invalid evidence-index JSON: {exc}") else: expected = { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.evidence-index", "crate": "rscrypto", "backend": "llvm", @@ -1005,12 +1199,57 @@ def validate_artifacts(root: Path, target: str, profile: str, ct: dict, errors: elif not row.get("locations"): fail(errors, f"evidence-index primitive {primitive_id} symbol {symbol} has no locations") + equality = ct.get("equality_evidence", {}).get("release_binary", {}) + equality_symbols = set(equality.get("owner_symbols", [])) | set(equality.get("public_len_symbols", [])) + equality_locations: dict[str, list[dict]] = {} + for primitive in evidence.get("primitives", []): + for row in primitive.get("harness", {}).get("symbols", []): + name = row.get("name") + if isinstance(name, str): + equality_locations.setdefault(name, []).extend(row.get("locations", [])) + for symbol in equality_symbols: + object_names = [str(row.get("object", "")) for row in equality_locations.get(symbol, [])] + if sum(name.startswith("rscrypto_ct_evidence") and name.endswith((".o", ".obj")) for name in object_names) != 1: + fail(errors, f"evidence-index lacks one equality pre-link location for {symbol}") + if sum(name == "rscrypto-ct-evidence.binary" for name in object_names) != 1: + fail(errors, f"evidence-index lacks one final linked location for {symbol}") + + final_closure = heuristics.get("final_equality_call_closure", {}) + if set(final_closure.get("root_symbols", [])) != equality_symbols: + fail(errors, "final equality call closure roots do not match ct.toml") + if final_closure.get("missing_root_symbols"): + fail(errors, "final equality call closure has missing roots") + if final_closure.get("unresolved_internal_calls"): + fail(errors, "final equality call closure has unresolved internal calls") + terminal_call_sites = final_closure.get("terminal_call_sites") + if not isinstance(terminal_call_sites, list): + fail(errors, "final equality call closure lacks terminal call sites") + else: + terminal_call_locators = [row.get("locator") for row in terminal_call_sites if isinstance(row, dict)] + if len(terminal_call_locators) != len(terminal_call_sites) or any( + not isinstance(locator, str) or not locator for locator in terminal_call_locators + ): + fail(errors, "final equality call closure has an invalid terminal call site") + elif len(set(terminal_call_locators)) != len(terminal_call_locators): + fail(errors, "final equality call closure has duplicate terminal call sites") + final_disassemblies = [ + row for row in provenance.get("artifacts", []) if row.get("kind") == "linked_binary_disassembly" + ] + if len(final_disassemblies) != 1: + fail(errors, "provenance lacks exactly one final linked binary disassembly") + else: + final_disassembly = final_disassemblies[0] + if final_closure.get("artifact") != final_disassembly.get("path") or final_closure.get( + "artifact_sha256" + ) != final_disassembly.get("sha256"): + fail(errors, "final equality call closure is not bound to the linked binary disassembly") + unmanifested = evidence.get("unmanifested_ct_symbols", []) if unmanifested: warn(warnings, f"evidence-index includes unmanifested ct_entry symbol(s): {', '.join(unmanifested)}") -def validate_dudect(root: Path, target: str, profile: str, errors: list[str], warnings: list[str]) -> None: +def validate_dudect(root: Path, target: str, profile: str, ct: dict, errors: list[str], warnings: list[str]) -> None: report_path = root / "target" / "ct" / target / profile / "dudect" / "dudect-report.json" if not report_path.exists(): fail(errors, f"dudect report missing: {report_path}") @@ -1023,7 +1262,7 @@ def validate_dudect(root: Path, target: str, profile: str, errors: list[str], wa return expected = { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.dudect", "crate": "rscrypto", "target": target, @@ -1034,6 +1273,85 @@ def validate_dudect(root: Path, target: str, profile: str, errors: list[str], wa if report.get(key) != value: fail(errors, f"dudect {key} expected {value!r}, got {report.get(key)!r}") + release_binary = ct.get("equality_evidence", {}).get("release_binary", {}) + for key, value in { + "git_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip(), + "features": release_binary.get("features"), + "default_features": release_binary.get("default_features"), + "backend": release_binary.get("backend"), + }.items(): + if report.get(key) != value: + fail(errors, f"dudect {key} expected {value!r}, got {report.get(key)!r}") + for key in ("crate_version", "linker", "linker_path", "linker_sha256", "linker_version"): + if not isinstance(report.get(key), str) or not report.get(key): + fail(errors, f"dudect {key} must be a non-empty string") + + provenance_path = root / "target" / "ct" / target / profile / "provenance.json" + try: + provenance = json.loads(provenance_path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + fail(errors, f"dudect cannot load lane provenance: {exc}") + provenance = {} + for key in ( + "configured_rustflags", + "environment_rustflags", + "effective_rustflags", + "rustflags_source", + "target_cpu", + "target_features", + "target_cfg_features", + ): + if report.get(key) != provenance.get(key): + fail(errors, f"dudect {key} does not match lane provenance") + if report.get("rustc_verbose") != provenance.get("tools", {}).get("rustc"): + fail(errors, "dudect rustc does not match lane provenance") + if report.get("cargo") != provenance.get("tools", {}).get("cargo"): + fail(errors, "dudect cargo does not match lane provenance") + dudect_manifest = root / "tools" / "ct-dudect" / "Cargo.toml" + harness_manifest = root / "tools" / "ct-harness" / "Cargo.toml" + dudect_lockfile = root / "tools" / "ct-dudect" / "Cargo.lock" + for key, path in ( + ("dudect_manifest_sha256", dudect_manifest), + ("harness_manifest_sha256", harness_manifest), + ("dudect_lockfile_sha256", dudect_lockfile), + ): + if report.get(key) != sha256_file(path): + fail(errors, f"dudect {key} mismatch") + with dudect_manifest.open("rb") as source: + expected_profile_settings = tomllib.load(source).get("profile", {}).get(profile, {}) + if report.get("profile_settings") != expected_profile_settings: + fail(errors, "dudect profile settings do not match its manifest") + + dudect_dir = report_path.parent + timing_artifacts = { + "binary": dudect_dir / ("rscrypto-ct-dudect.exe" if (dudect_dir / "rscrypto-ct-dudect.exe").exists() else "rscrypto-ct-dudect"), + "binary_disassembly": dudect_dir / "rscrypto-ct-dudect.binary.disasm.txt", + "binary_symbols": dudect_dir / "rscrypto-ct-dudect.binary.symbols.txt", + "linker_command_log": dudect_dir / "dudect-linker-command.txt", + } + for field, path in timing_artifacts.items(): + record = report.get(field, {}) + if not path.is_file(): + fail(errors, f"dudect {field} artifact missing: {path}") + elif record.get("sha256") != sha256_file(path) or record.get("bytes") != path.stat().st_size: + fail(errors, f"dudect {field} artifact identity mismatch") + expected_owner_symbols = { + f"ct_entry_owner_eq_{width}" + for width in release_binary.get("formal_owner_widths", []) + } + if set(report.get("binary", {}).get("owner_symbols", [])) != expected_owner_symbols: + fail(errors, "dudect binary owner symbol set must cover 16, 32, 48, and 64 bytes") + call_sites = report.get("binary", {}).get("owner_call_sites", {}) + if set(call_sites) != expected_owner_symbols or any( + isinstance(count, bool) or not isinstance(count, int) or count < 1 for count in call_sites.values() + ): + fail(errors, "dudect binary must call every owner equality symbol") + if timing_artifacts["binary_symbols"].is_file(): + counts = symbol_counts(timing_artifacts["binary_symbols"]) + wrong = sorted(symbol for symbol in expected_owner_symbols if counts.get(symbol) != 1) + if wrong: + fail(errors, f"dudect binary missing or duplicating owner symbol(s): {', '.join(wrong)}") + cases = report.get("cases") if not isinstance(cases, list) or not cases: fail(errors, "dudect report cases must be a non-empty list") @@ -1088,7 +1406,7 @@ def main() -> int: target = next(line.split(":", 1)[1].strip() for line in target.splitlines() if line.startswith("host:")) validate_artifacts(root, target, args.profile, ct, errors, warnings) if args.require_dudect: - validate_dudect(root, target, args.profile, errors, warnings) + validate_dudect(root, target, args.profile, ct, errors, warnings) for message in warnings: print(f"warning: {message}", file=sys.stderr) diff --git a/scripts/ct/validate_release_evidence.py b/scripts/ct/validate_release_evidence.py index df8f6a80..e9ebe9ac 100755 --- a/scripts/ct/validate_release_evidence.py +++ b/scripts/ct/validate_release_evidence.py @@ -46,6 +46,17 @@ def sha256_git_file(root: Path, commit: str, path: str) -> str: return hashlib.sha256(contents).hexdigest() +def load_git_toml(root: Path, commit: str, path: str) -> dict[str, Any]: + try: + contents = subprocess.check_output(["git", "show", f"{commit}:{path}"], cwd=root) + value = tomllib.loads(contents.decode()) + except (subprocess.CalledProcessError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + fail(f"cannot parse {path} from evidence commit {commit}: {exc}") + if not isinstance(value, dict): + fail(f"{path} from evidence commit {commit} must contain a TOML table") + return value + + def unique_file(root: Path, name: str) -> Path: matches = sorted(path for path in root.rglob(name) if path.is_file()) if len(matches) != 1: @@ -121,6 +132,20 @@ def records_by_name(records: Any, source: str) -> dict[str, dict[str, Any]]: return result +def records_by_path(records: Any, source: str) -> dict[str, dict[str, Any]]: + if not isinstance(records, list): + fail(f"{source} must be a list") + result = {} + for row in records: + if not isinstance(row, dict) or not isinstance(row.get("path"), str): + fail(f"{source} contains an invalid record") + path = row["path"] + if path in result: + fail(f"{source} contains duplicate record {path}") + result[path] = row + return result + + def parse_hashes(path: Path) -> dict[str, str]: result = {} for line_number, line in enumerate(path.read_text().splitlines(), 1): @@ -157,7 +182,21 @@ def require_equal(actual: Any, expected: Any, label: str) -> None: fail(f"{label}: expected {expected!r}, got {actual!r}") -def validate_report(report: dict[str, Any], suffix: str, target: str, ct: dict[str, Any]) -> None: +def validate_exact_candidate(version: str, commit: str, evidence_version: str, evidence_commit: str) -> None: + require_equal(evidence_version, version, "release CT evidence version") + require_equal(evidence_commit, commit, "release CT evidence commit") + + +def validate_report( + report: dict[str, Any], + suffix: str, + target: str, + version: str, + commit: str, + provenance: dict[str, Any], + provenance_path: Path, + ct: dict[str, Any], +) -> None: for key, expected in { "schema_version": 1, "kind": "rscrypto.ct.full-report", @@ -169,6 +208,14 @@ def validate_report(report: dict[str, Any], suffix: str, target: str, ct: dict[s "failure_count": 0, }.items(): require_equal(report.get(key), expected, f"{suffix} report {key}") + for key, expected in { + "crate_version": version, + "git_commit": commit, + "git_dirty": False, + "provenance": "provenance.json", + "provenance_sha256": sha256_file(provenance_path), + }.items(): + require_equal(report.get(key), expected, f"{suffix} report {key}") require_equal(report.get("dudect", {}).get("filter"), None, f"{suffix} DudeCT filter") require_equal(report.get("dudect", {}).get("gate"), "required", f"{suffix} DudeCT gate") require_equal(report.get("dudect", {}).get("coverage_mode"), "required", f"{suffix} DudeCT coverage mode") @@ -181,10 +228,95 @@ def validate_report(report: dict[str, Any], suffix: str, target: str, ct: dict[s if any(case.get("status") != "pass" for case in case_rows): fail(f"{suffix} contains a non-passing required DudeCT case") - validate_binsec(report, suffix, target, ct) - - -def validate_binsec(report: dict[str, Any], suffix: str, target: str, ct: dict[str, Any]) -> None: + equality = ct.get("equality_evidence", {}).get("release_binary", {}) + root = Path(__file__).resolve().parents[2] + dudect_manifest_path = "tools/ct-dudect/Cargo.toml" + harness_manifest_path = "tools/ct-harness/Cargo.toml" + dudect_lockfile_path = "tools/ct-dudect/Cargo.lock" + dudect_manifest = load_git_toml(root, commit, dudect_manifest_path) + expected_owner_symbols = {f"ct_entry_owner_eq_{width}" for width in equality.get("formal_owner_widths", [])} + binary_hashes = set() + timing_linkers = set() + for case in case_rows: + for key, expected in { + "crate_version": version, + "git_commit": commit, + "git_dirty": False, + "features": equality.get("features"), + "default_features": equality.get("default_features"), + "backend": equality.get("backend"), + "profile_settings": dudect_manifest.get("profile", {}).get("release", {}), + "dudect_manifest_sha256": sha256_git_file(root, commit, dudect_manifest_path), + "harness_manifest_sha256": sha256_git_file(root, commit, harness_manifest_path), + "dudect_lockfile_sha256": sha256_git_file(root, commit, dudect_lockfile_path), + "rustc_verbose": provenance.get("tools", {}).get("rustc"), + "cargo": provenance.get("tools", {}).get("cargo"), + "configured_rustflags": provenance.get("configured_rustflags"), + "environment_rustflags": provenance.get("environment_rustflags"), + "effective_rustflags": provenance.get("effective_rustflags"), + "rustflags_source": provenance.get("rustflags_source"), + "target_cpu": provenance.get("target_cpu"), + "target_features": provenance.get("target_features"), + "target_cfg_features": provenance.get("target_cfg_features"), + }.items(): + require_equal(case.get(key), expected, f"{suffix} DudeCT {case.get('name')} {key}") + for key in ("linker", "linker_path", "linker_sha256", "linker_version"): + if not isinstance(case.get(key), str) or not case.get(key): + fail(f"{suffix} DudeCT {case.get('name')} lacks {key}") + timing_linkers.add(tuple(case.get(key) for key in ("linker", "linker_path", "linker_sha256", "linker_version"))) + binary = case.get("binary", {}) + require_equal(set(binary.get("owner_symbols", [])), expected_owner_symbols, f"{suffix} DudeCT owner symbols") + call_sites = binary.get("owner_call_sites", {}) + require_equal(set(call_sites), expected_owner_symbols, f"{suffix} DudeCT owner call sites") + if any(isinstance(count, bool) or not isinstance(count, int) or count < 1 for count in call_sites.values()): + fail(f"{suffix} DudeCT timing executable does not call every owner root") + if not isinstance(binary.get("sha256"), str) or not isinstance(binary.get("bytes"), int): + fail(f"{suffix} DudeCT {case.get('name')} lacks binary identity") + binary_hashes.add(binary.get("sha256")) + for artifact_key in ("binary_disassembly", "binary_symbols", "linker_command_log"): + artifact = case.get(artifact_key, {}) + if not isinstance(artifact.get("sha256"), str) or not isinstance(artifact.get("bytes"), int): + fail(f"{suffix} DudeCT {case.get('name')} lacks {artifact_key} identity") + if len(binary_hashes) != 1: + fail(f"{suffix} DudeCT cases do not bind one exact timing executable") + if len(timing_linkers) != 1: + fail(f"{suffix} DudeCT cases do not bind one exact linker") + + report_artifacts = records_by_path(report.get("artifacts"), f"{suffix} full report artifacts") + for path, kind in ( + ("dudect/rscrypto-ct-dudect", "dudect_binary"), + ("dudect/rscrypto-ct-dudect.exe", "dudect_binary"), + ): + if path in report_artifacts: + timing_binary = report_artifacts[path] + break + else: + fail(f"{suffix} full report lacks the DudeCT timing executable") + require_equal(timing_binary.get("kind"), kind, f"{suffix} DudeCT binary kind") + require_equal(timing_binary.get("sha256"), next(iter(binary_hashes)), f"{suffix} DudeCT binary hash") + for path, kind, case_field in ( + ("dudect/rscrypto-ct-dudect.binary.disasm.txt", "dudect_binary_disassembly", "binary_disassembly"), + ("dudect/rscrypto-ct-dudect.binary.symbols.txt", "dudect_binary_symbol_map", "binary_symbols"), + ("dudect/dudect-linker-command.txt", "dudect_linker_command", "linker_command_log"), + ): + artifact = report_artifacts.get(path) + if artifact is None or artifact.get("kind") != kind: + fail(f"{suffix} full report lacks {kind}") + expected_hashes = {case.get(case_field, {}).get("sha256") for case in case_rows} + require_equal(expected_hashes, {artifact.get("sha256")}, f"{suffix} {kind} hash") + + validate_binsec(report, suffix, target, version, commit, provenance, ct) + + +def validate_binsec( + report: dict[str, Any], + suffix: str, + target: str, + version: str, + commit: str, + provenance: dict[str, Any], + ct: dict[str, Any], +) -> None: binsec = report.get("binsec", {}) policy = target_policy(ct, target) if policy.get("binsec") == "required": @@ -197,6 +329,52 @@ def validate_binsec(report: dict[str, Any], suffix: str, target: str, ct: dict[s require_equal(set(kernel_names), expected_binsec_kernels(ct, target), f"{suffix} required BINSEC kernels") if any(row.get("status") != "secure" for row in required_kernel_rows): fail(f"{suffix} contains a non-secure required BINSEC kernel") + report_artifacts = records_by_path(report.get("artifacts"), f"{suffix} full report artifacts") + root = Path(__file__).resolve().parents[2] + harness_manifest_path = "tools/ct-binsec-harness/Cargo.toml" + harness_lockfile_path = "tools/ct-binsec-harness/Cargo.lock" + harness = load_git_toml(root, commit, harness_manifest_path) + expected_profile = harness.get("profile", {}).get("release", {}) + dependency = harness.get("dependencies", {}).get("rscrypto", {}) + expected_features = dependency.get("features", []) + expected_default_features = dependency.get("default-features", True) + expected_hashes = { + "ct_manifest_sha256": sha256_git_file(root, commit, "ct.toml"), + "harness_manifest_sha256": sha256_git_file(root, commit, harness_manifest_path), + "harness_lockfile_sha256": sha256_git_file(root, commit, harness_lockfile_path), + } + for row in required_kernel_rows: + for key, expected in { + "backend": "llvm", + "target": target, + "target_triple": target, + "profile": "release", + "crate_version": version, + "git_commit": commit, + "git_dirty": False, + "default_features": expected_default_features, + "features": expected_features, + "profile_settings": expected_profile, + "rustc_verbose": provenance.get("tools", {}).get("rustc"), + "cargo": provenance.get("tools", {}).get("cargo"), + **expected_hashes, + }.items(): + require_equal(row.get(key), expected, f"{suffix} BINSEC {row.get('kernel')} {key}") + if not isinstance(row.get("rustflags"), list): + fail(f"{suffix} BINSEC {row.get('kernel')} lacks resolved rustflags") + if row.get("harness_elf_type") != "exec": + fail(f"{suffix} BINSEC {row.get('kernel')} does not use a static executable proof driver") + if not isinstance(row.get("binsec_version"), str) or not row.get("binsec_version"): + fail(f"{suffix} BINSEC {row.get('kernel')} lacks the BINSEC version") + if ".text" not in row.get("load_sections", []): + fail(f"{suffix} BINSEC {row.get('kernel')} does not load the proof driver's text section") + component_artifacts = row.get("artifacts", {}) + for required_name in ("driver.elf", "driver.disasm", "checkct.cfg", "binsec.log"): + digest = component_artifacts.get(required_name) + path = f"{row.get('artifact_dir')}/{required_name}" + record = report_artifacts.get(path) + if not isinstance(digest, str) or record is None or record.get("sha256") != digest: + fail(f"{suffix} BINSEC {row.get('kernel')} does not bind {required_name}") else: require_equal(binsec.get("enabled"), False, f"{suffix} BINSEC enabled") require_equal(binsec.get("policy"), "unsupported", f"{suffix} BINSEC policy") @@ -212,9 +390,15 @@ def validate_provenance( version: str, commit: str, root: Path, + ct: dict[str, Any], ) -> None: + equality = ct.get("equality_evidence", {}).get("release_binary", {}) + harness_path = "tools/ct-harness/Cargo.toml" + harness = load_git_toml(root, commit, harness_path) + harness_profile = harness.get("profile", {}).get("release", {}) + harness_library = harness.get("lib", {}) for key, expected in { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.provenance", "crate": "rscrypto", "crate_version": version, @@ -225,16 +409,59 @@ def validate_provenance( "target": target, "target_triple": target, "profile": "release", - "features": ["std", "full", "parallel"], + "backend": equality.get("backend"), + "features": equality.get("features"), + "default_features": equality.get("default_features"), + "harness_package": harness.get("package", {}).get("name"), + "harness_version": harness.get("package", {}).get("version"), + "harness_crate_type": harness_library.get("crate-type"), + "opt_level": str(harness_profile.get("opt-level")), + "lto": harness_profile.get("lto"), + "panic": harness_profile.get("panic"), + "codegen_units": harness_profile.get("codegen-units"), + "overflow_checks": harness_profile.get("overflow-checks"), + "debug": harness_profile.get("debug"), + "strip": harness_profile.get("strip"), }.items(): require_equal(provenance.get(key), expected, f"{suffix} provenance {key}") require_equal(provenance.get("ct_manifest_sha256"), sha256_git_file(root, commit, "ct.toml"), f"{suffix} ct.toml hash") + require_equal( + provenance.get("package_manifest_sha256"), + sha256_git_file(root, commit, "Cargo.toml"), + f"{suffix} Cargo.toml hash", + ) + require_equal( + provenance.get("harness_manifest_sha256"), + sha256_git_file(root, commit, harness_path), + f"{suffix} CT harness manifest hash", + ) tools = provenance.get("tools", {}) for tool in ("python", "cargo", "rustc", "llvm_objdump", "llvm_nm", "llvm_size"): if not isinstance(tools.get(tool), str) or not tools[tool]: fail(f"{suffix} provenance lacks the {tool} version") if not isinstance(provenance.get("rustc"), str) or not provenance["rustc"]: fail(f"{suffix} provenance lacks the rustc version") + for field in ( + "linker", + "linker_source", + "linker_path", + "linker_sha256", + "linker_version", + "linker_command", + "linker_command_sha256", + ): + if not isinstance(provenance.get(field), str) or not provenance.get(field): + fail(f"{suffix} provenance lacks {field}") + if not isinstance(provenance.get("effective_rustflags"), list): + fail(f"{suffix} provenance effective_rustflags must be a list") + if not isinstance(provenance.get("rustflags_source"), str) or not provenance.get("rustflags_source"): + fail(f"{suffix} provenance rustflags_source must be a non-empty string") + if not isinstance(provenance.get("target_features"), list): + fail(f"{suffix} provenance target_features must be a list") + if not isinstance(provenance.get("target_cfg_features"), list): + fail(f"{suffix} provenance target_cfg_features must be a list") + if provenance.get("target_cpu") is not None and not isinstance(provenance.get("target_cpu"), str): + fail(f"{suffix} provenance target_cpu must be a string or null") component_locks = provenance.get("component_lockfiles", []) if not isinstance(component_locks, list) or not component_locks: fail(f"{suffix} provenance lacks component lockfile hashes") @@ -264,7 +491,7 @@ def validate_indexes( heuristics_md_path: Path, ) -> dict[str, dict[str, Any]]: for key, expected in { - "schema_version": 1, + "schema_version": 2, "kind": "rscrypto.ct.evidence-index", "crate": "rscrypto", "crate_version": version, @@ -281,6 +508,26 @@ def validate_indexes( require_equal(evidence_artifacts, artifacts, f"{suffix} evidence/provenance artifact records") recorded_hashes = parse_hashes(hashes_path) require_equal(recorded_hashes, {name: row.get("sha256") for name, row in artifacts.items()}, f"{suffix} artifact hash list") + artifact_kinds = [row.get("kind") for row in artifacts.values()] + for kind in ( + "linked_binary", + "linked_binary_disassembly", + "linked_binary_raw_disassembly", + "linked_binary_symbol_map", + "linked_binary_nm_symbol_map", + "linked_binary_link_map", + "linked_binary_size", + "linker_command", + ): + require_equal(artifact_kinds.count(kind), 1, f"{suffix} {kind} artifact count") + linker_commands = [row for row in artifacts.values() if row.get("kind") == "linker_command"] + linker_command = linker_commands[0] + require_equal(provenance.get("linker_command"), linker_command.get("path"), f"{suffix} linker command path") + require_equal( + provenance.get("linker_command_sha256"), + linker_command.get("sha256"), + f"{suffix} linker command hash", + ) provenance_reports = records_by_name(provenance.get("reports"), f"{suffix} provenance reports") evidence_reports = records_by_name(evidence.get("reports"), f"{suffix} evidence reports") @@ -293,7 +540,7 @@ def validate_indexes( return artifacts -def validate_heuristics(heuristics: dict[str, Any], suffix: str, target: str) -> None: +def validate_heuristics(heuristics: dict[str, Any], suffix: str, target: str, ct: dict[str, Any]) -> None: for key, expected in { "schema_version": 2, "kind": "rscrypto.ct.asm-heuristics", @@ -301,6 +548,8 @@ def validate_heuristics(heuristics: dict[str, Any], suffix: str, target: str) -> "profile": "release", "needs_fix_count": 0, "unclassified_count": 0, + "unwaived_fail_count": 0, + "missing_symbols": [], }.items(): require_equal(heuristics.get(key), expected, f"{suffix} heuristics {key}") require_equal( @@ -308,6 +557,60 @@ def validate_heuristics(heuristics: dict[str, Any], suffix: str, target: str) -> heuristics.get("needs_fix_count", 0) + heuristics.get("needs_binsec_count", 0) + heuristics.get("accepted_count", 0), f"{suffix} heuristic disposition count", ) + equality = ct.get("equality_evidence", {}).get("release_binary", {}) + equality_symbols = set(equality.get("owner_symbols", [])) | set(equality.get("public_len_symbols", [])) + summary = heuristics.get("symbol_summary", {}) + for symbol in equality_symbols: + if not summary.get(symbol, {}).get("present"): + fail(f"{suffix} heuristics lack final equality symbol {symbol}") + owner_summary = heuristics.get("ct_intended_call_closure", {}).get("primitive_summary", {}).get( + "owner_equality.fixed", {} + ) + require_equal(set(owner_summary.get("root_symbols", [])), set(equality.get("owner_symbols", [])), f"{suffix} owner roots") + if owner_summary.get("missing_root_symbols"): + fail(f"{suffix} owner equality closure has missing roots") + final_closure = heuristics.get("final_equality_call_closure", {}) + require_equal(set(final_closure.get("root_symbols", [])), equality_symbols, f"{suffix} final equality roots") + require_equal(final_closure.get("missing_root_symbols"), [], f"{suffix} final equality missing roots") + require_equal(final_closure.get("unresolved_internal_calls"), [], f"{suffix} final equality unresolved calls") + terminal_call_sites = final_closure.get("terminal_call_sites") + if not isinstance(terminal_call_sites, list): + fail(f"{suffix} final equality closure lacks terminal call sites") + terminal_call_locators = [row.get("locator") for row in terminal_call_sites if isinstance(row, dict)] + if len(terminal_call_locators) != len(terminal_call_sites) or any( + not isinstance(locator, str) or not locator for locator in terminal_call_locators + ): + fail(f"{suffix} final equality closure has an invalid terminal call site") + if len(set(terminal_call_locators)) != len(terminal_call_locators): + fail(f"{suffix} final equality closure has duplicate terminal call sites") + disassemblies = [ + row for row in heuristics.get("disassembly_files", []) if row.get("path", "").endswith(".binary.disasm.txt") + ] + if len(disassemblies) != 1: + fail(f"{suffix} heuristics do not identify one final binary disassembly") + require_equal(final_closure.get("artifact"), disassemblies[0].get("path"), f"{suffix} final equality artifact") + require_equal( + final_closure.get("artifact_sha256"), + disassemblies[0].get("sha256"), + f"{suffix} final equality artifact hash", + ) + + +def validate_equality_index(evidence: dict[str, Any], suffix: str, ct: dict[str, Any]) -> None: + equality = ct.get("equality_evidence", {}).get("release_binary", {}) + equality_symbols = set(equality.get("owner_symbols", [])) | set(equality.get("public_len_symbols", [])) + locations: dict[str, list[dict[str, Any]]] = {} + for primitive in evidence.get("primitives", []): + for row in primitive.get("harness", {}).get("symbols", []): + name = row.get("name") + if isinstance(name, str): + locations.setdefault(name, []).extend(row.get("locations", [])) + for symbol in equality_symbols: + object_names = [str(row.get("object", "")) for row in locations.get(symbol, [])] + if sum(name.startswith("rscrypto_ct_evidence") and name.endswith((".o", ".obj")) for name in object_names) != 1: + fail(f"{suffix} evidence index lacks one equality pre-link location for {symbol}") + if sum(name == "rscrypto-ct-evidence.binary" for name in object_names) != 1: + fail(f"{suffix} evidence index lacks one final linked location for {symbol}") def validate_raw_archive( @@ -356,8 +659,8 @@ def validate_lane( provenance = load_json(provenance_path) evidence = load_json(evidence_path) heuristics = load_json(heuristics_path) - validate_report(report, suffix, target, ct) - validate_provenance(provenance, suffix, target, version, commit, root) + validate_report(report, suffix, target, version, commit, provenance, provenance_path, ct) + validate_provenance(provenance, suffix, target, version, commit, root, ct) artifacts = validate_indexes( provenance, evidence, @@ -369,7 +672,8 @@ def validate_lane( heuristics_path, heuristics_md_path, ) - validate_heuristics(heuristics, suffix, target) + validate_equality_index(evidence, suffix, ct) + validate_heuristics(heuristics, suffix, target, ct) validate_raw_archive( raw_path, suffix, @@ -398,8 +702,14 @@ def validate_lane( "features": provenance.get("features"), "target_cpu": provenance.get("target_cpu"), "target_features": provenance.get("target_features"), + "target_cfg_features": provenance.get("target_cfg_features"), "effective_rustflags": provenance.get("effective_rustflags"), + "rustflags_source": provenance.get("rustflags_source"), "linker": provenance.get("linker"), + "linker_path": provenance.get("linker_path"), + "linker_sha256": provenance.get("linker_sha256"), + "linker_version": provenance.get("linker_version"), + "linker_command_sha256": provenance.get("linker_command_sha256"), } @@ -423,6 +733,7 @@ def main() -> int: fail("--evidence-version must be an unprefixed SemVer version") if re.fullmatch(r"[0-9a-f]{40}", evidence_commit) is None: fail("--evidence-commit must be a full lowercase Git commit") + validate_exact_candidate(args.version, args.commit, evidence_version, evidence_commit) if not args.input.is_dir(): fail(f"CT evidence artifact directory missing: {args.input}") @@ -446,7 +757,7 @@ def main() -> int: "git_commit": args.commit, "evidence_crate_version": evidence_version, "evidence_git_commit": evidence_commit, - "evidence_mode": "exact_commit" if evidence_commit == args.commit else "release_only_delta", + "evidence_mode": "exact_commit", "profile": "release", "lanes": [ validate_lane(args.input, suffix, target, evidence_version, evidence_commit, root, ct) diff --git a/tools/ct-dudect/Cargo.lock b/tools/ct-dudect/Cargo.lock index 42fd4f08..cfda7259 100644 --- a/tools/ct-dudect/Cargo.lock +++ b/tools/ct-dudect/Cargo.lock @@ -303,6 +303,14 @@ version = "0.0.0" dependencies = [ "dudect-bencher", "rscrypto", + "rscrypto-ct-harness", +] + +[[package]] +name = "rscrypto-ct-harness" +version = "0.0.0" +dependencies = [ + "rscrypto", ] [[package]] diff --git a/tools/ct-dudect/Cargo.toml b/tools/ct-dudect/Cargo.toml index 33564661..48fe052f 100644 --- a/tools/ct-dudect/Cargo.toml +++ b/tools/ct-dudect/Cargo.toml @@ -19,6 +19,7 @@ codegen-units = 1 [dependencies] dudect-bencher = "0.7.0" rscrypto = { path = "../..", default-features = false, features = ["std", "full", "parallel", "diag"] } +rscrypto-ct-harness = { path = "../ct-harness" } [[bin]] name = "rscrypto-ct-dudect" diff --git a/tools/ct-dudect/src/main.rs b/tools/ct-dudect/src/main.rs index fa35a0ba..996ca57c 100644 --- a/tools/ct-dudect/src/main.rs +++ b/tools/ct-dudect/src/main.rs @@ -207,7 +207,7 @@ fn argon2i_parallel_params() -> Argon2Params { } macro_rules! fixed_owner_eq_case { - ($name:ident, $type:ty, $len:expr) => { + ($name:ident, $entry:path, $len:expr) => { fn $name(runner: &mut CtRunner, rng: &mut BenchRng) { let mut inputs = Vec::with_capacity(samples()); for _ in 0..samples() { @@ -217,20 +217,36 @@ macro_rules! fixed_owner_eq_case { if matches!(class, Class::Right) { right[0] ^= 1; } - inputs.push((class, <$type>::from_bytes(left), <$type>::from_bytes(right))); + inputs.push((class, left, right)); } for (class, left, right) in inputs { - runner.run_one(class, || left == right); + runner.run_one(class, || $entry(left.as_ptr(), right.as_ptr()) == 1); } } }; } -fixed_owner_eq_case!(owner_eq_16_equal_vs_first_diff, Aes128GcmKey, 16); -fixed_owner_eq_case!(owner_eq_32_equal_vs_first_diff, X25519SecretKey, 32); -fixed_owner_eq_case!(owner_eq_48_equal_vs_first_diff, HmacSha384Tag, 48); -fixed_owner_eq_case!(owner_eq_64_equal_vs_first_diff, HmacSha512Tag, 64); +fixed_owner_eq_case!( + owner_eq_16_equal_vs_first_diff, + rscrypto_ct_harness::ct_entry_owner_eq_16, + 16 +); +fixed_owner_eq_case!( + owner_eq_32_equal_vs_first_diff, + rscrypto_ct_harness::ct_entry_owner_eq_32, + 32 +); +fixed_owner_eq_case!( + owner_eq_48_equal_vs_first_diff, + rscrypto_ct_harness::ct_entry_owner_eq_48, + 48 +); +fixed_owner_eq_case!( + owner_eq_64_equal_vs_first_diff, + rscrypto_ct_harness::ct_entry_owner_eq_64, + 64 +); fn secret_wrappers_debug_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { let mut inputs = Vec::with_capacity(samples()); diff --git a/tools/ct-harness/Cargo.toml b/tools/ct-harness/Cargo.toml index 6f97fd5c..141c63ba 100644 --- a/tools/ct-harness/Cargo.toml +++ b/tools/ct-harness/Cargo.toml @@ -17,7 +17,12 @@ codegen-units = 1 [lib] name = "rscrypto_ct_harness" -crate-type = ["staticlib"] +crate-type = ["rlib", "staticlib"] +bench = false + +[[bin]] +name = "rscrypto-ct-evidence" +path = "src/main.rs" bench = false [dependencies] diff --git a/tools/ct-harness/src/lib.rs b/tools/ct-harness/src/lib.rs index b132fab3..cfe6e4d5 100644 --- a/tools/ct-harness/src/lib.rs +++ b/tools/ct-harness/src/lib.rs @@ -15,13 +15,13 @@ use rscrypto::{ Aegis256, Aegis256Key, Aes128Gcm, Aes128GcmKey, Aes128GcmSiv, Aes128GcmSivKey, Aes256Gcm, Aes256GcmKey, Aes256GcmSiv, Aes256GcmSivKey, Argon2Params, Argon2d, Argon2i, Argon2id, AsconAead128, AsconAead128Key, Blake2b256, Blake2b512, Blake2s128, Blake2s256, Blake3, Blake3KeyedHash, ChaCha20Poly1305, ChaCha20Poly1305Key, Crc32, EcdsaP256SecretKey, - EcdsaP384SecretKey, Ed25519PublicKey, Ed25519SecretKey, Ed25519Signature, HkdfSha256, HkdfSha384, HmacSha256, - HmacSha256Tag, HmacSha384, HmacSha384Tag, HmacSha512, HmacSha512Tag, Kmac256, MlKem512, MlKem512Ciphertext, - MlKem512DecapsulationKey, MlKem512EncapsulationKey, MlKem768, MlKem768Ciphertext, MlKem768DecapsulationKey, - MlKem768EncapsulationKey, MlKem1024, MlKem1024Ciphertext, MlKem1024DecapsulationKey, MlKem1024EncapsulationKey, - MlKemError, Pbkdf2Sha256, Pbkdf2Sha512, RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, RsaPssProfile, - RsaPublicKeyPolicy, Scrypt, ScryptParams, SecretBytes, Sha256, X25519PublicKey, X25519SecretKey, XChaCha20Poly1305, - XChaCha20Poly1305Key, + EcdsaP384SecretKey, Ed25519PublicKey, Ed25519SecretKey, Ed25519Signature, HkdfSha256, HkdfSha384, HmacSha3_224Tag, + HmacSha256, HmacSha256Tag, HmacSha384, HmacSha384Tag, HmacSha512, HmacSha512Tag, Kmac256, MlKem512, + MlKem512Ciphertext, MlKem512DecapsulationKey, MlKem512EncapsulationKey, MlKem768, MlKem768Ciphertext, + MlKem768DecapsulationKey, MlKem768EncapsulationKey, MlKem1024, MlKem1024Ciphertext, MlKem1024DecapsulationKey, + MlKem1024EncapsulationKey, MlKemError, Pbkdf2Sha256, Pbkdf2Sha512, RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, + RsaPssProfile, RsaPublicKeyPolicy, Scrypt, ScryptParams, SecretBytes, Sha256, X25519PublicKey, X25519SecretKey, + XChaCha20Poly1305, XChaCha20Poly1305Key, aead::{Nonce96, Nonce128, Nonce192, Nonce256}, checksum::Checksum, traits::Kem as _, @@ -800,6 +800,7 @@ fixed_tag_verify_entry!(ct_entry_hmac_sha512_verify, HmacSha512, HmacSha512Tag, macro_rules! fixed_owner_eq_entry { ($name:ident, $type:ty, $len:expr) => { + #[inline(never)] #[unsafe(no_mangle)] pub extern "C" fn $name(a: *const u8, b: *const u8) -> u8 { // SAFETY: Fixed-size FFI inputs are copied by value after null checks. @@ -817,9 +818,13 @@ macro_rules! fixed_owner_eq_entry { } fixed_owner_eq_entry!(ct_entry_owner_eq_16, Aes128GcmKey, 16); +fixed_owner_eq_entry!(ct_entry_owner_eq_28, HmacSha3_224Tag, 28); fixed_owner_eq_entry!(ct_entry_owner_eq_32, X25519SecretKey, 32); fixed_owner_eq_entry!(ct_entry_owner_eq_48, HmacSha384Tag, 48); fixed_owner_eq_entry!(ct_entry_owner_eq_64, HmacSha512Tag, 64); +fixed_owner_eq_entry!(ct_entry_owner_eq_1632, MlKem512DecapsulationKey, 1632); +fixed_owner_eq_entry!(ct_entry_owner_eq_2400, MlKem768DecapsulationKey, 2400); +fixed_owner_eq_entry!(ct_entry_owner_eq_3168, MlKem1024DecapsulationKey, 3168); #[unsafe(no_mangle)] pub extern "C" fn ct_entry_secret_bytes32_debug_masked(secret: *const u8) -> u8 { diff --git a/tools/ct-harness/src/main.rs b/tools/ct-harness/src/main.rs new file mode 100644 index 00000000..b5426873 --- /dev/null +++ b/tools/ct-harness/src/main.rs @@ -0,0 +1,33 @@ +//! Final linked constant-time evidence binary. + +fn main() { + macro_rules! retain { + ($($entry:path),+ $(,)?) => { + $(let _ = std::hint::black_box($entry as *const ());)+ + }; + } + + retain!( + rscrypto_ct_harness::ct_entry_owner_eq_16, + rscrypto_ct_harness::ct_entry_owner_eq_28, + rscrypto_ct_harness::ct_entry_owner_eq_32, + rscrypto_ct_harness::ct_entry_owner_eq_48, + rscrypto_ct_harness::ct_entry_owner_eq_64, + rscrypto_ct_harness::ct_entry_owner_eq_1632, + rscrypto_ct_harness::ct_entry_owner_eq_2400, + rscrypto_ct_harness::ct_entry_owner_eq_3168, + rscrypto_ct_harness::ct_entry_kmac256_verify, + rscrypto_ct_harness::ct_entry_mlkem512_decapsulate, + rscrypto_ct_harness::ct_entry_mlkem768_decapsulate, + rscrypto_ct_harness::ct_entry_mlkem1024_decapsulate, + rscrypto_ct_harness::ct_entry_argon2i_verify, + rscrypto_ct_harness::ct_entry_argon2d_verify, + rscrypto_ct_harness::ct_entry_argon2id_verify, + rscrypto_ct_harness::ct_entry_scrypt_verify, + rscrypto_ct_harness::ct_entry_rsa_pkcs1v15_sign_fixed_blinding, + rscrypto_ct_harness::ct_entry_rsa_pss_sign_fixed_blinding, + rscrypto_ct_harness::ct_entry_rsa_oaep_decrypt_fixed_blinding, + rscrypto_ct_harness::ct_entry_rsa_pkcs1v15_decrypt_fixed_blinding, + rscrypto_ct_harness::ct_entry_rsa_private_key_pkcs8_roundtrip, + ); +} From 31b6179afe1e7cc8233fc27a50012a215e8cedaa Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Sun, 19 Jul 2026 21:55:51 -0400 Subject: [PATCH 2/2] build: migrate to cargo-rail 0.18 Replace removed config synchronization with semantic migration checks while preserving disabled MSRV policy, push-only release effects, and the tagged v5.1 action contract. --- .config/rail.toml | 23 ++--------------------- .github/actions/setup/action.yaml | 2 +- .github/workflows/_ci-suite.yaml | 2 +- .github/workflows/ci.yaml | 2 +- justfile | 2 +- scripts/ci/install-tools.sh | 2 +- scripts/ci/pre-push.sh | 2 +- scripts/ci/release-preflight.sh | 2 +- 8 files changed, 9 insertions(+), 28 deletions(-) diff --git a/.config/rail.toml b/.config/rail.toml index 397b6aa5..2be66f9d 100644 --- a/.config/rail.toml +++ b/.config/rail.toml @@ -28,33 +28,17 @@ targets = [ include_paths = true include_renamed = true -pin_transitives = false -transitive_host = "root" - strict_version_compat = true exact_pin_handling = "warn" major_version_conflict = "warn" - -msrv = false # Root package MSRV is authoritative; this single-crate workspace does not inherit workspace.package. -msrv_source = "max" - -detect_unused = true -compiler_diag_cache = true -remove_unused = true - -prune_dead_features = true preserve_features = [] - -detect_undeclared_features = true -fix_undeclared_features = true skip_undeclared_patterns = [] exclude = [] include = [] max_backups = 2 -sort_dependencies = true -enforce_msrv_inheritance = false consumer_scope = "open" # Published library: preserve dormant public feature and dependency APIs. +msrv_policy = { mode = "disabled" } # Root package MSRV remains authoritative in this single-crate workspace. [release] @@ -62,8 +46,6 @@ tag_prefix = "v" tag_format = "{prefix}{version}" # Single crate: vX.Y.Z require_clean = true -push = true -create_github_release = false sign_tags = true publish_delay = 5 @@ -75,7 +57,7 @@ unconventional_commits = "allow" # Required .changes files own bumps and user-f semver_check = "warn" change_dir = ".changes" require_change_files = ["rscrypto"] -forge = "auto" # Release creation provider: auto, github, gitlab +remote_effects = "push" # Release automation pushes commits/tags; the repository workflow owns forge releases. [release.changelog] path = "CHANGELOG.md" @@ -107,7 +89,6 @@ infrastructure = [ ] unknown_file_policy = "strict" # Unknown crate files build/test; unknown workspace files run infrastructure checks. confidence_profile = "balanced" # Planner confidence profile: strict, balanced, fast -bot_pr_confidence_profile = "strict" # Optional planner profile override for bot-authored PRs [change-detection.custom] cargo_graph = [ diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 03e02942..ba7872a5 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -62,7 +62,7 @@ runs: if: inputs.tools-mode != 'none' shell: bash env: - CARGO_RAIL_VERSION: "0.17.3" + CARGO_RAIL_VERSION: "0.18.0" CARGO_SEMVER_CHECKS_VERSION: "0.48.0" TOOLS_MODE: ${{ inputs.tools-mode }} run: scripts/ci/install-tools.sh "$TOOLS_MODE" diff --git a/.github/workflows/_ci-suite.yaml b/.github/workflows/_ci-suite.yaml index cfe60bb8..73d222e3 100644 --- a/.github/workflows/_ci-suite.yaml +++ b/.github/workflows/_ci-suite.yaml @@ -77,7 +77,7 @@ jobs: run_script: | mkdir -p target/cargo-rail cargo rail config validate --strict - cargo rail config sync --check + cargo rail config migrate --check cargo rail unify --check --explain --format json \ --output target/cargo-rail/unify-result.json artifact_name: cargo-graph-${{ inputs.cache_key_prefix }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0914ccef..9cfbee1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,7 +41,7 @@ jobs: id: rail uses: loadingalias/cargo-rail-action@90f7f739028887508c6e8d8b7cc510dc612eb653 # v5.1.0 with: - version: "0.17.3" + version: "0.18.0" - name: Check Release Intent Coverage if: github.event_name == 'pull_request' diff --git a/justfile b/justfile index 05d8e1d0..e8372560 100644 --- a/justfile +++ b/justfile @@ -112,7 +112,7 @@ check-zeroize-evidence: check-unify: cargo rail config validate --strict - cargo rail config sync --check + cargo rail config migrate --check cargo rail unify --check --explain ci-check: diff --git a/scripts/ci/install-tools.sh b/scripts/ci/install-tools.sh index ec136d73..0134efa8 100755 --- a/scripts/ci/install-tools.sh +++ b/scripts/ci/install-tools.sh @@ -5,7 +5,7 @@ set -euo pipefail MODE="${1:-standard}" -CARGO_RAIL_VERSION="${CARGO_RAIL_VERSION:-0.17.3}" +CARGO_RAIL_VERSION="${CARGO_RAIL_VERSION:-0.18.0}" CARGO_SEMVER_CHECKS_VERSION="${CARGO_SEMVER_CHECKS_VERSION:-0.48.0}" ACTIONLINT_VERSION="${ACTIONLINT_VERSION:-1.7.12}" ZIZMOR_VERSION="${ZIZMOR_VERSION:-1.26.1}" diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index 19179217..db119495 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -202,7 +202,7 @@ run_actions_check() { run_rail_config_check() { cargo rail config validate --strict - cargo rail config sync --check + cargo rail config migrate --check } run_rail_unify_check() { diff --git a/scripts/ci/release-preflight.sh b/scripts/ci/release-preflight.sh index 83c637ef..d259e3e9 100755 --- a/scripts/ci/release-preflight.sh +++ b/scripts/ci/release-preflight.sh @@ -107,7 +107,7 @@ if ! grep -qE "^## \\[$tag_version\\]" CHANGELOG.md; then fi cargo rail config validate --strict -cargo rail config sync --check +cargo rail config migrate --check # Exact-commit CI owns exhaustive compiler-backed Cargo graph assurance. The # release CI Gate verifies that named job before publication can proceed. cargo deny check all