diff --git a/.changes/add-caller-random-rsa-signing-a1e7.md b/.changes/add-caller-random-rsa-signing-a1e7.md new file mode 100644 index 00000000..c4ec566f --- /dev/null +++ b/.changes/add-caller-random-rsa-signing-a1e7.md @@ -0,0 +1,5 @@ +--- +"rscrypto" = "minor" +--- + +Add caller-supplied entropy APIs for RSA profile and TLS signing, a fixed-schedule batched blinding inverse, and half-width CRT assembly dispatch while preserving bounded blinding, fault checks, output cleanup, and allocation-free scratch reuse. diff --git a/.config/benchmark-matrix.json b/.config/benchmark-matrix.json index 8da036b3..95a29fbe 100644 --- a/.config/benchmark-matrix.json +++ b/.config/benchmark-matrix.json @@ -95,7 +95,7 @@ "binary": "rsa", "kind": "criterion", "required": true, - "features": ["parallel", "rsa", "diag"] + "features": ["parallel", "rsa", "diag", "getrandom"] }, "aead_diag": { "binary": "aead", diff --git a/.config/rail.toml b/.config/rail.toml index fe4535a2..cb600503 100644 --- a/.config/rail.toml +++ b/.config/rail.toml @@ -1,7 +1,5 @@ -# cargo-rail config for rscrypto -# Documentation: https://github.com/loadingalias/cargo-rail - -# Targets +# Additional target-resolution and compiler-evidence views owned by +# .config/target-matrix.json and docs/platforms.md. targets = [ "aarch64-apple-darwin", "aarch64-pc-windows-msvc", @@ -23,67 +21,45 @@ targets = [ ] [unify] -include_paths = true include_renamed = true - strict_version_compat = true exact_pin_handling = "warn" major_version_conflict = "warn" -preserve_features = [] -skip_undeclared_patterns = [] - -exclude = [] -include = [] -max_backups = 2 -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. - +skip_undeclared_patterns = [] # Every feature spelling is actionable in this crate. +consumer_scope = "open" # Published library: external consumers may activate dormant public features. +msrv_policy = { mode = "disabled" } # Cargo.toml owns the tested package MSRV. [release] -tag_prefix = "v" -tag_format = "{prefix}{version}" # Single crate: vX.Y.Z - +source = "changes" +tag_format = "{prefix}{version}" sign_tags = true - -require_changelog_entries = false require_release_notes = true -release_notes_dir = "release-notes" pre_1_breaking_bump = "minor" -unconventional_commits = "allow" # Required .changes files own bumps and user-facing release notes. -semver_check = "warn" +# Before 1.0, reviewed minor releases may deliberately replace unstable API +# shapes. Re-enable compiler-backed SemVer enforcement when rscrypto adopts a +# stable compatibility contract. +semver_check = "off" change_dir = ".changes" -require_change_files = ["rscrypto"] -remote_effects = "push" # Release automation pushes commits/tags; the repository workflow owns forge releases. +remote_effects = "push" # The repository workflow, not Cargo Rail, owns forge release publication. [release.changelog] path = "CHANGELOG.md" relative_to = "workspace" -entry_format = "- {scope}{breaking}{description}{prs} ({sha_link})" # Entry placeholders: scope, breaking, description, prs, sha, sha_link, type -emoji = true # Render emoji in changelog section headers -group_order = [] # Render reviewed .changes entries only; commit subjects are not release notes. -fallback = "skip" # Drop commit-derived entries. - -[release.changelog.filters] -skip_types = ["chore", "ci"] -skip_scopes = [] -include_paths = [] # Optional changelog attribution include globs -exclude_paths = [] # Optional changelog attribution exclude globs - [change-detection] infrastructure = [ ".changes/**", ".github/**", ".config/**", + ".cargo/**", "justfile", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "rustfmt.toml", - ".cargo/config.toml", ] -unknown_file_policy = "strict" # Unknown crate files build/test; unknown workspace files run infrastructure checks. -confidence_profile = "balanced" # Planner confidence profile: strict, balanced, fast +unknown_file_policy = "strict" +confidence_profile = "balanced" [change-detection.custom] cargo_graph = [ @@ -92,12 +68,11 @@ cargo_graph = [ "rust-toolchain.toml", ".cargo/**", ".config/rail.toml", + ".config/target-matrix.json", + ".config/toolchains.toml", "build.rs", "src/**", "tests/**", "benches/**", "examples/**", ] - -[run] -default_profile = "local" # Default run profile (built-ins: local, ci, nightly) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index d45c299f..c7310f10 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -1,6 +1,6 @@ name: Setup rscrypto Environment description: | - Install Rust toolchain, cargo tools, and configure caching. + Install the Rust toolchain, configure Cargo Rail compiler reuse, and install cargo tools. Unified host setup for GHA + runs-on.com + IBM/RISC-V runners. inputs: @@ -8,10 +8,6 @@ inputs: description: Toolchain contract to install (development, msrv, or nightly). required: false default: "development" - cache-key: - description: Suffix for the rust-cache key (workflow + target). - required: false - default: "" tools-mode: description: Tool install mode (standard, quality, release, rail, ci, supply-chain, bench, ibm, fuzz, coverage, ct-linux, minimal, none). required: false @@ -20,44 +16,41 @@ inputs: description: Comma-separated rust toolchain components. required: false default: "clippy, rustfmt, rust-src" - enable-magic-cache: - description: Enable runs-on.com MagicCache (S3-backed). Only effective on runs-on pools declaring s3-cache extras. + cache-url: + description: Machine-owned Cargo Rail L2 URL. Empty leaves existing machine cache policy unchanged. required: false - default: "false" - enable-rust-cache: - description: Enable Swatinem/rust-cache for cargo artifacts. + default: "" + cache-mode: + description: Cargo Rail remote authority (read or read-write). required: false - default: "true" + default: "read" + cache-max-size: + description: Positive bound for the job-local verified compiler cache. + required: false + default: "10GiB" runs: using: composite steps: - # runs-on.com S3-backed cache intercept. No-op on non-runs-on pools. - - name: Setup MagicCache - if: inputs.enable-magic-cache == 'true' - uses: runs-on/action@46910bf61b41721b0579f237e186afb35477007a # v2.3.0 - - name: Setup Toolchain uses: ./.github/actions/setup-toolchain with: contract: ${{ inputs.toolchain-contract }} components: ${{ inputs.toolchain-components }} + - name: Setup Cargo Rail Cache + if: inputs.cache-url != '' + uses: loadingalias/cargo-rail-action/cache@47e86bde928ce420b85efa5f8d3b5feb96fd0ffc # v7.0.0 + with: + version: "0.22.2" + checksum: required + url: ${{ inputs.cache-url }} + mode: ${{ inputs.cache-mode }} + max-size: ${{ inputs.cache-max-size }} + - name: Install Cargo Tools if: inputs.tools-mode != 'none' shell: bash env: TOOLS_MODE: ${{ inputs.tools-mode }} run: scripts/ci/install-tools.sh "$TOOLS_MODE" - - - name: Setup Rust Cache - if: inputs.enable-rust-cache == 'true' - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - shared-key: "rscrypto-v1-${{ inputs.cache-key }}" - cache-directories: ${{ inputs.tools-mode == 'rail' && 'target/cargo-rail/cache' || '' }} - cache-bin: false - cache-on-failure: true - # Populate cache on main, manual dispatch, and PRs (so slow platforms - # like IBM/RISC-V warm their cache before merge). - save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' }} diff --git a/.github/runs-on.yml b/.github/runs-on.yml index 96656737..05340c37 100644 --- a/.github/runs-on.yml +++ b/.github/runs-on.yml @@ -100,7 +100,6 @@ runners: spot: false volume: 100gb:gp3 ssh: true - extras: ["s3-cache"] tags: - rscrypto:ci - arch:x86-64 @@ -113,7 +112,6 @@ runners: spot: false volume: 100gb:gp3 ssh: true - extras: ["s3-cache"] tags: - rscrypto:ci - arch:aarch64 diff --git a/.github/workflows/_ci-suite.yaml b/.github/workflows/_ci-suite.yaml index d1cdbf08..2d054716 100644 --- a/.github/workflows/_ci-suite.yaml +++ b/.github/workflows/_ci-suite.yaml @@ -7,10 +7,6 @@ name: _ci-suite on: workflow_call: inputs: - cache_key_prefix: - description: "Cache-key prefix, segregates commit/weekly caches." - required: true - type: string supply_chain_mode: description: "'light' (cargo deny check advisories) or 'full' (deny all + audit)." required: true @@ -60,11 +56,8 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 30 - cache_key: ${{ inputs.cache_key_prefix }}-quality tools_mode: quality toolchain_components: "clippy, rustfmt, rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: quality msrv: @@ -73,12 +66,9 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 30 - cache_key: ${{ inputs.cache_key_prefix }}-msrv tools_mode: none toolchain_contract: msrv toolchain_components: "" - enable_magic_cache: true - enable_rust_cache: true operation: msrv cargo-graph: @@ -86,15 +76,14 @@ jobs: name: Cargo Graph Assurance uses: ./.github/workflows/_rust-job.yaml with: - runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci + # PR runners are provisioned from protected main, whose pre-transition + # definition still enables RunsOn MagicCache until this change lands. + runner: ubuntu-latest timeout_minutes: 90 - cache_key: ${{ inputs.cache_key_prefix }}-cargo-graph tools_mode: rail toolchain_components: "rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: cargo-graph - artifact_name: cargo-graph-${{ inputs.cache_key_prefix }} + artifact_name: cargo-graph-${{ inputs.test_mode }} artifact_path: target/cargo-rail/unify-result.json artifact_always: true artifact_retention_days: ${{ inputs.artifact_retention_days }} @@ -105,11 +94,8 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 120 - cache_key: ${{ inputs.cache_key_prefix }}-feature-contracts tools_mode: minimal toolchain_components: "rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: feature-contracts ci: @@ -123,11 +109,8 @@ jobs: with: runner: ${{ matrix.target.runner }} timeout_minutes: 180 - cache_key: ${{ inputs.cache_key_prefix }}-${{ matrix.target.name }} tools_mode: ${{ contains(matrix.target.name, 'windows-msvc') && 'none' || 'ci' }} toolchain_components: ${{ contains(matrix.target.name, 'windows-msvc') && 'clippy' || 'clippy, rustfmt, rust-src' }} - enable_magic_cache: ${{ matrix.target.type == 'runson' }} - enable_rust_cache: true operation: native target: ${{ matrix.target.name }} test_mode: ${{ inputs.test_mode }} @@ -138,12 +121,9 @@ jobs: with: runner: ubuntu-24.04-s390x timeout_minutes: 120 - cache_key: ${{ inputs.cache_key_prefix }}-ibm-s390x tools_mode: none toolchain_contract: nightly toolchain_components: "clippy, rustfmt" - enable_magic_cache: false - enable_rust_cache: true operation: native-ibm test_mode: ${{ inputs.test_mode }} @@ -153,12 +133,9 @@ jobs: with: runner: ubuntu-24.04-ppc64le-p10 timeout_minutes: 120 - cache_key: ${{ inputs.cache_key_prefix }}-ibm-power10 tools_mode: none toolchain_contract: nightly toolchain_components: "clippy, rustfmt" - enable_magic_cache: false - enable_rust_cache: true operation: native-ibm test_mode: ${{ inputs.test_mode }} @@ -168,12 +145,9 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=intel-spr timeout_minutes: 30 - cache_key: ${{ inputs.cache_key_prefix }}-platform-amx-test-nodebug tools_mode: none toolchain_contract: nightly toolchain_components: "rustfmt" - enable_magic_cache: true - enable_rust_cache: true operation: platform-amx cross-targets: @@ -182,12 +156,9 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 90 - cache_key: ${{ inputs.cache_key_prefix }}-cross-targets tools_mode: none toolchain_contract: nightly toolchain_components: "clippy, rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: cross-targets supply-chain: @@ -196,9 +167,6 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 45 - cache_key: ${{ inputs.cache_key_prefix }}-supply-chain tools_mode: supply-chain - enable_magic_cache: true - enable_rust_cache: true operation: supply-chain supply_chain_mode: ${{ inputs.supply_chain_mode }} diff --git a/.github/workflows/_rust-job.yaml b/.github/workflows/_rust-job.yaml index 5f02f3f9..db587c02 100644 --- a/.github/workflows/_rust-job.yaml +++ b/.github/workflows/_rust-job.yaml @@ -17,10 +17,6 @@ on: required: false type: number default: 30 - cache_key: - required: false - type: string - default: "" tools_mode: required: false type: string @@ -33,14 +29,6 @@ on: required: false type: string default: "clippy, rustfmt, rust-src" - enable_magic_cache: - required: false - type: boolean - default: false - enable_rust_cache: - required: false - type: boolean - default: true test_mode: required: false type: string @@ -133,11 +121,10 @@ jobs: uses: ./.github/actions/setup with: toolchain-contract: ${{ inputs.toolchain_contract }} - cache-key: ${{ inputs.cache_key }} tools-mode: ${{ inputs.tools_mode }} toolchain-components: ${{ inputs.toolchain_components }} - enable-magic-cache: ${{ inputs.enable_magic_cache }} - enable-rust-cache: ${{ inputs.enable_rust_cache }} + cache-url: ${{ vars.CARGO_RAIL_CACHE_URL }} + cache-mode: ${{ github.event_name == 'pull_request' && 'read' || 'read-write' }} - name: Run shell: bash diff --git a/.github/workflows/bench.yaml b/.github/workflows/bench.yaml index 33ccc0bc..f406518f 100644 --- a/.github/workflows/bench.yaml +++ b/.github/workflows/bench.yaml @@ -69,12 +69,9 @@ jobs: with: runner: ${{ matrix.runner }} timeout_minutes: ${{ matrix.timeout_minutes }} - cache_key: bench-${{ matrix.artifact_suffix }} tools_mode: ${{ matrix.tools_mode }} toolchain_contract: ${{ (matrix.platform == 'ibm-s390x' || matrix.platform == 'ibm-power10') && 'nightly' || 'development' }} toolchain_components: ${{ matrix.toolchain_components }} - enable_magic_cache: false - enable_rust_cache: true operation: benchmark platform: ${{ matrix.platform }} bench_targets: ${{ inputs.targets || 'all' }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 742ffe18..2c4ac339 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,9 +37,9 @@ jobs: - name: Build Plan (PR) if: github.event_name == 'pull_request' id: rail - uses: loadingalias/cargo-rail-action@ebc8ef2740efefe2cd5c42826344a8f1213113ae # v6.1.2 + uses: loadingalias/cargo-rail-action@47e86bde928ce420b85efa5f8d3b5feb96fd0ffc # v7.0.0 with: - version: "0.21.0" + version: "0.22.2" checksum: required since: ${{ github.event.pull_request.base.sha }} @@ -56,11 +56,8 @@ jobs: with: runner: ubuntu-latest timeout_minutes: 45 - cache_key: dependabot-smoke tools_mode: supply-chain toolchain_components: "clippy, rustfmt" - enable_magic_cache: false - enable_rust_cache: true operation: dependabot-smoke base_sha: ${{ github.event.pull_request.base.sha }} @@ -70,7 +67,6 @@ jobs: if: ${{ !cancelled() && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && github.actor != 'dependabot[bot]' && (needs.rail-plan.result != 'success' || needs.rail-plan.outputs.build == 'true' || needs.rail-plan.outputs.test == 'true' || needs.rail-plan.outputs.infra == 'true'))) }} uses: ./.github/workflows/_ci-suite.yaml with: - cache_key_prefix: commit supply_chain_mode: light test_mode: commit include_cargo_graph: ${{ contains(needs.rail-plan.outputs.surfaces_json, '"custom:cargo_graph":true') }} diff --git a/.github/workflows/ct.yaml b/.github/workflows/ct.yaml index 57c4dff5..67efe03c 100644 --- a/.github/workflows/ct.yaml +++ b/.github/workflows/ct.yaml @@ -173,12 +173,9 @@ jobs: checkout_ref: ${{ needs.plan.outputs.checkout_ref }} runner: ${{ matrix.runner }} timeout_minutes: ${{ matrix.timeout_minutes }} - cache_key: ct-${{ matrix.artifact_suffix }} tools_mode: ${{ matrix.tools_mode }} toolchain_contract: ${{ (matrix.platform == 'ibm-s390x' || matrix.platform == 'ibm-power10' || matrix.platform == 'rise-riscv') && 'nightly' || 'development' }} toolchain_components: ${{ matrix.toolchain_components }} - enable_magic_cache: ${{ matrix.enable_magic_cache }} - enable_rust_cache: ${{ matrix.enable_rust_cache }} operation: constant-time platform: ${{ matrix.platform }} target: ${{ matrix.target }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ed5762d4..673ed00a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -131,19 +131,16 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - cache-key: release-preflight tools-mode: release toolchain-components: "" - enable-magic-cache: false - enable-rust-cache: true + cache-url: ${{ vars.CARGO_RAIL_CACHE_URL }} + cache-mode: read-write - - name: Install recovery SemVer checker + - name: Remove reviewed recovery tooling if: github.event_name == 'workflow_dispatch' run: | set -euo pipefail - automation_root=target/release-automation - "$automation_root/scripts/ci/install-tools.sh" semver - rm -rf "$automation_root" + rm -rf target/release-automation - name: Release preflight id: preflight @@ -185,11 +182,10 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - cache-key: release-publish tools-mode: none toolchain-components: "" - enable-magic-cache: false - enable-rust-cache: true + cache-url: ${{ vars.CARGO_RAIL_CACHE_URL }} + cache-mode: read - name: Restore validated release inputs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/riscv.yaml b/.github/workflows/riscv.yaml index 3f8ae671..54c3a303 100644 --- a/.github/workflows/riscv.yaml +++ b/.github/workflows/riscv.yaml @@ -50,12 +50,9 @@ jobs: with: runner: ubuntu-24.04-riscv timeout_minutes: 240 - cache_key: weekly-riscv64 tools_mode: none toolchain_contract: nightly toolchain_components: "clippy, rustfmt" - enable_magic_cache: false - enable_rust_cache: true operation: native-riscv ct: @@ -82,12 +79,9 @@ jobs: with: runner: ubuntu-24.04-riscv timeout_minutes: 240 - cache_key: bench-rise-riscv tools_mode: ibm toolchain_contract: nightly toolchain_components: "clippy, rustfmt" - enable_magic_cache: false - enable_rust_cache: true operation: benchmark platform: rise-riscv bench_targets: ${{ inputs.targets || 'all' }} diff --git a/.github/workflows/rsa.yaml b/.github/workflows/rsa.yaml index b3b0d714..e4fe3cee 100644 --- a/.github/workflows/rsa.yaml +++ b/.github/workflows/rsa.yaml @@ -36,12 +36,9 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 60 - cache_key: rsa-miri-linux-x64 tools_mode: minimal toolchain_contract: nightly toolchain_components: "miri, rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: rsa-miri artifact_name: rsa-miri-linux-x64 artifact_path: ci-evidence/rsa-miri-linux-x64.log @@ -62,10 +59,7 @@ jobs: with: runner: ${{ matrix.target.runner }} timeout_minutes: 180 - cache_key: rsa-leakage-${{ matrix.target.name }} tools_mode: minimal - enable_magic_cache: true - enable_rust_cache: true operation: rsa-leakage target: ${{ matrix.target.name }} artifact_name: rsa-leakage-${{ matrix.target.name }} @@ -79,11 +73,8 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 45 - cache_key: rsa-linux-x86_64-asm tools_mode: none toolchain_components: "" - enable_magic_cache: true - enable_rust_cache: true operation: rsa-linux-x64-asm artifact_name: rsa-linux-x86_64-asm artifact_path: ci-evidence/rsa-linux-x86_64-asm.log diff --git a/.github/workflows/weekly.yaml b/.github/workflows/weekly.yaml index c06fd325..7c20faf9 100644 --- a/.github/workflows/weekly.yaml +++ b/.github/workflows/weekly.yaml @@ -67,7 +67,6 @@ jobs: needs: mode uses: ./.github/workflows/_ci-suite.yaml with: - cache_key_prefix: weekly supply_chain_mode: ${{ needs.mode.outputs.mode == 'release' && 'full' || 'light' }} test_mode: weekly include_cargo_graph: ${{ needs.mode.outputs.mode == 'release' }} @@ -88,12 +87,9 @@ jobs: with: runner: ${{ matrix.target.runner }} timeout_minutes: 60 - cache_key: weekly-miri-${{ matrix.target.name }} tools_mode: minimal toolchain_contract: nightly toolchain_components: "miri, rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: miri # ─── Miri memory-safety (tree borrows). ── @@ -103,12 +99,9 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 60 - cache_key: weekly-miri-tree-borrows tools_mode: minimal toolchain_contract: nightly toolchain_components: "miri, rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: miri miri_tree_borrows: true @@ -128,11 +121,8 @@ jobs: with: runner: ${{ matrix.target.runner }} timeout_minutes: 120 - cache_key: weekly-fuzz-${{ matrix.target.name }} tools_mode: fuzz toolchain_contract: nightly - enable_magic_cache: true - enable_rust_cache: true operation: fuzz artifact_name: fuzz-output-weekly-${{ matrix.target.name }} artifact_path: | @@ -156,12 +146,9 @@ jobs: with: runner: ${{ matrix.target.runner }} timeout_minutes: 120 - cache_key: weekly-asan-fuzz-${{ matrix.target.name }} tools_mode: fuzz toolchain_contract: nightly toolchain_components: "rust-src" - enable_magic_cache: true - enable_rust_cache: true operation: fuzz-asan # --- Native ML-KEM promotion gate on the physical AWS targets. --- @@ -180,11 +167,8 @@ jobs: with: runner: runs-on=${{ github.run_id }}/runner=${{ matrix.platform }} timeout_minutes: 180 - cache_key: weekly-mlkem-${{ matrix.platform }} tools_mode: minimal toolchain_components: "clippy, rustfmt, rust-src" - enable_magic_cache: false - enable_rust_cache: true operation: mlkem-aarch64 platform: ${{ matrix.platform }} artifact_name: mlkem-aarch64-gate-${{ matrix.platform }} @@ -227,11 +211,10 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - cache-key: weekly-coverage tools-mode: coverage toolchain-components: "rust-src" - enable-magic-cache: "true" - enable-rust-cache: "true" + cache-url: ${{ vars.CARGO_RAIL_CACHE_URL }} + cache-mode: read-write - name: Download fuzz corpus artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.zed/settings.json b/.zed/settings.json index e68e209b..ec8e9191 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,7 +1,6 @@ { "lsp": { "rust-analyzer": { - "enable_lsp_tasks": false, "initialization_options": { "cargo": { "extraArgs": ["--locked"], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc30be55..6de7c670 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,32 @@ Use `minor` or `major` instead of `patch` when the compatibility impact requires it. The pre-push check is the final authority on whether release intent is missing. +## Configure compiler reuse + +Enable Cargo Rail once for the effective Cargo home on each development +machine: + +```bash +cargo rail cache setup --check +cargo rail cache setup +cargo rail cache status --scope local --format json +``` + +The installed wrapper applies transparently to ordinary Cargo, nextest, Just, +and IDE invocations. Cargo Rail bypasses unsupported compiler operations; do +not add wrapper commands to recipes or scripts. To add a machine-owned remote +authority, preview and apply it explicitly: + +```bash +cargo rail cache setup --check --remote '' --remote-mode read-write +cargo rail cache setup --remote '' --remote-mode read-write +``` + +Keep credentials outside the URL and repository. Use `read` for a consumer +identity and enforce the same restriction in the provider policy. Use +`CARGO_RAIL_CACHE=off` only when a check requires a deliberately cold compiler +process, such as machine-code zeroization evidence or Miri. + ## Validate the change Run checks proportional to the change. Common starting points are: @@ -70,7 +96,7 @@ Use deeper checks where the risk requires them: | Portable unsafe path | `just test-miri` | | Constant-time claim boundary | `just ct-full --target `; update `ct.toml` only with matching evidence | | Apple Silicon RSA assembly | `just test-rsa-macos-asm` on a physical local Arm64 Mac; GitHub Actions intentionally has no macOS runner | -| Public API change | `cargo semver-checks --package rscrypto --all-features` | +| Public API change | Review callers, docs, migration guidance, and release intent; pre-1.0 SemVer enforcement is deferred | | Dependency or release change | `cargo deny check all` and `cargo audit --ignore RUSTSEC-2023-0071` | ## Review and commit diff --git a/Cargo.toml b/Cargo.toml index bc381376..c8e89000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -419,7 +419,7 @@ strip = "none" debug-assertions = true overflow-checks = true lto = false -incremental = false # I get higher sccache hit rates and faster build/check cycles locally. +incremental = false # Preserve reproducible full codegen and Cargo Rail compiler-result reuse. codegen-units = 256 [profile.release] diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 7faaaa77..18830a75 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -46,13 +46,22 @@ lifecycle, transport, and access control. Inputs crossing the boundary: -| Input | Source | Assumption | -| ---------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| Keys, passwords, seeds | Caller | The caller protects confidentiality and supplies the required entropy. Imports enforce documented shape and algorithm constraints, not key quality. | -| Messages, AAD, ciphertexts, tags, signatures, encoded keys | Caller, usually relayed from a network peer | Untrusted. | -| Randomness | `getrandom` or caller-supplied fill closures | The operating system or caller provides the required entropy quality. Output lengths are fixed by the API. | -| CPU capability reports | CPUID, auxv, sysctl, OS APIs | The host reports capabilities correctly. Forced-backend overrides are validated before use. | -| Build configuration | Cargo features, target features | The builder selects and records the intended configuration. | +| Input | Source | Assumption | +| ---------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Keys, passwords, seeds | Caller | The caller protects confidentiality and supplies the required entropy. Imports enforce documented shape and algorithm constraints, not key quality. | +| Messages, AAD, ciphertexts, tags, signatures, encoded keys | Caller, usually relayed from a network peer | Untrusted. | +| Randomness | `getrandom` or caller-supplied fill closures | The operating system or caller provides the required entropy quality and fills every requested byte. Request lengths are fixed by the selected operation and public inputs. | +| CPU capability reports | CPUID, auxv, sysctl, OS APIs | The host reports capabilities correctly. Forced-backend overrides are validated before use. | +| Build configuration | Cargo features, target features | The builder selects and records the intended configuration. | + +RSA caller-random signing accepts bytes, not mathematical blinding values. +rscrypto owns bounded sampling, inversion, range validation, private +arithmetic, and the public fault check. A fill error is reduced to +`RsaPrivateOpError::EntropyUnavailable`; the callback's error payload does not +cross the primitive boundary. Accepted factors are inverted directly modulo +the public RSA modulus with a fixed public-width batched schedule; inversion +does not consume or branch on the private CRT factors. Candidate retry count +and the final opaque success/failure remain observable as documented. Outputs are digests, tags, ciphertexts, signatures, derived keys, and opaque errors. Direct comparison of fixed-size secret-bearing owners returns an opaque diff --git a/benches/rapidhash.rs b/benches/rapidhash.rs index e9670194..ac6cd67e 100644 --- a/benches/rapidhash.rs +++ b/benches/rapidhash.rs @@ -15,7 +15,7 @@ const COLLECTION_KEYS: usize = 4096; fn collection_key(index: u64) -> [u8; 32] { let mut state = index; let mut key = [0u8; 32]; - for lane in key.chunks_exact_mut(8) { + for lane in key.as_chunks_mut::<8>().0 { state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); let mut word = state; word = (word ^ (word >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); diff --git a/benches/rsa.rs b/benches/rsa.rs index 831f52b7..44b7eb1c 100644 --- a/benches/rsa.rs +++ b/benches/rsa.rs @@ -10,6 +10,14 @@ use core::hint::black_box; use aws_lc_rs::signature as aws_signature; use criterion::{Criterion, criterion_group, criterion_main}; use ring::signature as ring_signature; +#[cfg(feature = "getrandom")] +use rsa::{ + RsaPrivateKey as RustCryptoRsaPrivateKey, + pkcs1::DecodeRsaPrivateKey, + pss::BlindedSigningKey as RustCryptoBlindedPssSigningKey, + rand_core::{CryptoRng as RustCryptoCryptoRng, Error as RustCryptoRngError, RngCore as RustCryptoRngCore}, + signature::RandomizedSigner as _, +}; use rsa::{ RsaPublicKey as RustCryptoRsaPublicKey, pkcs1v15::{Signature as RustCryptoPkcs1v15Signature, VerifyingKey as RustCryptoPkcs1v15VerifyingKey}, @@ -19,11 +27,14 @@ use rsa::{ }; #[cfg(feature = "diag")] use rscrypto::auth::rsa::{ - diag_rsa_public_operation_bitserial, diag_rsa_public_operation_cios, diag_rsa_public_operation_cios_portable, - diag_rsa_public_operation_generic_exponent, diag_rsa_public_operation_product, diag_rsa_verify_pkcs1v15_encoded, - diag_rsa_verify_pss_encoded_with_scratch, + diag_rsa_blinding_factor_inverse_with_scratch, diag_rsa_public_operation_bitserial, diag_rsa_public_operation_cios, + diag_rsa_public_operation_cios_portable, diag_rsa_public_operation_generic_exponent, + diag_rsa_public_operation_product, diag_rsa_verify_pkcs1v15_encoded, diag_rsa_verify_pss_encoded_with_scratch, +}; +use rscrypto::{ + Digest, RsaBlindingPair, RsaPkcs1v15Profile, RsaPrivateKey, RsaPrivateKeyParts, RsaPssProfile, RsaPublicKey, + RsaPublicKeyPolicy, Sha256, Sha384, Sha512, }; -use rscrypto::{RsaPkcs1v15Profile, RsaPssProfile, RsaPublicKey, RsaPublicKeyPolicy, Sha256, Sha384, Sha512}; #[cfg(all( any(unix, windows), @@ -58,9 +69,38 @@ const RSA8192_SPKI: &[u8] = include_bytes!("rsa_fixtures/rsa8192_spki.der"); const RSA8192_PSS_SHA256: &[u8] = include_bytes!("rsa_fixtures/rsa8192_pss_sha256.sig"); const RSA8192_PKCS1V15_SHA256: &[u8] = include_bytes!("rsa_fixtures/rsa8192_pkcs1v15_sha256.sig"); +const PRIVATE_SIGNING_MESSAGE: &[u8] = b"rscrypto TLS-shaped RSA private signing benchmark"; + +#[cfg(feature = "getrandom")] +struct GetrandomRng; + +#[cfg(feature = "getrandom")] +impl RustCryptoRngCore for GetrandomRng { + fn next_u32(&mut self) -> u32 { + rsa::rand_core::impls::next_u32_via_fill(self) + } + + fn next_u64(&mut self) -> u64 { + rsa::rand_core::impls::next_u64_via_fill(self) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + self + .try_fill_bytes(dest) + .expect("OS entropy must remain available during the RSA benchmark") + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), RustCryptoRngError> { + getrandom::fill(dest).map_err(RustCryptoRngError::new) + } +} + +#[cfg(feature = "getrandom")] +impl RustCryptoCryptoRng for GetrandomRng {} + fn hex_to_vec(hex: &str) -> Vec { let mut out = Vec::with_capacity(hex.len() / 2); - for chunk in hex.as_bytes().chunks_exact(2) { + for chunk in hex.as_bytes().as_chunks::<2>().0 { let high = hex_value(chunk[0]).expect("RSA benchmark fixture must contain hexadecimal digits"); let low = hex_value(chunk[1]).expect("RSA benchmark fixture must contain hexadecimal digits"); out.push((high << 4) | low); @@ -241,6 +281,219 @@ fn modulus_minus_one(key: &RsaPublicKey) -> Vec { value } +fn rsa2048_private_key() -> RsaPrivateKey { + let modulus = hex_to_vec( + "d397b84d98a4c26138ed1b695a8106ead91d553bf06041b62d3fdc50a041e222b8f4529689c1b82c5e71554f5dd69fa2f4b6158cf0dbeb57811a0fc327e1f28e74fe74d3bc166c1eabdc1b8b57b934ca8be5b00b4f29975bcc99acaf415b59bb28a6782bb41a2c3c2976b3c18dbadef62f00c6bb226640095096c0cc60d22fe7ef987d75c6a81b10d96bf292028af110dc7cc1bbc43d22adab379a0cd5d8078cc780ff5cd6209dea34c922cf784f7717e428d75b5aec8ff30e5f0141510766e2e0ab8d473c84e8710b2b98227c3db095337ad3452f19e2b9bfbccdd8148abf6776fa552775e6e75956e45229ae5a9c46949bab1e622f0e48f56524a84ed3483b", + ); + let private_exponent = hex_to_vec( + "c4e70c689162c94c660828191b52b4d8392115df486a9adbe831e458d73958320dc1b755456e93701e9702d76fb0b92f90e01d1fe248153281fe79aa9763a92fae69d8d7ecd144de29fa135bd14f9573e349e45031e3b76982f583003826c552e89a397c1a06bd2163488630d92e8c2bb643d7abef700da95d685c941489a46f54b5316f62b5d2c3a7f1bbd134cb37353a44683fdc9d95d36458de22f6c44057fe74a0a436c4308f73f4da42f35c47ac16a7138d483afc91e41dc3a1127382e0c0f5119b0221b4fc639d6b9c38177a6de9b526ebd88c38d7982c07f98a0efd877d508aae275b946915c02e2e1106d175d74ec6777f5e80d12c053d9c7be1e341", + ); + let prime_p = hex_to_vec( + "f827bbf3a41877c7cc59aebf42ed4b29c32defcb8ed96863d5b090a05a8930dd624a21c9dcf9838568fdfa0df65b8462a5f2ac913d6c56f975532bd8e78fb07bd405ca99a484bcf59f019bbddcb3933f2bce706300b4f7b110120c5df9018159067c35da3061a56c8635a52b54273b31271b4311f0795df6021e6355e1a42e61", + ); + let prime_q = hex_to_vec( + "da4817ce0089dd36f2ade6a3ff410c73ec34bf1b4f6bda38431bfede11cef1f7f6efa70e5f8063a3b1f6e17296ffb15feefa0912a0325b8d1fd65a559e717b5b961ec345072e0ec5203d03441d29af4d64054a04507410cf1da78e7b6119d909ec66e6ad625bf995b279a4b3c5be7d895cd7c5b9c4c497fde730916fcdb4e41b", + ); + let exponent_p = hex_to_vec( + "1da6e9cf80212856e87522eb59bcef094b7836ba1514a7639e8a1d8dfba37f0245176498315e6337d2c6de5542c5c6b8dee973735b6a91adf735fbfc4c1720587b8a419e40495826e55c14d70803312a103af7b4ecc5b2ff265371c4dcd730348a10d7827ddb7d1fcd9da561db09610a4b88f767b25b5e3de21ced73baa59aa1", + ); + let exponent_q = hex_to_vec( + "d737a7c8e43d0a10c85bf0011886a16996a6371b0d46b0c5325de3003f9cc47491539f6a0b7d82407f12851cbf86e1f34da3d7d8367d104967efa7e7ad2e04cbbb8b1f4aeb165d57bd3e8afed8a62602ef304bd74f1ff106d51d44dd9f52a5ed23da1d6d2c82b4e6052fecd5978e0726ad94cd8e295510eb35cc6c49491026ab", + ); + let coefficient = hex_to_vec( + "5268d7cf073479aebb2d2ed4dd66b8c89915b52d141e0c4932f56b0c0ed0936141894ec4d27d53bc86453cd8ca5b455045218c7e196209c1c651702ece090a15e3cbcc265971300023a86fe9d34ad527e9ef03b7adfe736e0680747abfd49839b82f2ffdec43bd0343ca30e13961b32af6cdeddd195672c76b53b76fc3ea76f8", + ); + + RsaPrivateKey::from_components_with_policy( + RsaPrivateKeyParts { + modulus: &modulus, + public_exponent: 65_537, + private_exponent: &private_exponent, + prime_p: &prime_p, + prime_q: &prime_q, + exponent_p: &exponent_p, + exponent_q: &exponent_q, + coefficient: &coefficient, + }, + &RsaPublicKeyPolicy::legacy_verification(), + ) + .expect("valid RSA private benchmark fixture must succeed") +} + +fn factor_two_and_inverse(modulus: &[u8]) -> (Vec, Vec) { + let mut factor = vec![0u8; modulus.len()]; + factor[modulus.len().strict_sub(1)] = 2; + + let mut inverse = vec![0u8; modulus.len()]; + let mut carry = 0u8; + for (dst, &byte) in inverse.iter_mut().zip(modulus) { + *dst = (byte >> 1) | carry; + carry = (byte & 1) << 7; + } + for byte in inverse.iter_mut().rev() { + let (sum, overflow) = byte.overflowing_add(1); + *byte = sum; + if !overflow { + break; + } + } + + (factor, inverse) +} + +fn rsa_private_signing(c: &mut Criterion) { + let key = rsa2048_private_key(); + let mut scratch = key.private_scratch(); + let mut signature = vec![0u8; key.signature_len()]; + let salt = [0x5au8; Sha256::OUTPUT_SIZE]; + let (factor, inverse) = factor_two_and_inverse(key.public_key().modulus()); + #[cfg(feature = "getrandom")] + let rustcrypto_signing_key = { + let private_key_der = key.to_pkcs1_der(); + let mut private_key = RustCryptoRsaPrivateKey::from_pkcs1_der(&private_key_der) + .expect("RustCrypto must import the RSA private benchmark fixture"); + private_key + .precompute() + .expect("RustCrypto must precompute the RSA private benchmark fixture"); + RustCryptoBlindedPssSigningKey::::new(private_key) + }; + + let mut group = c.benchmark_group("rsa-2048-private-signing"); + #[cfg(feature = "diag")] + { + let mut inverse_scratch = key.private_scratch(); + let mut computed_inverse = vec![0u8; key.signature_len()]; + group.bench_function("blinding-inverse-scratch-rscrypto", |b| { + b.iter(|| { + diag_rsa_blinding_factor_inverse_with_scratch( + black_box(&key), + black_box(&factor), + black_box(&mut computed_inverse), + black_box(&mut inverse_scratch), + ) + .expect("valid scratch-backed RSA blinding-factor inversion must succeed") + }) + }); + } + group.bench_function("scratch-setup-rscrypto", |b| { + b.iter(|| black_box(key.private_scratch())) + }); + group.bench_function("sign-pss-sha256-fixed-entropy-scratch-rscrypto", |b| { + b.iter(|| { + key + .sign_pss_with_salt_and_blinding_factor_and_scratch( + RsaPssProfile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + black_box(&salt), + RsaBlindingPair::new(black_box(&factor), black_box(&inverse)), + black_box(&mut signature), + black_box(&mut scratch), + ) + .expect("valid scratch-backed RSA-PSS benchmark signing must succeed") + }) + }); + group.bench_function("sign-pss-sha256-fixed-entropy-oneshot-rscrypto", |b| { + b.iter(|| { + key + .sign_pss_with_salt_and_blinding_factor( + RsaPssProfile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + black_box(&salt), + RsaBlindingPair::new(black_box(&factor), black_box(&inverse)), + black_box(&mut signature), + ) + .expect("valid one-shot RSA-PSS benchmark signing must succeed") + }) + }); + group.bench_function("sign-pss-sha256-caller-entropy-scratch-rscrypto", |b| { + b.iter(|| { + key + .sign_pss_with_random_fill_and_scratch( + RsaPssProfile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + black_box(&mut signature), + black_box(&mut scratch), + |out| { + if out.len() == salt.len() { + out.copy_from_slice(black_box(&salt)); + } else { + out.copy_from_slice(black_box(&factor)); + } + Ok::<(), ()>(()) + }, + ) + .expect("valid caller-random scratch-backed RSA-PSS benchmark signing must succeed") + }) + }); + group.bench_function("sign-pss-sha256-caller-entropy-oneshot-rscrypto", |b| { + b.iter(|| { + key + .sign_pss_with_random_fill( + RsaPssProfile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + black_box(&mut signature), + |out| { + if out.len() == salt.len() { + out.copy_from_slice(black_box(&salt)); + } else { + out.copy_from_slice(black_box(&factor)); + } + Ok::<(), ()>(()) + }, + ) + .expect("valid caller-random one-shot RSA-PSS benchmark signing must succeed") + }) + }); + group.bench_function("sign-pkcs1v15-sha256-fixed-entropy-scratch-rscrypto", |b| { + b.iter(|| { + key + .sign_pkcs1v15_with_blinding_factor_and_scratch( + RsaPkcs1v15Profile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + RsaBlindingPair::new(black_box(&factor), black_box(&inverse)), + black_box(&mut signature), + black_box(&mut scratch), + ) + .expect("valid scratch-backed RSA-PKCS1-v1_5 benchmark signing must succeed") + }) + }); + #[cfg(feature = "getrandom")] + group.bench_function("sign-pss-sha256-os-entropy-scratch-rscrypto", |b| { + b.iter(|| { + key + .sign_pss_with_scratch( + RsaPssProfile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + black_box(&mut signature), + black_box(&mut scratch), + ) + .expect("valid OS-random scratch-backed RSA-PSS benchmark signing must succeed") + }) + }); + #[cfg(feature = "getrandom")] + group.bench_function("sign-pss-sha256-os-entropy-oneshot-rscrypto", |b| { + b.iter(|| { + key + .sign_pss( + RsaPssProfile::Sha256, + black_box(PRIVATE_SIGNING_MESSAGE), + black_box(&mut signature), + ) + .expect("valid OS-random one-shot RSA-PSS benchmark signing must succeed") + }) + }); + #[cfg(feature = "getrandom")] + group.bench_function("sign-pss-sha256-os-entropy-oneshot-blinded-rustcrypto", |b| { + let mut rng = GetrandomRng; + b.iter(|| { + black_box(&rustcrypto_signing_key) + .try_sign_with_rng(&mut rng, black_box(PRIVATE_SIGNING_MESSAGE)) + .expect("valid RustCrypto RSA-PSS benchmark signing must succeed") + }) + }); + group.finish(); +} + fn rsa_components_for_size( c: &mut Criterion, name: &str, @@ -890,11 +1143,18 @@ fn rsa_components(c: &mut Criterion) { } #[cfg(not(feature = "diag"))] -criterion_group!(benches, rsa_components, rsa_public_exponents, rsa_hash_components); +criterion_group!( + benches, + rsa_components, + rsa_private_signing, + rsa_public_exponents, + rsa_hash_components +); #[cfg(feature = "diag")] criterion_group!( benches, rsa_components, + rsa_private_signing, rsa_public_exponents, rsa_hash_components, rsa_montgomery_thresholds diff --git a/ct.toml b/ct.toml index 2949f795..db187a16 100644 --- a/ct.toml +++ b/ct.toml @@ -84,7 +84,7 @@ roots = [ symbol = "rscrypto::auth::rsa::mont_mul_cios_portable" kind = "variable_latency_division" max_count = 1 -source = "src/auth/rsa.rs:10000" +source = "src/auth/rsa.rs:10425" rationale = "LLVM's scalable-vector loop partition divides the public modulus-width iteration count by the target's public vector length. Private limb contents do not feed the operands." [[asm_public_operand]] @@ -99,7 +99,7 @@ roots = [ symbol = "rscrypto::auth::rsa::mont_reduce_cios" kind = "variable_latency_division" max_count = 1 -source = "src/auth/rsa.rs:10049" +source = "src/auth/rsa.rs:10475" rationale = "LLVM's scalable-vector loop partition divides the public modulus-width iteration count by the target's public vector length. Private limb contents do not feed the operands." [[asm_public_operand]] @@ -114,7 +114,7 @@ roots = [ symbol = "rscrypto::auth::rsa::private_import_is_probable_prime" kind = "variable_latency_division" max_count = 2 -source = "src/auth/rsa.rs:6877" +source = "src/auth/rsa.rs:7290" rationale = "LLVM's scalable-vector loop partitions divide the caller-visible private-component byte width and its derived fixed limb width by the target's public vector length. Candidate contents do not feed the operands." [[asm_public_operand]] @@ -128,7 +128,7 @@ roots = [ symbol = "rscrypto::auth::rsa::private_import_unsigned_be_mod_to_fixed" kind = "variable_latency_division" max_count = 1 -source = "src/auth/rsa.rs:9499" +source = "src/auth/rsa.rs:9945" rationale = "LLVM's scalable-vector loop partition divides the public modulus limb width by the target's public vector length. The private representative contents do not feed the operands." [[asm_public_operand]] @@ -143,7 +143,7 @@ roots = [ symbol = "rscrypto::auth::rsa::private_import_unsigned_be_mod_to_len" kind = "variable_latency_division" max_count = 1 -source = "src/auth/rsa.rs:9481" +source = "src/auth/rsa.rs:9927" rationale = "LLVM's scalable-vector loop partition divides the public component/modulus byte width by the target's public vector length. Private component contents do not feed the operands." [[asm_public_operand]] @@ -158,7 +158,7 @@ roots = [ symbol = "rscrypto::auth::rsa::public_montgomery_r2_into" kind = "variable_latency_division" max_count = 1 -source = "src/auth/rsa.rs:9597" +source = "src/auth/rsa.rs:10040" rationale = "LLVM's scalable-vector loop partition divides the public modulus limb width by the target's public vector length. No private value feeds the operands." [operation_inventory] @@ -1167,6 +1167,16 @@ samples = 512 smoke_samples = 128 notes = "The precomputed CRT component modulus, representative, reusable scratch addresses, buffer lengths, and all lower exponent bytes are identical across classes. AArch64 execution enters FEAT_DIT when the target advertises it." +[[dudect_case]] +name = "rsa_blinding_inverse_fixed_vs_random_factor" +primitive = "rsa.private_ops" +filter = "rsa_blinding_inverse_fixed_vs_random_factor" +left_class = "fixed valid blinding factor" +right_class = "random valid same-width blinding factor" +samples = 20000 +smoke_samples = 2000 +notes = "Required allocation-free leaf evidence for the fixed-schedule batched modular inverse. Both classes use nonzero 64-bit factors, which cannot share either 1024-bit prime factor of the fixed RSA-2048 modulus. RISC-V and s390x artifact heuristics must also reject scalar multiply in the retained evidence closure." + [[dudect_case]] name = "rsa_oaep_decrypt_fixed_vs_random_plaintext" primitive = "rsa.private_ops" @@ -2777,8 +2787,8 @@ name = "x86_64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2238 -compiler_api_sha256 = "efda15591b99fd483fb0cced2a1a7fc472706b59702b3ac46b020e05de163ba9" +compiler_api_item_count = 2246 +compiler_api_sha256 = "c3584d2a5516984b8acd3189c2d73f2aa4f7b33af63e2aec4cb31a0bb3b58a4d" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2789,8 +2799,8 @@ name = "aarch64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2232 -compiler_api_sha256 = "d47bab9a296b970b5952c9bf446e378c235436289fe4f291444e3a77227cf8f3" +compiler_api_item_count = 2240 +compiler_api_sha256 = "c573e79dace25ef23c10cedca144416acefa9e360c48ffa8bea14636cc3dd9c3" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2849,8 +2859,8 @@ name = "aarch64-apple-darwin" group = "macos" backend = "llvm" linker = "apple-ld-unpinned" -compiler_api_item_count = 2232 -compiler_api_sha256 = "d47bab9a296b970b5952c9bf446e378c235436289fe4f291444e3a77227cf8f3" +compiler_api_item_count = 2240 +compiler_api_sha256 = "c573e79dace25ef23c10cedca144416acefa9e360c48ffa8bea14636cc3dd9c3" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2874,8 +2884,8 @@ name = "s390x-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2231 -compiler_api_sha256 = "778ee9f5d5f30c3a06534eec7a8c6f787c8bc778f0beb06487d7a1c21c58efeb" +compiler_api_item_count = 2239 +compiler_api_sha256 = "44733fbae78fed04e9534deec1c367c411bc9f6004eceb52f5aaa6ef1ac41502" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2887,8 +2897,8 @@ name = "powerpc64le-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2231 -compiler_api_sha256 = "1c2422d98996e29d752f45adcc0a073efea82d1c2fab5c1071cbf177295070ca" +compiler_api_item_count = 2239 +compiler_api_sha256 = "b1ff2caddb915001a54731b9a0f53328c5541e9213c83da8e01b9a680eb53913" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2900,8 +2910,8 @@ name = "riscv64gc-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2231 -compiler_api_sha256 = "952b093eb6a502497085fdc73849ead03452b963ed0230bf3b4040957cbcc126" +compiler_api_item_count = 2239 +compiler_api_sha256 = "461621913477bbb623f12d4b07af646cd49e597027c24133294aeec9251aa34b" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -3705,15 +3715,16 @@ limitation = "Prime generation is inherently variable-work and not a CT claim. S [[operation]] id = "rsa.private_sign_and_decrypt" api = [ - "rscrypto::RsaPrivateKey::{sign_signature*,sign_pkcs1v15*,sign_pss*,decrypt_oaep*,decrypt_pkcs1v15*}", + "rscrypto::RsaPrivateKey::{sign_signature*,sign_pkcs1v15*,sign_pss*,sign_tls13_signature_scheme*,sign_tls_certificate_signature_scheme*,decrypt_oaep*,decrypt_pkcs1v15*}", ] -features = ["rsa", "alloc", "getrandom for blinding/randomized padding"] +features = ["rsa", "alloc", "getrandom only for OS-backed convenience APIs"] targets = ["all-supported"] secret_inputs = [ "private exponent/components", "plaintext", "blinding factor", "PSS salt", + "caller-supplied entropy bytes", "private arithmetic scratch", ] public_inputs = [ @@ -3726,10 +3737,15 @@ public_inputs = [ variable_time_components = [ "public modulus-width loops", "public padding/hash profiles", - "entropy-source latency", + "entropy-source or caller callback latency", + "bounded blinding-candidate rejection", "opaque input-shape rejection", ] -permitted_leakage = ["modulus/profile/lengths", "opaque success/failure"] +permitted_leakage = [ + "modulus/profile/lengths", + "entropy request count and lengths", + "opaque success/failure", +] claim = "ct-intended" evidence = [ "primitive:rsa.private_ops", @@ -3738,7 +3754,7 @@ evidence = [ "unit:rsa.private_ops.oaep_decrypt", "unit:rsa.private_ops.pkcs1v15_decrypt", ] -limitation = "Retained CRT exponents are normalized to the corresponding factor width before arithmetic, so canonical exponent encoding length is not a steady-state control or address input. AArch64 private exponentiation scopes FEAT_DIT when runtime or compile-time detection advertises it. Full private operations remain target-artifact/DudeCT evidence, not source-only claims." +limitation = "Caller-supplied entropy APIs feed the same private-operation leaf as OS-backed APIs; callback execution and bounded public-modulus blinding-candidate rejection are outside the leaf CT claim. Retained CRT exponents are normalized to the corresponding factor width before arithmetic, so canonical exponent encoding length is not a steady-state control or address input. AArch64 private exponentiation scopes FEAT_DIT when runtime or compile-time detection advertises it. Full private operations remain target-artifact/DudeCT evidence, not source-only claims." [[operation]] id = "rsa.public_operations" @@ -4206,10 +4222,18 @@ variants = ["Pkcs1v15Sign", "PssSign", "OaepDecrypt", "Pkcs1v15Decrypt"] entrypoints = [ "RsaPrivateKey::sign_signature", "RsaPrivateKey::sign_signature_with_scratch", + "RsaPrivateKey::sign_signature_with_random_fill", + "RsaPrivateKey::sign_signature_with_random_fill_and_scratch", "RsaPrivateKey::sign_pkcs1v15", "RsaPrivateKey::sign_pkcs1v15_with_scratch", "RsaPrivateKey::sign_pss", "RsaPrivateKey::sign_pss_with_scratch", + "RsaPrivateKey::sign_pss_with_random_fill", + "RsaPrivateKey::sign_pss_with_random_fill_and_scratch", + "RsaPrivateKey::sign_tls13_signature_scheme_with_random_fill", + "RsaPrivateKey::sign_tls13_signature_scheme_with_random_fill_and_scratch", + "RsaPrivateKey::sign_tls_certificate_signature_scheme_with_random_fill", + "RsaPrivateKey::sign_tls_certificate_signature_scheme_with_random_fill_and_scratch", "RsaPrivateKey::decrypt_oaep", "RsaPrivateKey::decrypt_oaep_with_scratch", "RsaPrivateKey::decrypt_pkcs1v15", @@ -4224,11 +4248,24 @@ secrets = [ "padding_state", "blinding_inverse", ] -public = ["message", "ciphertext", "profile", "modulus_length", "salt_length", "rng_failure"] -may_leak = ["input_length", "public_profile", "opaque_success_or_failure", "public_rng_failure"] +public = [ + "message", + "ciphertext", + "profile", + "modulus_length", + "salt_length", + "entropy_callback_failure", +] +may_leak = [ + "input_length", + "public_profile", + "entropy_request_count_and_lengths", + "opaque_success_or_failure", + "public_entropy_failure", +] must_not_leak_ref = "common_secret_data" required = ["tier_a"] -notes = "Top-level OS randomness and public profile parsing may fail publicly. Validated CRT exponents are retained at the corresponding factor width before steady-state private arithmetic. Steady-state private math and same-width padding/failure behavior are CT-critical." +notes = "Top-level OS randomness or caller entropy callbacks and public profile parsing may fail publicly. Candidate rejection uses only fresh entropy and the public modulus and is bounded before the shared private-operation leaf. Blinding inversion uses a public-width batched extended-binary-GCD schedule; its RISC-V and s390x secret-fed co-reduction products are multiplication-free and remain subject to the native timing gate. Validated CRT exponents are retained at the corresponding factor width before steady-state private arithmetic. Steady-state private math and same-width padding/failure behavior are CT-critical." [primitive.harness] status = "covered" symbols = [ diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 7aabfaa4..d8766830 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -91,6 +91,16 @@ Some common libraries are not primary benchmark baselines: and ML-KEM-1024. - RSA import rows measure more than raw ASN.1 parsing when the public API also validates key material or prepares arithmetic state. +- RSA private-signing rows separate reusable-scratch setup, fixed test entropy, + caller-provided entropy, and OS entropy. The RustCrypto comparison uses the + same OS entropy source with private-operation blinding enabled, but its + high-level signer returns an owned signature while rscrypto writes into a + caller buffer; interpret allocation differences as part of those public API + shapes. +- The diagnostic RSA blinding-inverse row isolates the production fixed-schedule + batched inverse with reusable scratch and cleanup. Use it with the fixed- and + caller-entropy signing rows to distinguish inverse cost from CRT + exponentiation and whole-operation overhead. - `ring` X25519 is excluded from static-key Diffie-Hellman rows because its public API exposes an ephemeral agreement shape that consumes the private key. - `dryoc` XChaCha20-Poly1305 is excluded from one-shot AEAD rows because the diff --git a/docs/constant-time.md b/docs/constant-time.md index 48d6c48b..c0f62412 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -65,6 +65,21 @@ Validated RSA keys retain each CRT exponent at its corresponding factor width before steady-state private arithmetic. DER import and export remain variable-shape operations outside that steady-state boundary. +Caller-random RSA signing changes entropy provenance, not the private +arithmetic. The callback and bounded blinding-factor rejection sampling are +outside the constant-time claim. After a candidate is accepted, caller-random +and OS-random methods reach the same claimed private sign leaf and public +re-encryption fault check. No constant-time claim is made for callback code, +entropy acquisition, or the number of rejected candidates. + +Accepted RSA blinding candidates are inverted directly modulo the public odd +modulus with a fixed public-width schedule of batched extended-binary-GCD +steps. Candidate nonzero/range validation scans the complete fixed-width +input. The final invertible/non-invertible result is declassified only after +the fixed schedule; no private CRT factor participates in inversion. Required +timing evidence includes an allocation-free fixed-factor-versus-random-factor +inverse leaf in addition to the end-to-end private-operation cases. + ## Excluded unless listed The following are not constant-time claims unless a specific manifest entry says @@ -74,7 +89,7 @@ otherwise: - Checksums and non-cryptographic hashes. - Public-key verification math. - Public key, signature, ciphertext-container, DER, PHC, and protocol parsing. -- Unlisted key-generation paths and OS randomness. +- Unlisted key-generation paths, entropy callbacks, and OS randomness. - Serialization and export of secret material. - Benchmark-only paths. - Unmeasured targets, compilers, linkers, target features, or crate feature sets. @@ -129,7 +144,18 @@ On AArch64 targets that advertise FEAT_DIT, RSA private exponentiation enters data-independent-timing state for the complete fixed-window arithmetic loop and restores the caller's prior PSTATE afterward. Generic `no_std` AArch64 builds use this hardening only when compiled with `+dit`; physical evidence remains -bound to the exact release lanes above. +bound to the exact release lanes above. The existing generic Montgomery +assembly kernel is authorized for 16- and 24-limb half-width CRT arithmetic as +well as the full public-key widths; target-native differential tests compare +those multiply, square, and in-place paths with portable Rust. + +RSA caller-blinding inversion uses a public-width batched extended binary GCD +schedule rather than secret-dependent Euclidean division or Fermat +exponentiation. Its RISC-V and s390x co-reduction products use fixed-work +bit-serial multiplication so secret operands do not reach the targets' scalar +multiply instructions. The release artifact heuristic rejects scalar multiply +in those CT evidence closures; native DudeCT remains required because this +generated-code property is necessary, not sufficient. ECDSA P-256/P-384 signing uses multiplication-free, fixed-work limb arithmetic on s390x and RISC-V to avoid the variable-latency scalar multiply observed in diff --git a/docs/features.md b/docs/features.md index 1f1ea0f5..01dd80ad 100644 --- a/docs/features.md +++ b/docs/features.md @@ -16,10 +16,10 @@ algorithm feature explicitly. # One algorithm, no_std. rscrypto = { version = "0.8.1", default-features = false, features = ["sha2"] } -# RSA public-key import and verification, no_std + alloc. +# RSA import, verification, and caller-entropy signing/encryption, no_std + alloc. rscrypto = { version = "0.8.1", default-features = false, features = ["rsa"] } -# RSA key generation, signing, encryption, and private-operation blinding. +# Add OS-entropy key generation and convenience wrappers. rscrypto = { version = "0.8.1", default-features = false, features = ["rsa", "getrandom"] } # ECDSA P-256/SHA-256 and P-384/SHA-384 signing and verification. diff --git a/docs/migration/RustCrypto/rsa.md b/docs/migration/RustCrypto/rsa.md index 2c23daea..411f5291 100644 --- a/docs/migration/RustCrypto/rsa.md +++ b/docs/migration/RustCrypto/rsa.md @@ -16,22 +16,22 @@ interoperability tests used by the stack migration guides. # Before rsa = { version = "0.9", features = ["sha2"] } -# After +# After: caller-entropy signing/encryption and imported private operations rscrypto = { version = "0.8.1", default-features = false, features = ["rsa"] } -# After, when generating keys or using randomized private/encryption APIs +# After: add key generation and OS-entropy convenience methods rscrypto = { version = "0.8.1", default-features = false, features = ["rsa", "getrandom"] } ``` ## Map -| RustCrypto `rsa` | rscrypto | -| ------------------------------------------- | ----------------------------------------------------------------------------------- | -| `RsaPublicKey` | `RsaPublicKey` | -| `RsaPrivateKey` | `RsaPrivateKey` | -| PKCS#1 / PKCS#8 / SPKI import-export traits | inherent DER import-export methods | -| `Pss`, `Pkcs1v15Sign`, `Oaep` | `RsaPssProfile`, `RsaPkcs1v15Profile`, `RsaOaepProfile` | -| caller-managed RNG | `getrandom` wrappers, or encryption `*_with_random_fill` methods for no-std callers | +| RustCrypto `rsa` | rscrypto | +| ------------------------------------------- | ------------------------------------------------------------------------------------- | +| `RsaPublicKey` | `RsaPublicKey` | +| `RsaPrivateKey` | `RsaPrivateKey` | +| PKCS#1 / PKCS#8 / SPKI import-export traits | inherent DER import-export methods | +| `Pss`, `Pkcs1v15Sign`, `Oaep` | `RsaPssProfile`, `RsaPkcs1v15Profile`, `RsaOaepProfile` | +| caller-managed RNG | signing and encryption `*_with_random_fill` methods; `getrandom` convenience wrappers | ## Import Keys @@ -140,6 +140,25 @@ private_key.sign_pkcs1v15(RsaPkcs1v15Profile::Sha256, message, &mut pkcs1v15_sig rscrypto signs with blinding. Prefer the high-level signing methods unless a test needs deterministic explicit salt/blinding hooks. +Without `getrandom`, pass the platform CSPRNG through the caller-entropy API: + +```rust +let mut signature = vec![0u8; private_key.signature_len()]; +private_key.sign_pss_with_random_fill( + RsaPssProfile::Sha256, + message, + &mut signature, + |requested| platform_csprng_fill(requested), +)?; +``` + +The callback must fill the complete requested slice with fresh unpredictable +bytes. PSS requests salt first and then one modulus-width blinding candidate +per bounded attempt; PKCS#1 v1.5 requests only blinding candidates. Callback +errors become `RsaPrivateOpError::EntropyUnavailable`. Use +`sign_pss_with_random_fill_and_scratch` when repeated signing must avoid +steady-state allocation after `RsaPrivateScratch` setup. + ## OAEP ```rust diff --git a/docs/release.md b/docs/release.md index ef5f619a..0386af0b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -143,9 +143,13 @@ cargo rail change add rscrypto --bump patch --message "Describe the user-visible cargo rail change status ``` -Use `minor` or `major` when compatibility requires it. Before preparing a -release, `cargo rail release check rscrypto --extended` validates the pending -release and its SemVer contract. +Use `minor` or `major` when compatibility requires it. Before 1.0, reviewed +minor releases may deliberately replace unstable public shapes while the +library converges on its audited contract. Compiler-backed SemVer enforcement +is therefore disabled; callers, docs, migration guidance, and release intent +still require explicit review. Re-enable enforcement when rscrypto adopts a +stable compatibility contract. `cargo rail release check rscrypto --extended` +still validates the pending release and its non-SemVer release contracts. Pull-request CI uses cargo-rail's planner to select checks from the actual changed surfaces. Weekly release mode runs the full Cargo graph proof for an @@ -178,7 +182,7 @@ receive anything, the workflow: 1. Verifies the annotated SSH signature, tag target, crate version, and changelog version. -2. Revalidates configuration, dependency policy, audit results, SemVer, and the +2. Revalidates configuration, dependency policy, audit results, and the exact-commit Weekly release-mode Cargo graph result. 3. Requires the Weekly release gate, live raw CT artifacts, complete Weekly CT/RSA, and manually dispatched RISC-V native/CT evidence from that exact diff --git a/docs/secret-lifecycle.md b/docs/secret-lifecycle.md index fd8a5838..63f6496c 100644 --- a/docs/secret-lifecycle.md +++ b/docs/secret-lifecycle.md @@ -22,18 +22,18 @@ owners merely because they are produced by secret-bearing operations. ## Source ownership and cleanup -| Owner or flow | Retained secret state | Cleanup boundary | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SecretBytes`, `SecretVec`, typed keys, private keys, and shared secrets | Fixed or variable-length key material | Concrete `Drop`; consuming export either clears the source allocation or explicitly transfers responsibility to the returned ordinary bytes | -| AEAD contexts and AES backend schedules | Expanded encryption keys and authentication subkeys | Context and nested schedule `Drop`; operation-local subkeys and authentication state are cleared after use; failed open and private-output paths clear rejected output | -| HMAC-SHA-2 | Live SHA state, keyed inner/outer prefixes, oversized-key digests, and inner-digest finalization snapshots | Secret-specific SHA finalization clears copied state and padding blocks; reset clears the replaced live state; `Drop` clears the live state and both saved prefixes | -| HKDF and PBKDF2 | PRK or password-derived HMAC prefix words and derivation scratch | Prefix-owner `Drop`; oversized-key/password digests and per-block working values are cleared on every return path | -| Ed25519 signing | Expanded scalar, nonce prefix, nonce hash state, digest, and scalar intermediates | Expanded-secret `Drop`; secret-specific SHA-512 digest/finalization clears hash state and padding snapshots; signing clears scalar and digest temporaries before return | -| HMAC-SHA-3 and KMAC | Keyed Keccak state, initial snapshots, and finalized inner state | Secret-mode Keccak owners clear on replacement and `Drop`; fixed-output finalized sponge copies and inner digests are cleared after absorption | -| Keyed BLAKE2 | Stored key, chaining state, block buffer, and finalization copies | Core and parameter-owner `Drop`; finalized chaining words and partial-block copies are cleared before return | -| Keyed and derive-key BLAKE3 | Key words, chunk/output/root state, CV stack, XOF root, and per-state, reduction, or thread-local parallel CV vectors | Conditional nested `Drop` follows the mode flags; emitted output blocks are cleared after copying; reusable and reduction vectors are cleared after their last keyed use, before logical `Vec::clear`, and on owner drop | -| ECDSA, X25519, ML-KEM, and RSA private operations | Secret scalars, decapsulation arithmetic, private components, blinding values, limbs, encoded-message buffers, and reusable private scratch | Typed and nested owner `Drop`; operation wrappers clear local arrays and initialized heap regions; rejected private outputs are cleared | -| Argon2 and scrypt | Password/pepper-derived matrix or ROMix working set | Owning matrix/state `Drop` clears every initialized block; allocation and parameter failures retain the same RAII boundary | +| Owner or flow | Retained secret state | Cleanup boundary | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SecretBytes`, `SecretVec`, typed keys, private keys, and shared secrets | Fixed or variable-length key material | Concrete `Drop`; consuming export either clears the source allocation or explicitly transfers responsibility to the returned ordinary bytes | +| AEAD contexts and AES backend schedules | Expanded encryption keys and authentication subkeys | Context and nested schedule `Drop`; operation-local subkeys and authentication state are cleared after use; failed open and private-output paths clear rejected output | +| HMAC-SHA-2 | Live SHA state, keyed inner/outer prefixes, oversized-key digests, and inner-digest finalization snapshots | Secret-specific SHA finalization clears copied state and padding blocks; reset clears the replaced live state; `Drop` clears the live state and both saved prefixes | +| HKDF and PBKDF2 | PRK or password-derived HMAC prefix words and derivation scratch | Prefix-owner `Drop`; oversized-key/password digests and per-block working values are cleared on every return path | +| Ed25519 signing | Expanded scalar, nonce prefix, nonce hash state, digest, and scalar intermediates | Expanded-secret `Drop`; secret-specific SHA-512 digest/finalization clears hash state and padding snapshots; signing clears scalar and digest temporaries before return | +| HMAC-SHA-3 and KMAC | Keyed Keccak state, initial snapshots, and finalized inner state | Secret-mode Keccak owners clear on replacement and `Drop`; fixed-output finalized sponge copies and inner digests are cleared after absorption | +| Keyed BLAKE2 | Stored key, chaining state, block buffer, and finalization copies | Core and parameter-owner `Drop`; finalized chaining words and partial-block copies are cleared before return | +| Keyed and derive-key BLAKE3 | Key words, chunk/output/root state, CV stack, XOF root, and per-state, reduction, or thread-local parallel CV vectors | Conditional nested `Drop` follows the mode flags; emitted output blocks are cleared after copying; reusable and reduction vectors are cleared after their last keyed use, before logical `Vec::clear`, and on owner drop | +| ECDSA, X25519, ML-KEM, and RSA private operations | Secret scalars, decapsulation arithmetic, private components, caller-random salt and blinding values, 64-bit limbs, 31-bit batched-inverse words, encoded-message buffers, and reusable private scratch | Typed and nested owner `Drop`; operation wrappers clear local arrays and initialized heap regions; the RSA inverse clears all GCD/coefficient/modulus words after success or failure; rejected private outputs are cleared | +| Argon2 and scrypt | Password/pepper-derived matrix or ROMix working set | Owning matrix/state `Drop` clears every initialized block; allocation and parameter failures retain the same RAII boundary | Backend call records and fixed-size array arguments may be copied into registers or ABI spill slots by the compiler. Their durable source owners and explicit @@ -42,14 +42,14 @@ explicit limitation rather than an unprovable erasure claim. ## Path audit -| Path | Source audit result | Optimized evidence | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Success | Finalized HMAC/Keccak/BLAKE copies, oversized-key digests, emitted BLAKE3 blocks, AEAD authentication state, parser staging, and private-operation scratch are cleared after the last read | Fixed stack, secret hex success, HMAC-SHA-2/SHA-3 finalization, keyed BLAKE3, and portable AEAD authentication wrappers retain volatile zero stores | -| Error | `ZeroizingBytes` and RAII owners cover parser/generator failure; AEAD and RSA clear rejected plaintext/private output | The secret hex error wrapper reaches the same audited parser as success; RSA private-component validation routes every initialized secret buffer through the audited owner drop before deallocation | -| Early return | Scope-owned fixed and heap secrets retain `Drop` cleanup across `return` and `?`; explicit cleanup precedes returns from manual scratch paths | `diag_zeroize_early_return` retains zero stores; RSA validation stages retain the nested owner-drop chain in release MIR, LLVM IR, and assembly | -| Move or transfer | `SecretBytes::expose` clears its source before returning ordinary bytes; `SecretVec::into_unprotected_vec` transfers the allocation and responsibility; keyed XOF moves transfer one root owner whose destination clears on drop | Fixed-owner move and keyed BLAKE3 XOF move/consume wrappers retain source and destination cleanup | -| Reuse | HMAC clears replaced live SHA state; secret-mode Keccak assignment drops the replaced state; BLAKE3 replacement drops the old owner; BLAKE3 parallel vectors are wiped before reuse | The BLAKE3 reset wrapper contains separate production `Drop` calls for the replaced and final owners | -| Drop | Every confidential public owner in the ownership inventory reaches a concrete or nested cleanup implementation; heap owners traverse initialized storage before deallocation | The gate follows the keyed BLAKE3 wrapper into its production `drop_in_place` and requires retained owner and heap-scratch zero stores | +| Path | Source audit result | Optimized evidence | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Success | Finalized HMAC/Keccak/BLAKE copies, oversized-key digests, emitted BLAKE3 blocks, AEAD authentication state, parser staging, and private-operation scratch are cleared after the last read | Fixed stack, secret hex success, HMAC-SHA-2/SHA-3 finalization, keyed BLAKE3, portable AEAD authentication, and RSA caller-random signing wrappers retain volatile zero stores | +| Error | `ZeroizingBytes` and RAII owners cover parser/generator failure; AEAD and RSA clear rejected plaintext/private output, including partially filled caller-entropy scratch | The RSA caller-random error wrapper reaches the same complete private-scratch cleanup as success; RSA private-component validation routes every initialized secret buffer through owner drop | +| Early return | Scope-owned fixed and heap secrets retain `Drop` cleanup across `return` and `?`; explicit cleanup precedes returns from manual scratch paths | `diag_zeroize_early_return` retains zero stores; RSA validation stages retain the nested owner-drop chain in release MIR, LLVM IR, and assembly | +| Move or transfer | `SecretBytes::expose` clears its source before returning ordinary bytes; `SecretVec::into_unprotected_vec` transfers the allocation and responsibility; keyed XOF moves transfer one root owner whose destination clears on drop | Fixed-owner move and keyed BLAKE3 XOF move/consume wrappers retain source and destination cleanup | +| Reuse | HMAC clears replaced live SHA state; secret-mode Keccak assignment drops the replaced state; BLAKE3 replacement drops the old owner; BLAKE3 parallel vectors are wiped before reuse | The BLAKE3 reset wrapper contains separate production `Drop` calls for the replaced and final owners | +| Drop | Every confidential public owner in the ownership inventory reaches a concrete or nested cleanup implementation; heap owners traverse initialized storage before deallocation | The gate follows the keyed BLAKE3 wrapper into its production `drop_in_place` and requires retained owner and heap-scratch zero stores | ## Inspect an optimized binary @@ -81,6 +81,7 @@ The gate maps evidence to production behavior as follows: | `diag_poly1305_block_portable_digest`, `diag_ascon_aead128_tag_portable`, `diag_aegis256_update_portable` | Portable Poly1305, Ascon-AEAD, and AEGIS-256 authentication-state cleanup | | `diag_aes128gcm_ghash`, `diag_aes256gcm_ghash` | AES-GCM authentication-accumulator cleanup | | `diag_zeroize_mlkem_sha3_512`, `diag_zeroize_mlkem_shake256_{scalar,pair,quad}` | ML-KEM secret SHA3-512 and scalar, paired, or quad SHAKE256 owner and seeded-state cleanup | +| `diag_rsa_caller_random_signing_success`, `diag_rsa_caller_random_signing_error` | Shared complete private-scratch cleanup after successful signing and a partially filled entropy-error path | | `diag_rsa_validate_pkcs8_private_key_der_stage` | RSA private-component validation success, staged exits, and errors through heap-owner drop before deallocation | This is host-binary evidence, not a universal machine-code proof. The gate must @@ -98,6 +99,10 @@ RSA private components, the public-key-only private-key view, private scratch, `SecretVec`, and Ed25519 expanded state have adjacent unit snapshots where their internal fixtures are available. +RSA caller-random signing discards the callback's error value and returns only +`RsaPrivateOpError::EntropyUnavailable`; neither `Debug`, `Display`, nor an +error source can expose a caller payload through this boundary. + Secret-key hex errors retain the public offending-byte field for programmatic inspection but omit that byte from both `Debug` and `Display`. Generic ECDSA and password-record entropy errors omit the caller's payload from `Debug`, diff --git a/docs/secret-ownership.md b/docs/secret-ownership.md index 247f4ff8..702c97e9 100644 --- a/docs/secret-ownership.md +++ b/docs/secret-ownership.md @@ -29,33 +29,33 @@ capability for permanent retention. ## Confidential public owners and views -| Type or family | Clone / Copy | Debug | Serialization or export | Storage | Capability use | -| --------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `SecretBytes` | Neither | Masked | Consuming `expose()` returns a plain array | Inline | Fixed-size integration boundary whose owned source is cleared on extraction | -| `SecretVec` | Neither | Masked | Consuming `into_unprotected_vec()` returns an ordinary allocation | Heap `Vec` | Variable-size RSA private-key export and explicit transfer to APIs that cannot borrow | -| `expert::DisplaySecret<'a>` | Neither | Intentionally prints bytes through both `Display` and `Debug` | Hex formatting only | Borrowed | Explicit opt-in escape hatch for integrations that must render a key; never use it in logs | -| AEAD `*Key` types | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Lets an owned cipher context coexist with a caller-retained typed key while making the extra lifetime visible | -| AEAD cipher contexts | Neither | Masked | None | Inline, except boxed RISC-V fixslice AES schedules | Reusable expanded key and authentication subkey state without exposing a generic duplication path | -| ECDSA P-256/P-384 secret keys and keypairs | Explicit duplicate; no `Clone` or `Copy` | Secret keys are masked; keypairs show only the public half | `SecretBytes` export and hex opt-in; no Serde | Inline | Caller-controlled key/keypair duplication for independent signing owners | -| Ed25519 secret key and keypair | Explicit duplicate; no `Clone` or `Copy` | Secret key is masked; keypair shows only the public half | Secret-key `SecretBytes` export, hex opt-in, and `serde-secrets`; no keypair Serde | Inline | Independent signing owners; keypair duplication also copies its expanded secret state deliberately | -| X25519 secret key and shared secret | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Explicit transfer of key-agreement material into a separately owned protocol or KDF context | -| ML-KEM decapsulation keys and shared secrets | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Explicit transfer of decapsulation or established key material into another owner | -| ML-KEM prepared decapsulation keys | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export; no Serde | Inline | Reuse of validated private arithmetic without making implicit copies | -| `RsaPrivateKey` | Neither | Public key plus a redacted private-components field | PKCS#1/PKCS#8 DER into `SecretVec`; no Serde | Heap-backed big integers and Montgomery state | Standards-compatible private-key storage/export while keeping the returned allocation typed as secret | -| `RsaPrivateScratch` | Neither | Public sizing metadata only | None | Reusable heap buffers and limb vectors | Amortizes private-operation allocation while keeping intermediate ownership with the caller | -| `RsaPrivateKeyParts<'a>` | `Clone + Copy` | Modulus size and public exponent only; private fields masked | Borrowed import fields; no Serde | Borrowed | Pass-by-value import description; copying duplicates references, not private bytes | -| `RsaBlindingPair<'a>` | `Clone + Copy` | Factor and inverse masked | Borrowed factor and inverse; no Serde | Borrowed | Couples the two caller-blinding inputs; private operations validate them against the selected key | -| `RsaSignatureSigner<'a>` | `Clone + Copy` | Signature profile only | None | Borrowed | Reusable profile-bound signing handle; copying duplicates a private-key reference, not the key | -| HMAC-SHA-2 and HMAC-SHA-3 states | Neither | Masked | None | Inline | Reuse through `reset`; independent streamed owners require explicit keyed construction rather than an implicit state copy | -| HKDF-SHA-2 states | Neither | Masked | None | Inline | Repeated expansion borrows one extracted PRK owner, which can also be shared by reference | -| KMAC128/256 states | Neither | Masked | None | Inline | Reuse through `reset`; private cSHAKE snapshots implement non-consuming finalization and reset without exposing a public keyed-state copy | -| PBKDF2-SHA-2 states | Neither | Masked | None | Inline | Repeated derivation borrows one password-prefix owner, which can also be shared by reference | -| `Poly1305OneTimeKey` and `Poly1305` | Neither | Masked | `SecretBytes` key export; no Serde | Inline | Enforces one-time key consumption and consuming finalization | -| `Argon2Context<'a>` | `Clone + Copy` | Secret bytes redacted; secret and associated-data lengths shown | None | Borrowed | Pass-by-value optional pepper/associated-data configuration without duplicating either byte string | -| BLAKE2 keyed parameter builders and variable-output state | Neither | Key bytes masked; builders show key length, salt, and personalization | None | Inline | Reuse by borrowing; independent keyed owners require explicit construction | -| Fixed-output BLAKE2 states | `Clone` | Masked | None | Inline | The same type serves unkeyed `Digest` and keyed modes, so the public `Digest: Clone` contract also permits keyed prefix forks | -| `Blake3` | `Clone` | Masked | None | Inline, plus `Vec` scratch with `parallel` | Fork a streamed common prefix in keyed or derive-key mode; cloning also duplicates initialized parallel scratch | -| `Blake3XofReader` | `Clone` | Masked | None | Inline | Checkpoint or fork an output cursor; the reader is secret-bearing when created from keyed or derive-key state | +| Type or family | Clone / Copy | Debug | Serialization or export | Storage | Capability use | +| --------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SecretBytes` | Neither | Masked | Consuming `expose()` returns a plain array | Inline | Fixed-size integration boundary whose owned source is cleared on extraction | +| `SecretVec` | Neither | Masked | Consuming `into_unprotected_vec()` returns an ordinary allocation | Heap `Vec` | Variable-size RSA private-key export and explicit transfer to APIs that cannot borrow | +| `expert::DisplaySecret<'a>` | Neither | Intentionally prints bytes through both `Display` and `Debug` | Hex formatting only | Borrowed | Explicit opt-in escape hatch for integrations that must render a key; never use it in logs | +| AEAD `*Key` types | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Lets an owned cipher context coexist with a caller-retained typed key while making the extra lifetime visible | +| AEAD cipher contexts | Neither | Masked | None | Inline, except boxed RISC-V fixslice AES schedules | Reusable expanded key and authentication subkey state without exposing a generic duplication path | +| ECDSA P-256/P-384 secret keys and keypairs | Explicit duplicate; no `Clone` or `Copy` | Secret keys are masked; keypairs show only the public half | `SecretBytes` export and hex opt-in; no Serde | Inline | Caller-controlled key/keypair duplication for independent signing owners | +| Ed25519 secret key and keypair | Explicit duplicate; no `Clone` or `Copy` | Secret key is masked; keypair shows only the public half | Secret-key `SecretBytes` export, hex opt-in, and `serde-secrets`; no keypair Serde | Inline | Independent signing owners; keypair duplication also copies its expanded secret state deliberately | +| X25519 secret key and shared secret | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Explicit transfer of key-agreement material into a separately owned protocol or KDF context | +| ML-KEM decapsulation keys and shared secrets | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Explicit transfer of decapsulation or established key material into another owner | +| ML-KEM prepared decapsulation keys | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export; no Serde | Inline | Reuse of validated private arithmetic without making implicit copies | +| `RsaPrivateKey` | Neither | Public key plus a redacted private-components field | PKCS#1/PKCS#8 DER into `SecretVec`; no Serde | Heap-backed big integers and Montgomery state | Standards-compatible private-key storage/export while keeping the returned allocation typed as secret | +| `RsaPrivateScratch` | Neither | Public sizing metadata only | None | Reusable heap buffers, 64-bit limbs, and 31-bit inverse words | Amortizes private-operation allocation while keeping intermediate ownership with the caller; inverse words hold batched-GCD values and coefficients | +| `RsaPrivateKeyParts<'a>` | `Clone + Copy` | Modulus size and public exponent only; private fields masked | Borrowed import fields; no Serde | Borrowed | Pass-by-value import description; copying duplicates references, not private bytes | +| `RsaBlindingPair<'a>` | `Clone + Copy` | Factor and inverse masked | Borrowed factor and inverse; no Serde | Borrowed | Couples the two caller-blinding inputs; private operations validate them against the selected key | +| `RsaSignatureSigner<'a>` | `Clone + Copy` | Signature profile only | None | Borrowed | Reusable profile-bound signing handle; copying duplicates a private-key reference, not the key | +| HMAC-SHA-2 and HMAC-SHA-3 states | Neither | Masked | None | Inline | Reuse through `reset`; independent streamed owners require explicit keyed construction rather than an implicit state copy | +| HKDF-SHA-2 states | Neither | Masked | None | Inline | Repeated expansion borrows one extracted PRK owner, which can also be shared by reference | +| KMAC128/256 states | Neither | Masked | None | Inline | Reuse through `reset`; private cSHAKE snapshots implement non-consuming finalization and reset without exposing a public keyed-state copy | +| PBKDF2-SHA-2 states | Neither | Masked | None | Inline | Repeated derivation borrows one password-prefix owner, which can also be shared by reference | +| `Poly1305OneTimeKey` and `Poly1305` | Neither | Masked | `SecretBytes` key export; no Serde | Inline | Enforces one-time key consumption and consuming finalization | +| `Argon2Context<'a>` | `Clone + Copy` | Secret bytes redacted; secret and associated-data lengths shown | None | Borrowed | Pass-by-value optional pepper/associated-data configuration without duplicating either byte string | +| BLAKE2 keyed parameter builders and variable-output state | Neither | Key bytes masked; builders show key length, salt, and personalization | None | Inline | Reuse by borrowing; independent keyed owners require explicit construction | +| Fixed-output BLAKE2 states | `Clone` | Masked | None | Inline | The same type serves unkeyed `Digest` and keyed modes, so the public `Digest: Clone` contract also permits keyed prefix forks | +| `Blake3` | `Clone` | Masked | None | Inline, plus `Vec` scratch with `parallel` | Fork a streamed common prefix in keyed or derive-key mode; cloning also duplicates initialized parallel scratch | +| `Blake3XofReader` | `Clone` | Masked | None | Inline | Checkpoint or fork an output cursor; the reader is secret-bearing when created from keyed or derive-key state | ## Protocol-visible authentication owners @@ -74,20 +74,20 @@ key. Generated-code timing claims remain limited by ## Internal and operation-scoped owners -| Owner | Clone / Copy | Debug / serialization | Storage | Capability use | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `ZeroizingBytes` | Neither | Neither | Inline | Generation and parsing scratch that cannot escape as a generic clone | -| AES expanded schedules | Neither | Neither | Inline; boxed only for the large RISC-V fixslice schedule | Retained by an AEAD context and borrowed by block operations; unused private `Clone` derives were removed during this inventory | -| AEAD authentication working state | Private copies only where a backend finalizer consumes a value | Neither | Inline | Bound, intra-operation snapshot needed by consuming backend finalization | -| HMAC-SHA-3 and KMAC Keccak/cSHAKE snapshots | Private use of `Clone` | Neither | Inline | Implement non-consuming finalization and reset inside one public keyed owner | -| Ed25519 `ExpandedSecret` | Private `Clone` | Masked; no serialization | Inline | Implements the public keypair's explicit `duplicate_secret()` operation | -| X25519 clamped scalar and ECDSA secret scalar/word wrappers | No generic duplication on the owning wrappers | Neither | Inline | Bound one-operation arithmetic ownership | -| ML-KEM prepared decapsulation arithmetic | Private `Clone` | Neither | Inline | Implements the prepared key's explicit `duplicate_secret()` operation | -| RSA private components, buffers, limbs, and key-generation DRBG | Neither | Private integers are masked; no serialization | Heap for variable-width integers/scratch; DRBG inline | Variable-width private arithmetic, reusable scratch, and generated-key construction | -| Argon2 matrix | `MemoryBlock` and borrowed `MatrixView` are `Clone + Copy`; `Matrix` is not | Neither | Heap `Vec` | Algorithm-defined whole-block mixing and disjoint parallel lane views; copying the view duplicates only a pointer/length pair | -| scrypt working state | `SalsaBlock` is `Clone + Copy`; owning states are not | Neither | Heap vectors | Algorithm-defined whole-block ROMix copies while one owner retains the complete work area | -| BLAKE3 parallel scratch | Cloned with `Blake3` | Neither | Per-state vectors plus thread-local vectors | Avoid repeated allocation during parallel subtree reduction; keyed modes make stored chaining values secret-derived | -| Private-key and AEAD backend transfer records | Private `Clone`/`Copy` only where passed by value or snapshotted by a consuming kernel | Neither | Inline | Fixed-layout, call-scoped handoff to portable, SIMD, or assembly code; no public capability | +| Owner | Clone / Copy | Debug / serialization | Storage | Capability use | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ZeroizingBytes` | Neither | Neither | Inline | Generation and parsing scratch that cannot escape as a generic clone | +| AES expanded schedules | Neither | Neither | Inline; boxed only for the large RISC-V fixslice schedule | Retained by an AEAD context and borrowed by block operations; unused private `Clone` derives were removed during this inventory | +| AEAD authentication working state | Private copies only where a backend finalizer consumes a value | Neither | Inline | Bound, intra-operation snapshot needed by consuming backend finalization | +| HMAC-SHA-3 and KMAC Keccak/cSHAKE snapshots | Private use of `Clone` | Neither | Inline | Implement non-consuming finalization and reset inside one public keyed owner | +| Ed25519 `ExpandedSecret` | Private `Clone` | Masked; no serialization | Inline | Implements the public keypair's explicit `duplicate_secret()` operation | +| X25519 clamped scalar and ECDSA secret scalar/word wrappers | No generic duplication on the owning wrappers | Neither | Inline | Bound one-operation arithmetic ownership | +| ML-KEM prepared decapsulation arithmetic | Private `Clone` | Neither | Inline | Implements the prepared key's explicit `duplicate_secret()` operation | +| RSA private components, buffers, 64-bit limbs, 31-bit inverse words, and key-generation DRBG | Neither | Private integers are masked; no serialization | Heap for variable-width integers/scratch; DRBG inline | Variable-width private arithmetic, fixed-schedule blinding inversion, reusable scratch, caller-entropy signing, and generated-key construction; fill closures are borrowed and not retained | +| Argon2 matrix | `MemoryBlock` and borrowed `MatrixView` are `Clone + Copy`; `Matrix` is not | Neither | Heap `Vec` | Algorithm-defined whole-block mixing and disjoint parallel lane views; copying the view duplicates only a pointer/length pair | +| scrypt working state | `SalsaBlock` is `Clone + Copy`; owning states are not | Neither | Heap vectors | Algorithm-defined whole-block ROMix copies while one owner retains the complete work area | +| BLAKE3 parallel scratch | Cloned with `Blake3` | Neither | Per-state vectors plus thread-local vectors | Avoid repeated allocation during parallel subtree reduction; keyed modes make stored chaining values secret-derived | +| Private-key and AEAD backend transfer records | Private `Clone`/`Copy` only where passed by value or snapshotted by a consuming kernel | Neither | Inline | Fixed-layout, call-scoped handoff to portable, SIMD, or assembly code; no public capability | ## Review consequences diff --git a/fuzz/target_impls/auth_rsa_import.rs b/fuzz/target_impls/auth_rsa_import.rs index 6e71a2a5..272df187 100644 --- a/fuzz/target_impls/auth_rsa_import.rs +++ b/fuzz/target_impls/auth_rsa_import.rs @@ -260,7 +260,7 @@ fn der_len(len: usize) -> Vec { #[cfg(any(fuzzing, rscrypto_internal_fuzzing))] fn hex_to_vec(hex: &str) -> Vec { let mut out = Vec::with_capacity(hex.len() / 2); - for chunk in hex.as_bytes().chunks_exact(2) { + for chunk in hex.as_bytes().as_chunks::<2>().0 { let high = hex_value(chunk[0]).unwrap_or_else(|| panic!("invalid fixture hex")); let low = hex_value(chunk[1]).unwrap_or_else(|| panic!("invalid fixture hex")); out.push((high << 4) | low); diff --git a/fuzz/target_impls/auth_rsa_private_ops.rs b/fuzz/target_impls/auth_rsa_private_ops.rs index 6b0168c0..734e4bee 100644 --- a/fuzz/target_impls/auth_rsa_private_ops.rs +++ b/fuzz/target_impls/auth_rsa_private_ops.rs @@ -1,7 +1,7 @@ #[cfg(any(fuzzing, rscrypto_internal_fuzzing))] use rscrypto::{ - RsaBlindingPair, RsaEncryptionError, RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, RsaPssProfile, - RsaPublicKeyPolicy, RsaSignatureProfile, + RsaBlindingPair, RsaEncryptionError, RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, RsaPrivateOpError, + RsaPssProfile, RsaPublicKeyPolicy, RsaSignatureProfile, }; #[cfg(any(fuzzing, rscrypto_internal_fuzzing))] use rscrypto_fuzz::{FuzzInput, some_or_return, split_at_ratio}; @@ -36,7 +36,7 @@ pub(super) fn run(data: &[u8]) { .expect("fuzz RSA private-key fixture must parse"); let (one, one_inverse) = factor_one(key.signature_len()); - match mode.rem_euclid(10) { + match mode.rem_euclid(13) { 0 => { let profile = PKCS1_PROFILES[usize::from(selector) % PKCS1_PROFILES.len()]; let mut signature = vec![0u8; key.signature_len()]; @@ -173,6 +173,82 @@ pub(super) fn run(data: &[u8]) { &mut plaintext, ); } + 10 => { + let profile = PSS_PROFILES[usize::from(selector) % PSS_PROFILES.len()]; + let salt = caller_pss_salt(profile, selector, left, right); + let mut calls = 0usize; + let mut signature = vec![0u8; key.signature_len()]; + key + .sign_pss_with_random_fill(profile, left, &mut signature, |out| { + if calls == 0 { + out.copy_from_slice(&salt); + } else { + out.copy_from_slice(&one); + } + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }) + .expect("fixture caller-random RSA-PSS signing must succeed"); + assert_eq!(calls, 2); + key + .public_key() + .verify_pss(profile, left, &signature) + .expect("self-produced caller-random RSA-PSS signature must verify"); + } + 11 => { + let profile = PSS_PROFILES[usize::from(selector) % PSS_PROFILES.len()]; + let fail_at = usize::from(split) % 130; + let mut calls = 0usize; + let mut signature = vec![0xa5; key.signature_len()]; + let mut scratch = key.private_scratch(); + let result = key.sign_pss_with_random_fill_and_scratch(profile, left, &mut signature, &mut scratch, |out| { + let call = calls; + calls = calls.strict_add(1); + out.fill(0); + if call == fail_at { Err(()) } else { Ok(()) } + }); + assert!(matches!( + result, + Err(RsaPrivateOpError::EntropyUnavailable | RsaPrivateOpError::InvalidBlindingFactor) + )); + assert!(signature.iter().all(|&byte| byte == 0)); + + key + .sign_pss_with_random_fill_and_scratch(profile, right, &mut signature, &mut scratch, |out| { + if out.len() == key.signature_len() { + out.copy_from_slice(&one); + } else { + out.fill(selector); + } + Ok::<(), ()>(()) + }) + .expect("caller-random RSA scratch must be reusable after a scheduled entropy failure"); + key + .public_key() + .verify_pss(profile, right, &signature) + .expect("caller-random RSA-PSS signature after scratch reuse must verify"); + } + 12 => { + const TLS_SCHEMES: [u16; 9] = [0x0401, 0x0501, 0x0601, 0x0804, 0x0805, 0x0806, 0x0809, 0x080a, 0x080b]; + let scheme = TLS_SCHEMES[usize::from(selector) % TLS_SCHEMES.len()]; + let profile = RsaSignatureProfile::from_tls_certificate_signature_scheme(scheme) + .expect("fixture TLS scheme must map to an RSA profile"); + let mut signature = vec![0u8; key.signature_len()]; + key + .sign_tls_certificate_signature_scheme_with_random_fill(scheme, left, &mut signature, |out| { + if out.len() == key.signature_len() { + out.copy_from_slice(&one); + } else { + out.fill(split); + } + Ok::<(), ()>(()) + }) + .expect("fixture TLS caller-random RSA signing must succeed"); + key + .public_key() + .verify_signature(profile, left, &signature) + .expect("fixture TLS caller-random RSA signature must verify"); + } _ => {} } } @@ -238,6 +314,16 @@ fn oaep_seed(profile: RsaOaepProfile, selector: u8, left: &[u8], right: &[u8]) - seed } +#[cfg(any(fuzzing, rscrypto_internal_fuzzing))] +fn caller_pss_salt(profile: RsaPssProfile, selector: u8, left: &[u8], right: &[u8]) -> Vec { + let mut salt = vec![selector; profile.digest_len()]; + for (index, byte) in left.iter().chain(right.iter()).copied().enumerate() { + let salt_len = salt.len(); + salt[index % salt_len] ^= byte; + } + salt +} + #[cfg(any(fuzzing, rscrypto_internal_fuzzing))] fn pkcs1v15_message_limit(key: &RsaPrivateKey) -> usize { key.signature_len().saturating_sub(11) diff --git a/fuzz/target_impls/auth_rsa_public_key.rs b/fuzz/target_impls/auth_rsa_public_key.rs index d0d48e8c..d0a204a2 100644 --- a/fuzz/target_impls/auth_rsa_public_key.rs +++ b/fuzz/target_impls/auth_rsa_public_key.rs @@ -178,7 +178,7 @@ fn der_null() -> Vec { fn hex_to_vec(hex: &str) -> Vec { let mut out = Vec::with_capacity(hex.len().div_euclid(2)); - for chunk in hex.as_bytes().chunks_exact(2) { + for chunk in hex.as_bytes().as_chunks::<2>().0 { let hi = hex_value(chunk[0]).expect("RSA modulus fixture must contain hexadecimal digits"); let lo = hex_value(chunk[1]).expect("RSA modulus fixture must contain hexadecimal digits"); out.push(hi.strict_shl(4) | lo); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e81aa497..903e0c74 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.0" profile = "minimal" components = ["clippy", "rustfmt", "rust-src", "rust-analyzer"] diff --git a/scripts/README.md b/scripts/README.md index 9b4d8a92..3ebd92f5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -136,6 +136,32 @@ receives only the exact stable or nightly contract declared in rustup, which verifies component downloads against the exact distribution manifest, because network bootstrap installers are rejected. +## Compiler-result reuse + +`.github/actions/setup/action.yaml` is the only CI compiler-cache owner. It +uses the same immutable Cargo Rail action revision and authenticated Cargo Rail +version as pull-request planning, installs the cache before repository Cargo +tools, and leaves tool executables and package-manager state uncached. + +The repository variable `CARGO_RAIL_CACHE_URL` selects the machine-owned L2 +authority. An empty value skips setup without changing an existing runner +receipt. Before setting it, provision each participating runner or job with +standard provider credentials restricted to that URL's bucket, container, or +prefix. The URL contains no credentials and does not belong in +`.config/rail.toml`. + +Pull-request jobs select `read`; their provider identity must also be +read-only. Trusted branch, scheduled, and release-preflight jobs select +`read-write`. Release publication consumes the cache read-only. Provider or +transport failure falls back to compilation, while Cargo Rail continues to +verify every restored compiler result. + +Local development uses the same machine-owned setup documented in +[`CONTRIBUTING.md`](../CONTRIBUTING.md). Miri and optimized zeroization evidence +set `CARGO_RAIL_CACHE=off` because those checks require a deliberately cold +compiler path. Cross-target and otherwise unsupported compiler operations rely +on Cargo Rail's typed bypass instead of clearing a global wrapper. + ## Results layout Bench results from local runs (`just bench*`) and CI (`/extract-bench` skill diff --git a/scripts/check/check-all.sh b/scripts/check/check-all.sh index b13e6b4f..7766a253 100755 --- a/scripts/check/check-all.sh +++ b/scripts/check/check-all.sh @@ -109,7 +109,7 @@ run_constrained_check() { args+=(--features "$feature_set") fi - if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" cargo_for_target "$target" "${args[@]}" >>"$log_file" 2>&1; then + if ! CARGO_TARGET_DIR="$target_dir" cargo_for_target "$target" "${args[@]}" >>"$log_file" 2>&1; then return 1 fi } @@ -162,7 +162,7 @@ run_constrained_target() { if [[ ${#alloc_crates[@]} -gt 0 ]]; then step "$target check (alloc)" for crate in "${alloc_crates[@]}"; do - if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + if ! CARGO_TARGET_DIR="$target_dir" \ cargo_for_target "$target" check --locked -p "$crate" --no-default-features --features alloc --target "$target" --lib \ >>"$log_file" 2>&1; then fail @@ -175,7 +175,7 @@ run_constrained_target() { step "$target build (no features)" for crate in "${CONSTRAINED_CRATES[@]}"; do - if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + if ! CARGO_TARGET_DIR="$target_dir" \ cargo_for_target "$target" build --locked -p "$crate" --no-default-features --target "$target" --lib --release \ >>"$log_file" 2>&1; then fail @@ -188,7 +188,7 @@ run_constrained_target() { if [[ ${#alloc_crates[@]} -gt 0 ]]; then step "$target build (alloc)" for crate in "${alloc_crates[@]}"; do - if ! RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + if ! CARGO_TARGET_DIR="$target_dir" \ cargo_for_target "$target" build --locked -p "$crate" --no-default-features --features alloc --target "$target" --lib --release \ >>"$log_file" 2>&1; then fail @@ -253,7 +253,6 @@ echo "Cross-platform checks" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -maybe_disable_sccache SCOPE_ARGS=() HOST_ARGS=("$@") FEATURE_MODE_SET=false diff --git a/scripts/check/check-feature-matrix.sh b/scripts/check/check-feature-matrix.sh index 46043e10..83d24aac 100755 --- a/scripts/check/check-feature-matrix.sh +++ b/scripts/check/check-feature-matrix.sh @@ -7,8 +7,6 @@ source "$SCRIPT_DIR/../lib/common.sh" # shellcheck source=../lib/feature-profiles.sh source "$SCRIPT_DIR/../lib/feature-profiles.sh" -maybe_disable_sccache - LOG_DIR=$(mktemp -d) trap 'rm -rf "$LOG_DIR"' EXIT TOTAL=${#COMPILE_FEATURE_SETS[@]} diff --git a/scripts/check/check-ibm.sh b/scripts/check/check-ibm.sh index 140f91ff..246ef22d 100755 --- a/scripts/check/check-ibm.sh +++ b/scripts/check/check-ibm.sh @@ -54,7 +54,7 @@ for i in "${!IBM_TARGETS[@]}"; do ( # shellcheck disable=SC2086 - if ! CC="$ZIG_CC" RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" RUSTUP_TOOLCHAIN="$NIGHTLY_TOOLCHAIN" \ + if ! CC="$ZIG_CC" CARGO_TARGET_DIR="$target_dir" RUSTUP_TOOLCHAIN="$NIGHTLY_TOOLCHAIN" \ cargo clippy $CRATE_FLAGS --lib --all-features --locked --target "$target" \ >"$log_file" 2>&1; then exit 1 diff --git a/scripts/check/check-linux.sh b/scripts/check/check-linux.sh index 54ee22d4..ddf9f2b8 100755 --- a/scripts/check/check-linux.sh +++ b/scripts/check/check-linux.sh @@ -62,7 +62,7 @@ for i in "${!LINUX_TARGETS[@]}"; do toolchain_env=(env "RUSTUP_TOOLCHAIN=$NIGHTLY_TOOLCHAIN") fi # shellcheck disable=SC2086 - if ! CC="$ZIG_CC" RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + if ! CC="$ZIG_CC" CARGO_TARGET_DIR="$target_dir" \ "${toolchain_env[@]}" cargo clippy $CRATE_FLAGS --lib --all-features --locked --target "$target" \ >"$log_file" 2>&1; then exit 1 diff --git a/scripts/check/check-win.sh b/scripts/check/check-win.sh index 33164498..8d39f2e4 100755 --- a/scripts/check/check-win.sh +++ b/scripts/check/check-win.sh @@ -17,8 +17,6 @@ if ! cargo xwin --version >/dev/null 2>&1; then exit 0 fi -maybe_disable_sccache - # Parse args and set CRATE_FLAGS, SCOPE_DESC get_crate_flags "$@" diff --git a/scripts/check/check.sh b/scripts/check/check.sh index f4dc5513..08b93330 100755 --- a/scripts/check/check.sh +++ b/scripts/check/check.sh @@ -9,8 +9,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=../lib/common.sh source "$SCRIPT_DIR/../lib/common.sh" -maybe_disable_sccache - RUN_FEATURE_MATRIX=false CHECK_ARGS=() for arg in "$@"; do diff --git a/scripts/check/rsa-asm-provenance.sh b/scripts/check/rsa-asm-provenance.sh index a7941399..d5431312 100755 --- a/scripts/check/rsa-asm-provenance.sh +++ b/scripts/check/rsa-asm-provenance.sh @@ -156,9 +156,9 @@ verify_rsa_wrapper_fingerprints() { [[ "$actual" == "$expected" ]] \ || die "$wrapper changed without a provenance review" done <<'EOF' -src/auth/rsa_aarch64_asm.rs dbcd55bc88daa1a9ab1812e964c84bfd4586ae9f6fa9b07dcd28985df6576507 -src/auth/rsa_aarch64_linux_asm.rs 69531bafa963d6d817c6a74f4a0fcaf1d98e713611874f4597768b1a6e0a0e7a -src/auth/rsa_x86_64_asm.rs 5552b8ffa76f1ba43f6aaea1b91a28fb04a02fa167408d925bf5ccec6daefe18 +src/auth/rsa_aarch64_asm.rs 2f3207e455cd800cd0ae8c833f1f743b2f830e004e65eea2f8099db28edbb846 +src/auth/rsa_aarch64_linux_asm.rs 4cc4619203bab93baeb87511924a65853a83773ce5227d6caf94019f00a87d4a +src/auth/rsa_x86_64_asm.rs 7c610b07b75a25900393efaf6e23f817edc756dbbb3459daa4280a79546d7c61 EOF } diff --git a/scripts/check/zeroize-evidence.sh b/scripts/check/zeroize-evidence.sh index 0dd6a8d1..950472a7 100755 --- a/scripts/check/zeroize-evidence.sh +++ b/scripts/check/zeroize-evidence.sh @@ -5,7 +5,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" TARGET_DIR="$ROOT/target/zeroize-evidence" MANIFEST="$ROOT/Cargo.toml" -RUSTC_WRAPPER="" CARGO_TARGET_DIR="$TARGET_DIR" cargo rustc \ +# Assembly evidence is a deliberate cold baseline; the custom target directory +# would bypass reuse, and the explicit switch keeps that boundary auditable. +CARGO_RAIL_CACHE=off CARGO_TARGET_DIR="$TARGET_DIR" cargo rustc \ --locked \ --manifest-path "$MANIFEST" \ --release \ @@ -54,6 +56,8 @@ for symbol in \ diag_zeroize_blake3_parallel_scratch \ diag_zeroize_hmac_sha256_finalize \ diag_zeroize_hmac_sha3_finalize \ + diag_rsa_caller_random_signing_success \ + diag_rsa_caller_random_signing_error \ diag_hkdf_sha256_derive_portable \ diag_hkdf_sha384_derive_portable \ diag_hkdf_sha512_derive_portable \ @@ -95,6 +99,8 @@ for symbol in \ diag_zeroize_blake3_parallel_scratch \ diag_zeroize_hmac_sha256_finalize \ diag_zeroize_hmac_sha3_finalize \ + diag_rsa_caller_random_signing_success \ + diag_rsa_caller_random_signing_error \ diag_hkdf_sha256_derive_portable \ diag_hkdf_sha384_derive_portable \ diag_hkdf_sha512_derive_portable \ @@ -342,6 +348,39 @@ llvm_calls() { true } +RSA_CALLER_SUCCESS_IR="$( + sed -n '/define .*@diag_rsa_caller_random_signing_success(/,/^}/p' "$LLVM_IR" +)" +RSA_CALLER_ERROR_IR="$( + sed -n '/define .*@diag_rsa_caller_random_signing_error(/,/^}/p' "$LLVM_IR" +)" +RSA_CALLER_SUCCESS_CLEAR_SYMBOL="$( + llvm_calls 'RsaPrivateScratch.*clear' <<<"$RSA_CALLER_SUCCESS_IR" | + sed -n 's/.*@\([^ (]*RsaPrivateScratch[^ (]*clear[^ (]*\).*/\1/p' | + head -n 1 +)" +RSA_CALLER_ERROR_CLEAR_SYMBOL="$( + llvm_calls 'RsaPrivateScratch.*clear' <<<"$RSA_CALLER_ERROR_IR" | + sed -n 's/.*@\([^ (]*RsaPrivateScratch[^ (]*clear[^ (]*\).*/\1/p' | + head -n 1 +)" +if [[ -z "$RSA_CALLER_SUCCESS_CLEAR_SYMBOL" || \ + "$RSA_CALLER_SUCCESS_CLEAR_SYMBOL" != "$RSA_CALLER_ERROR_CLEAR_SYMBOL" ]]; then + echo "zeroize RSA caller-random signing paths do not share scratch cleanup" >&2 + exit 1 +fi + +RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL="$RSA_CALLER_SUCCESS_CLEAR_SYMBOL" +RSA_PRIVATE_SCRATCH_CLEAR_IR="$( + sed -n "/define .*@$RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL(/,/^}/p" "$LLVM_IR" +)" +if [[ "$(grep -c 'store volatile .* 0' <<<"$RSA_PRIVATE_SCRATCH_CLEAR_IR" || true)" -lt 40 ]] || \ + [[ "$(grep -c 'fence syncscope("singlethread") seq_cst' \ + <<<"$RSA_PRIVATE_SCRATCH_CLEAR_IR" || true)" -lt 20 ]]; then + echo "zeroize release evidence does not clear the complete RSA private scratch" >&2 + exit 1 +fi + RSA_VALIDATION_SYMBOL="$( llvm_calls 'validate_private_key_components_through_stage' \ <<<"$RSA_VALIDATION_WRAPPER_IR" | @@ -475,8 +514,23 @@ ordered_assembly_cleanup() { } FIXED_ASSEMBLY="$(function_assembly diag_zeroize_fixed_stack <"$ASSEMBLY")" +RSA_CALLER_SUCCESS_ASSEMBLY="$( + function_assembly diag_rsa_caller_random_signing_success <"$ASSEMBLY" +)" +RSA_CALLER_ERROR_ASSEMBLY="$( + function_assembly diag_rsa_caller_random_signing_error <"$ASSEMBLY" +)" +RSA_PRIVATE_SCRATCH_CLEAR_ASSEMBLY="$( + function_assembly "$RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL" <"$ASSEMBLY" +)" RSA_VALIDATION_ASSEMBLY="$(function_assembly "$RSA_VALIDATION_SYMBOL" <"$ASSEMBLY")" RSA_SECRET_DROP_ASSEMBLY="$(function_assembly "$RSA_SECRET_DROP_SYMBOL" <"$ASSEMBLY")" +if [[ "$RSA_CALLER_SUCCESS_ASSEMBLY" != *"$RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL"* || \ + "$RSA_CALLER_ERROR_ASSEMBLY" != *"$RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL"* || \ + -z "$RSA_PRIVATE_SCRATCH_CLEAR_ASSEMBLY" ]]; then + echo "zeroize RSA caller-random signing assembly does not retain scratch cleanup" >&2 + exit 1 +fi if [[ "$RSA_VALIDATION_ASSEMBLY" != *"$RSA_SECRET_DROP_SYMBOL"* || -z "$RSA_SECRET_DROP_ASSEMBLY" ]]; then echo "zeroize RSA private-key validation assembly does not retain owner cleanup" >&2 exit 1 @@ -543,6 +597,9 @@ case "$HOST_ARCH" in diag_zeroize_blake3_parallel_scratch \ diag_zeroize_hmac_sha256_finalize \ diag_zeroize_hmac_sha3_finalize \ + diag_rsa_caller_random_signing_success \ + diag_rsa_caller_random_signing_error \ + "$RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL" \ diag_hkdf_sha256_derive_portable \ diag_hkdf_sha384_derive_portable \ diag_hkdf_sha512_derive_portable \ @@ -586,6 +643,9 @@ case "$HOST_ARCH" in diag_zeroize_blake3_parallel_scratch \ diag_zeroize_hmac_sha256_finalize \ diag_zeroize_hmac_sha3_finalize \ + diag_rsa_caller_random_signing_success \ + diag_rsa_caller_random_signing_error \ + "$RSA_PRIVATE_SCRATCH_CLEAR_SYMBOL" \ diag_hkdf_sha256_derive_portable \ diag_hkdf_sha384_derive_portable \ diag_hkdf_sha512_derive_portable \ diff --git a/scripts/ci/changed-test-planning-test.sh b/scripts/ci/changed-test-planning-test.sh index 855e7902..b45717b5 100755 --- a/scripts/ci/changed-test-planning-test.sh +++ b/scripts/ci/changed-test-planning-test.sh @@ -37,7 +37,7 @@ make_scope() { --argjson test "$test" \ --argjson infra "$infra" \ '{ - scope_contract_version: 2, + scope_contract_version: 4, resolved_base: "base", resolved_head: "head", mode: $mode, @@ -62,51 +62,40 @@ make_plan() { --argjson scope "$scope" \ --argjson files "$files" \ '{ - schema_version: 1, - command: "plan", - mode: "inspect", - result: "success", - exit_code: 0, - plan_contract_version: 5, + plan_contract_version: 7, + inputs: {snapshot_id: "v1-sha256-test"}, + resolution_universe: { + mode: "declared_dependencies", + identity: "resolution-universe-v1:sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, files: $files, scope: ( $scope - | .scope_contract_version = 3 | del(.surfaces) ), surfaces: ( $scope.surfaces - | with_entries(.value = {enabled: .value}) + | with_entries(.value = { + enabled: .value, + reasons: [], + scope: ( + if .value then + ($scope | del(.scope_contract_version, .resolved_base, .resolved_head, .surfaces)) + else + {mode: "empty", crates: [], cargo_args: []} + end + ) + }) ) }' } -make_legacy_plan() { - local scope=$1 - local files=${2:-'[]'} - - jq -cn \ - --argjson scope "$scope" \ - --argjson files "$files" \ - '{ - schema_version: 1, - command: "plan", - mode: "inspect", - result: "success", - exit_code: 0, - plan_contract_version: 3, - files: $files, - scope: $scope - }' -} - EMPTY_SCOPE="$(make_scope empty '[]' '[]')" WORKSPACE_SCOPE="$(make_scope workspace '[]' '["--workspace"]' true true false)" CRATES_SCOPE="$(make_scope crates '["crate-a","crate-b"]' '["-p","crate-a","-p","crate-b"]' true true false)" EMPTY_PLAN="$(make_plan "$EMPTY_SCOPE")" WORKSPACE_PLAN="$(make_plan "$WORKSPACE_SCOPE")" CRATES_PLAN="$(make_plan "$CRATES_SCOPE")" -LEGACY_WORKSPACE_PLAN="$(make_legacy_plan "$WORKSPACE_SCOPE")" planner_bin="$TMP_ROOT/planner-bin" mkdir -p "$planner_bin" @@ -143,8 +132,10 @@ assert_eq workspace "$(scope_mode_for_planner '' 9)" "nonzero planner exit to fa assert_eq workspace "$(scope_mode_for_planner '' 0)" "empty planner output to fail closed" assert_eq workspace "$(scope_mode_for_cached_plan '{')" "malformed JSON to fail closed" -unsuccessful_plan="$(jq -c '.result = "failure" | .exit_code = 1' <<<"$WORKSPACE_PLAN")" -assert_eq workspace "$(scope_mode_for_cached_plan "$unsuccessful_plan")" "unsuccessful result to fail closed" +missing_snapshot="$(jq -c 'del(.inputs.snapshot_id)' <<<"$WORKSPACE_PLAN")" +invalid_universe="$(jq -c '.resolution_universe.identity = "invalid"' <<<"$WORKSPACE_PLAN")" +assert_eq workspace "$(scope_mode_for_cached_plan "$missing_snapshot")" "missing snapshot identity to fail closed" +assert_eq workspace "$(scope_mode_for_cached_plan "$invalid_universe")" "invalid resolution universe to fail closed" missing_scope="$(jq -c 'del(.scope)' <<<"$WORKSPACE_PLAN")" missing_mode="$(jq -c 'del(.scope.mode)' <<<"$WORKSPACE_PLAN")" @@ -156,8 +147,8 @@ assert_eq workspace "$(scope_mode_for_cached_plan "$unsupported_mode")" "unsuppo empty_crate_selection="$(jq -c '.scope.mode = "crates" | .scope.cargo_args = []' <<<"$WORKSPACE_PLAN")" non_string_crate="$(jq -c '.scope.crates = [7] | .scope.mode = "crates" | .scope.cargo_args = ["-p", "7"]' <<<"$WORKSPACE_PLAN")" malformed_surface="$(jq -c '.surfaces.test.enabled = "false"' <<<"$WORKSPACE_PLAN")" -unsupported_plan_contract="$(jq -c '.plan_contract_version = 4' <<<"$WORKSPACE_PLAN")" -unsupported_scope_contract="$(jq -c '.scope.scope_contract_version = 2' <<<"$WORKSPACE_PLAN")" +unsupported_plan_contract="$(jq -c '.plan_contract_version = 6' <<<"$WORKSPACE_PLAN")" +unsupported_scope_contract="$(jq -c '.scope.scope_contract_version = 3' <<<"$WORKSPACE_PLAN")" assert_eq workspace "$(scope_mode_for_cached_plan "$empty_crate_selection")" "empty crate selection to fail closed" assert_eq workspace "$(scope_mode_for_cached_plan "$non_string_crate")" "non-string crate selection to fail closed" assert_eq workspace "$(scope_mode_for_cached_plan "$malformed_surface")" "malformed surface to fail closed" @@ -167,7 +158,6 @@ assert_eq workspace "$(scope_mode_for_cached_plan "$unsupported_scope_contract") assert_eq empty "$(scope_mode_for_cached_plan "$EMPTY_PLAN")" "valid explicit empty scope" assert_eq workspace "$(scope_mode_for_cached_plan "$WORKSPACE_PLAN")" "valid workspace scope" assert_eq crates "$(scope_mode_for_cached_plan "$CRATES_PLAN")" "valid crate scope" -assert_eq workspace "$(scope_mode_for_cached_plan "$LEGACY_WORKSPACE_PLAN")" "valid legacy workspace scope" crate_output="$( ( @@ -230,7 +220,8 @@ workspace_test='cargo test --locked --workspace --all-features --lib --tests' run_test_consumer planner-failure '' 9 "$workspace_test" run_test_consumer empty-output '' 0 "$workspace_test" run_test_consumer malformed-json '{' 0 "$workspace_test" -run_test_consumer unsuccessful "$unsuccessful_plan" 0 "$workspace_test" +run_test_consumer missing-snapshot "$missing_snapshot" 0 "$workspace_test" +run_test_consumer invalid-universe "$invalid_universe" 0 "$workspace_test" run_test_consumer missing-scope "$missing_scope" 0 "$workspace_test" run_test_consumer missing-mode "$missing_mode" 0 "$workspace_test" run_test_consumer unsupported-mode "$unsupported_mode" 0 "$workspace_test" @@ -239,7 +230,6 @@ run_test_consumer malformed-surface "$malformed_surface" 0 "$workspace_test" run_test_consumer valid-empty "$EMPTY_PLAN" 0 '' run_test_consumer valid-workspace "$WORKSPACE_PLAN" 0 "$workspace_test" run_test_consumer valid-crates "$CRATES_PLAN" 0 $'cargo test --locked -p crate-a --all-features --lib --tests\ncargo test --locked -p crate-b --all-features --lib --tests' -run_test_consumer valid-legacy-workspace "$LEGACY_WORKSPACE_PLAN" 0 "$workspace_test" check_fixture="$TMP_ROOT/check-repository" mkdir -p "$check_fixture/scripts/check" "$check_fixture/scripts/lib" "$check_fixture/scripts/ct" "$check_fixture/scripts/test" diff --git a/scripts/ci/check-ci-ownership-test.sh b/scripts/ci/check-ci-ownership-test.sh index f1c81a93..f64f6377 100755 --- a/scripts/ci/check-ci-ownership-test.sh +++ b/scripts/ci/check-ci-ownership-test.sh @@ -17,7 +17,9 @@ make_fixture() { cp -R "$REPO_ROOT/.github/rulesets" "$fixture/.github/rulesets" cp -R "$REPO_ROOT/.github/repository-settings" "$fixture/.github/repository-settings" cp "$REPO_ROOT/.github/dependabot.yaml" "$fixture/.github/dependabot.yaml" + cp "$REPO_ROOT/.github/runs-on.yml" "$fixture/.github/runs-on.yml" cp "$REPO_ROOT/.config/target-matrix.json" "$fixture/.config/target-matrix.json" + cp "$REPO_ROOT/.config/rail.toml" "$fixture/.config/rail.toml" cp "$REPO_ROOT/.config/ci-tool-archives.tsv" "$fixture/.config/ci-tool-archives.tsv" cp -R "$REPO_ROOT/scripts/ci" "$fixture/scripts/ci" cp "$REPO_ROOT/scripts/lib/ci-tool-integrity.sh" "$REPO_ROOT/scripts/lib/common.sh" \ @@ -154,11 +156,17 @@ yq eval '.runs.steps += [{"name": "Restore poisonable tools", "uses": "actions/c "$poisonable_tool_cache/.github/actions/setup/action.yaml" expect_failure "$poisonable_tool_cache" "CI tool executables can be restored from a poisonable cache" -rust_cache_binaries="$TMP_ROOT/rust-cache-binaries" -make_fixture "$rust_cache_binaries" -yq eval '(.runs.steps[] | select(.name == "Setup Rust Cache") | .with."cache-bin") = true' -i \ - "$rust_cache_binaries/.github/actions/setup/action.yaml" -expect_failure "$rust_cache_binaries" "the Rust build cache can restore Cargo tool executables" +competing_rust_cache="$TMP_ROOT/competing-rust-cache" +make_fixture "$competing_rust_cache" +yq eval '.runs.steps += [{"name": "Competing Rust cache", "uses": "Swatinem/rust-cache@0123456789012345678901234567890123456789"}]' -i \ + "$competing_rust_cache/.github/actions/setup/action.yaml" +expect_failure "$competing_rust_cache" "a competing Rust compiler cache bypasses Cargo Rail" + +magic_cache_extra="$TMP_ROOT/magic-cache-extra" +make_fixture "$magic_cache_extra" +yq eval '.runners.linux-x64-ci.extras = ["s3-cache"]' -i \ + "$magic_cache_extra/.github/runs-on.yml" +expect_failure "$magic_cache_extra" "RunsOn MagicCache intercepts Cargo Rail compiler results" unauthenticated_rustup="$TMP_ROOT/unauthenticated-rustup" make_fixture "$unauthenticated_rustup" @@ -179,6 +187,18 @@ yq eval '(.jobs."rail-plan".steps[] | select(.id == "rail") | .uses) = "loadinga "$floating_rail_action/.github/workflows/ci.yaml" expect_failure "$floating_rail_action" "cargo-rail-action is not commit-pinned" +floating_rail_cache="$TMP_ROOT/floating-rail-cache" +make_fixture "$floating_rail_cache" +yq eval '(.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .uses) = "loadingalias/cargo-rail-action/cache@v7"' -i \ + "$floating_rail_cache/.github/actions/setup/action.yaml" +expect_failure "$floating_rail_cache" "the Cargo Rail cache action is not commit-pinned with the planner" + +writable_pr_cache="$TMP_ROOT/writable-pr-cache" +make_fixture "$writable_pr_cache" +yq eval '(.jobs.run.steps[] | select(.name == "Setup") | .with."cache-mode") = "read-write"' -i \ + "$writable_pr_cache/.github/workflows/_rust-job.yaml" +expect_failure "$writable_pr_cache" "untrusted pull requests can write shared compiler results" + mismatched_rail_version="$TMP_ROOT/mismatched-rail-version" make_fixture "$mismatched_rail_version" yq eval '(.jobs."rail-plan".steps[] | select(.id == "rail") | .with.version) = "0.19.1"' -i \ @@ -454,10 +474,10 @@ sed -i.bak '/tools\/\*/d' "$missing_tools/.github/dependabot.yaml" rm -f "$missing_tools/.github/dependabot.yaml.bak" expect_failure "$missing_tools" "missing standalone tool dependency coverage" -duplicate_semver_owner="$TMP_ROOT/duplicate-semver-owner" -make_fixture "$duplicate_semver_owner" -printf '\n# duplicate owner\n run: cargo semver-checks --package rscrypto --all-features\n' >>"$duplicate_semver_owner/.github/workflows/weekly.yaml" -expect_failure "$duplicate_semver_owner" "duplicate SemVer owner" +reintroduced_semver_owner="$TMP_ROOT/reintroduced-semver-owner" +make_fixture "$reintroduced_semver_owner" +printf '\n# pre-1.0 SemVer enforcement reintroduced\n run: cargo semver-checks --package rscrypto --all-features\n' >>"$reintroduced_semver_owner/.github/workflows/weekly.yaml" +expect_failure "$reintroduced_semver_owner" "reintroduced SemVer owner" shrunk_matrix="$TMP_ROOT/shrunk-matrix" make_fixture "$shrunk_matrix" @@ -472,10 +492,4 @@ sed -i.bak '/EXECUTABLE_FEATURE_SETS=(/,/^)/ s/ "full"/ "std,full,uncompiled-f rm -f "$uncompiled_execution/scripts/lib/feature-profiles.sh.bak" expect_failure "$uncompiled_execution" "executable feature profile without compile coverage" -stale_amx_cache="$TMP_ROOT/stale-amx-cache" -make_fixture "$stale_amx_cache" -yq eval '.jobs.platform-amx.with.cache_key = "${{ inputs.cache_key_prefix }}-platform-amx"' -i \ - "$stale_amx_cache/.github/workflows/_ci-suite.yaml" -expect_failure "$stale_amx_cache" "AMX cache identity omits its reduced-debug test profile" - echo "CI ownership regression tests passed" diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index 9cdc87c7..cace2b36 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -30,6 +30,7 @@ RSA="$WORKFLOWS/rsa.yaml" SETUP_ACTION="$ACTIONS/setup/action.yaml" TOOLCHAIN_ACTION="$ACTIONS/setup-toolchain/action.yaml" MANIFEST="$ROOT/.config/target-matrix.json" +RAIL_CONFIG="$ROOT/.config/rail.toml" TOOL_ARCHIVES="$ROOT/.config/ci-tool-archives.tsv" CARGO_CONFIG="$ROOT/.cargo/config.toml" CROSS_SCRIPT="$ROOT/scripts/ci/cross-targets.sh" @@ -60,6 +61,7 @@ REPOSITORY_POLICY="$ROOT/.github/rulesets/protect-main.json" RELEASE_TAG_POLICY="$ROOT/.github/rulesets/protect-release-tags.json" RELEASE_IMMUTABILITY_POLICY="$ROOT/.github/repository-settings/release-immutability.json" DEPENDABOT="$ROOT/.github/dependabot.yaml" +RUNS_ON="$ROOT/.github/runs-on.yml" fail() { echo "CI ownership error: $*" >&2 @@ -129,6 +131,7 @@ require_file "$RSA" require_file "$SETUP_ACTION" require_file "$TOOLCHAIN_ACTION" require_file "$MANIFEST" +require_file "$RAIL_CONFIG" require_file "$TOOL_ARCHIVES" require_file "$CARGO_CONFIG" require_file "$CROSS_SCRIPT" @@ -159,6 +162,7 @@ require_file "$REPOSITORY_POLICY" require_file "$RELEASE_TAG_POLICY" require_file "$RELEASE_IMMUTABILITY_POLICY" require_file "$DEPENDABOT" +require_file "$RUNS_ON" [[ $(yq eval '.version' "$DEPENDABOT") == "2" ]] || fail "Dependabot config must use version 2" [[ $(yq eval '[.updates[] | select(."package-ecosystem" == "cargo")] | length' "$DEPENDABOT") == "1" ]] \ @@ -336,6 +340,67 @@ rail_version=$(sed -n 's/^CARGO_RAIL_VERSION=//p' "$INSTALL_TOOLS") rail_action_condition=$(yq eval '.jobs."rail-plan".steps[] | select(.id == "rail") | .if' "$CI") [[ "$rail_action_condition" == "github.event_name == 'pull_request'" ]] \ || fail "cargo-rail-action must run only for pull requests" + +cache_action=$(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .uses' "$SETUP_ACTION") +expected_cache_action="${rail_action%@*}/cache@${rail_action#*@}" +[[ "$cache_action" == "$expected_cache_action" ]] \ + || fail "compiler reuse must use the planner action's immutable v7 cache implementation" +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .with.version' "$SETUP_ACTION") \ + == "$rail_version" ]] \ + || fail "the Cargo Rail cache action must use the authenticated Cargo Rail version" +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .with.checksum' "$SETUP_ACTION") \ + == "required" ]] \ + || fail "the Cargo Rail cache action must require release checksums" +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .with.url' "$SETUP_ACTION") \ + == '${{ inputs.cache-url }}' ]] \ + || fail "the Cargo Rail cache action must consume only machine-owned URL input" +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .with.mode' "$SETUP_ACTION") \ + == '${{ inputs.cache-mode }}' ]] \ + || fail "the Cargo Rail cache action must consume the trust-selected remote mode" +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .with."max-size"' "$SETUP_ACTION") \ + == '${{ inputs.cache-max-size }}' ]] \ + || fail "the Cargo Rail cache action must retain an explicit local size bound" +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Cargo Rail Cache") | .if' "$SETUP_ACTION") \ + == "inputs.cache-url != ''" ]] \ + || fail "Cargo Rail cache setup must skip cleanly until machine-owned L2 is configured" +cache_step_index=$(yq eval '.runs.steps | to_entries | .[] | select(.value.name == "Setup Cargo Rail Cache") | .key' "$SETUP_ACTION") +tools_step_index=$(yq eval '.runs.steps | to_entries | .[] | select(.value.name == "Install Cargo Tools") | .key' "$SETUP_ACTION") +[[ "$cache_step_index" =~ ^[0-9]+$ && "$tools_step_index" =~ ^[0-9]+$ \ + && "$cache_step_index" -lt "$tools_step_index" ]] \ + || fail "Cargo Rail cache setup must precede repository command execution" + +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +[[ $(yq eval '.jobs.run.steps[] | select(.name == "Setup") | .with."cache-url"' "$RUST_JOB") \ + == '${{ vars.CARGO_RAIL_CACHE_URL }}' ]] \ + || fail "reusable Rust jobs must select the machine-owned Cargo Rail L2 URL" +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +[[ $(yq eval '.jobs.run.steps[] | select(.name == "Setup") | .with."cache-mode"' "$RUST_JOB") \ + == '${{ github.event_name == '\''pull_request'\'' && '\''read'\'' || '\''read-write'\'' }}' ]] \ + || fail "pull-request cache authority must be read-only" +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +[[ $(yq eval '.jobs.coverage.steps[] | select(.name == "Setup") | .with."cache-url"' "$WEEKLY") \ + == '${{ vars.CARGO_RAIL_CACHE_URL }}' \ + && $(yq eval '.jobs.coverage.steps[] | select(.name == "Setup") | .with."cache-mode"' "$WEEKLY") \ + == "read-write" ]] \ + || fail "trusted Weekly coverage must use the machine-owned Cargo Rail cache" +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +[[ $(yq eval '.jobs.preflight.steps[] | select(.name == "Setup") | .with."cache-url"' "$RELEASE") \ + == '${{ vars.CARGO_RAIL_CACHE_URL }}' \ + && $(yq eval '.jobs.preflight.steps[] | select(.name == "Setup") | .with."cache-mode"' "$RELEASE") \ + == "read-write" ]] \ + || fail "release preflight must populate the machine-owned Cargo Rail cache" +# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. +[[ $(yq eval '.jobs.publish.steps[] | select(.name == "Setup") | .with."cache-url"' "$RELEASE") \ + == '${{ vars.CARGO_RAIL_CACHE_URL }}' \ + && $(yq eval '.jobs.publish.steps[] | select(.name == "Setup") | .with."cache-mode"' "$RELEASE") \ + == "read" ]] \ + || fail "release publication must consume the machine-owned Cargo Rail cache read-only" +if grep -ERn 'uses:[[:space:]]+(Swatinem/rust-cache|runs-on/action|actions/cache)@' \ + "$WORKFLOWS" "$ACTIONS" >/dev/null; then + fail "Cargo Rail must be the only Rust compiler cache owner" +fi +[[ $(yq eval '[.runners[].extras[]? | select(. == "s3-cache")] | length' "$RUNS_ON") == "0" ]] \ + || fail "RunsOn MagicCache must not intercept Cargo Rail compiler results" # shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts. [[ $(yq eval '.jobs."rail-plan".steps[] | select(.name == "Check Release Intent Coverage") | .env.RAIL_BASE_REF' "$CI") \ == '${{ steps.rail.outputs.base-ref }}' ]] \ @@ -356,8 +421,6 @@ fi if grep -Eq '[.]cargo/(bin|[.]crates)|[.]opam' "$SETUP_ACTION"; then fail "CI tool executables and OPAM switches must not be restored from caches" fi -[[ $(yq eval '.runs.steps[] | select(.name == "Setup Rust Cache") | .with."cache-bin"' "$SETUP_ACTION") \ - == "false" ]] || fail "the Rust build cache must exclude Cargo tool executables" if grep -Fq 'export PATH="$HOME/.cargo/bin:$PATH"' "$CI_CHECK" "$HOST_CHECK"; then fail "CI checks must not place unverified runner tools ahead of the authenticated tool root" fi @@ -420,10 +483,6 @@ grep -Fq 'COMPILE_FEATURE_SETS' "$COMPILE_MATRIX" \ || fail "compile feature matrix must consume the shared profile authority" grep -Fq 'EXECUTABLE_FEATURE_SETS' "$EXECUTABLE_MATRIX" \ || fail "executable feature matrix must consume the shared profile authority" -[[ $(yq eval '.jobs.platform-amx.with.cache_key' "$SUITE") == \ - '${{ inputs.cache_key_prefix }}-platform-amx-test-nodebug' ]] \ - || fail "AMX cache identity must track its reduced-debug test profile" - grep -Eq 'HOST_ARGS\+=\(--feature-matrix\)' "$CHECK_ALL" \ || fail "local check-all must retain one explicit feature-matrix execution" @@ -448,19 +507,18 @@ grep -Eq 'HOST_ARGS\+=\(--feature-matrix\)' "$CHECK_ALL" \ identity_step=$(yq eval '.jobs.preflight.steps[] | select(.id == "identity") | .run' "$RELEASE") grep -Fq 'refs/heads/main' <<<"$identity_step" \ || fail "release recovery must reject workflow code outside protected main" -recovery_tool_step=$(yq eval '.jobs.preflight.steps[] | select(.name == "Install recovery SemVer checker") | .run' "$RELEASE") -grep -Fq 'install-tools.sh" semver' <<<"$recovery_tool_step" \ - || fail "release recovery must use the authenticated SemVer tool installer" +recovery_cleanup_step=$(yq eval '.jobs.preflight.steps[] | select(.name == "Remove reviewed recovery tooling") | .run' "$RELEASE") +grep -Fq 'rm -rf target/release-automation' <<<"$recovery_cleanup_step" \ + || fail "release recovery must remove its reviewed tooling checkout before preflight" ct_recovery_step=$(yq eval '.jobs.preflight.steps[] | select(.name == "Verify s390x CT recovery evidence") | .run' "$RELEASE") grep -Fq 'release-ct-recovery-check.sh' <<<"$ct_recovery_step" \ || fail "release recovery must validate replacement s390x CT evidence" grep -Fq -- '--workflow-commit "$WORKFLOW_COMMIT"' <<<"$ct_recovery_step" \ || fail "replacement s390x CT evidence must come from the reviewed workflow commit" -# shellcheck disable=SC2016 # `$crate` is an intentional literal in the release-preflight contract regex. -[[ $(count_matches 'cargo semver-checks --package "\$crate" --all-features' "$RELEASE_PREFLIGHT") -eq 1 ]] \ - || fail "tag preflight must have exactly one final-version SemVer owner" -if grep -ERn 'cargo semver-checks' "$WORKFLOWS" >/dev/null; then - fail "ordinary workflows must leave version-aware SemVer analysis to cargo-rail release planning" +[[ $(yq -oy -p toml eval '.release.semver_check' "$RAIL_CONFIG") == "off" ]] \ + || fail "pre-1.0 Cargo Rail SemVer enforcement must remain explicitly disabled" +if grep -ERn 'cargo[ -]semver-checks' "$WORKFLOWS" "$RELEASE_PREFLIGHT" "$INSTALL_TOOLS" >/dev/null; then + fail "pre-1.0 workflows and installers must not reintroduce SemVer enforcement" fi if grep -ERn 'just check --all|check-all\.sh' "$WORKFLOWS" "$RUN_RUST_JOB" >/dev/null; then diff --git a/scripts/ci/check-worktree-test.sh b/scripts/ci/check-worktree-test.sh index f0ff3788..9f2fc1bc 100755 --- a/scripts/ci/check-worktree-test.sh +++ b/scripts/ci/check-worktree-test.sh @@ -116,7 +116,7 @@ case "${1:-}" in mutate_lockfile fi rm -f "$MOCK_PREFLIGHT_MARKER" - printf '%s\n' '{"schema_version":1,"command":"plan","mode":"inspect","result":"success","exit_code":0,"plan_contract_version":5,"files":[{"path":"src/lib.rs"}],"scope":{"scope_contract_version":3,"resolved_base":"base","resolved_head":"head","mode":"workspace","crates":[],"cargo_args":["--workspace"]},"surfaces":{"bench":{"enabled":false},"build":{"enabled":true},"custom:cargo_graph":{"enabled":true},"docs":{"enabled":false},"infra":{"enabled":false},"test":{"enabled":true}}}' + printf '%s\n' '{"plan_contract_version":7,"inputs":{"snapshot_id":"v1-sha256-test"},"resolution_universe":{"mode":"declared_dependencies","identity":"resolution-universe-v1:sha256:0000000000000000000000000000000000000000000000000000000000000000"},"files":[{"path":"src/lib.rs"}],"scope":{"scope_contract_version":4,"resolved_base":"base","resolved_head":"head","mode":"workspace","crates":[],"cargo_args":["--workspace"]},"surfaces":{"bench":{"enabled":false,"reasons":[],"scope":{"mode":"empty","crates":[],"cargo_args":[]}},"build":{"enabled":true,"reasons":[],"scope":{"mode":"workspace","crates":[],"cargo_args":["--workspace"]}},"custom:cargo_graph":{"enabled":true,"reasons":[],"scope":{"mode":"empty","crates":[],"cargo_args":[]}},"docs":{"enabled":false,"reasons":[],"scope":{"mode":"empty","crates":[],"cargo_args":[]}},"infra":{"enabled":false,"reasons":[],"scope":{"mode":"empty","crates":[],"cargo_args":[]}},"test":{"enabled":true,"reasons":[],"scope":{"mode":"workspace","crates":[],"cargo_args":["--workspace"]}}}}' fi ;; check | clippy | doc | test | build | rustc) diff --git a/scripts/ci/cross-targets.sh b/scripts/ci/cross-targets.sh index 01784b29..a40f5d1a 100755 --- a/scripts/ci/cross-targets.sh +++ b/scripts/ci/cross-targets.sh @@ -13,8 +13,6 @@ source "$SCRIPT_DIR/../lib/common.sh" # shellcheck source=../lib/targets.sh source "$SCRIPT_DIR/../lib/targets.sh" -maybe_disable_sccache - MUSL_TARGETS=() for target in "${LINUX_TARGETS[@]}"; do if [[ "$target" == *-musl ]]; then @@ -35,11 +33,11 @@ for target in "${MUSL_TARGETS[@]}"; do target_dir="target/cross-check/$target" mkdir -p "$target_dir" - RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + CARGO_TARGET_DIR="$target_dir" \ cargo check --locked --target "$target" --no-default-features --lib - RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + CARGO_TARGET_DIR="$target_dir" \ cargo clippy --locked --target "$target" --lib --all-features - RUSTC_WRAPPER="" CARGO_TARGET_DIR="$target_dir" \ + CARGO_TARGET_DIR="$target_dir" \ cargo build --locked --target "$target" --no-default-features --features alloc --lib --release done diff --git a/scripts/ci/emit-manual-matrix-test.sh b/scripts/ci/emit-manual-matrix-test.sh index f4b8bc73..9f39c14f 100755 --- a/scripts/ci/emit-manual-matrix-test.sh +++ b/scripts/ci/emit-manual-matrix-test.sh @@ -14,7 +14,8 @@ jq -e ' and .[0].platform == "rise-riscv" and .[0].target == "riscv64gc-unknown-linux-gnu" and .[0].tools_mode == "none" - and .[0].enable_rust_cache == true + and (.[0] | has("enable_rust_cache") | not) + and (.[0] | has("enable_magic_cache") | not) ' <<<"$matrix" >/dev/null ct_default="$({ diff --git a/scripts/ci/emit-manual-matrix.sh b/scripts/ci/emit-manual-matrix.sh index e0e32a3e..40c0726e 100755 --- a/scripts/ci/emit-manual-matrix.sh +++ b/scripts/ci/emit-manual-matrix.sh @@ -97,31 +97,31 @@ append_ct_row_for_platform() { local runner_uarch="runs-on=${GH_RUN_ID_VAL}/runner=" case "$platform" in amd-zen4) - ROWS+=("{\"platform\":\"amd-zen4\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AMD Zen4\",\"artifact_suffix\":\"amd-zen4\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}amd-zen4\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":true,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"amd-zen4\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AMD Zen4\",\"artifact_suffix\":\"amd-zen4\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}amd-zen4\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; intel-spr) - ROWS+=("{\"platform\":\"intel-spr\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"Intel Sapphire Rapids\",\"artifact_suffix\":\"intel-spr\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}intel-spr\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":true,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"intel-spr\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"Intel Sapphire Rapids\",\"artifact_suffix\":\"intel-spr\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}intel-spr\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; intel-icl) - ROWS+=("{\"platform\":\"intel-icl\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"Intel Ice Lake\",\"artifact_suffix\":\"intel-icl\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}intel-icl\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":true,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"intel-icl\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"Intel Ice Lake\",\"artifact_suffix\":\"intel-icl\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}intel-icl\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; amd-zen5) - ROWS+=("{\"platform\":\"amd-zen5\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AMD Zen5\",\"artifact_suffix\":\"amd-zen5\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}amd-zen5\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":true,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"amd-zen5\",\"target\":\"x86_64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AMD Zen5\",\"artifact_suffix\":\"amd-zen5\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}amd-zen5\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; graviton3) - ROWS+=("{\"platform\":\"graviton3\",\"target\":\"aarch64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AWS Graviton3\",\"artifact_suffix\":\"graviton3\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}graviton3\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":true,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"graviton3\",\"target\":\"aarch64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AWS Graviton3\",\"artifact_suffix\":\"graviton3\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}graviton3\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; graviton4) - ROWS+=("{\"platform\":\"graviton4\",\"target\":\"aarch64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AWS Graviton4\",\"artifact_suffix\":\"graviton4\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}graviton4\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":true,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"graviton4\",\"target\":\"aarch64-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"AWS Graviton4\",\"artifact_suffix\":\"graviton4\",\"timeout_minutes\":${CT_RUNSON_TIMEOUT_MINUTES},\"runner\":\"${runner_uarch}graviton4\",\"tools_mode\":\"ct-linux\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; ibm-s390x) - ROWS+=("{\"platform\":\"ibm-s390x\",\"target\":\"s390x-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"IBM Z s390x\",\"artifact_suffix\":\"ibm-s390x\",\"timeout_minutes\":${CT_IBM_TIMEOUT_MINUTES},\"runner\":\"ubuntu-24.04-s390x\",\"tools_mode\":\"ibm\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":false,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"ibm-s390x\",\"target\":\"s390x-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"IBM Z s390x\",\"artifact_suffix\":\"ibm-s390x\",\"timeout_minutes\":${CT_IBM_TIMEOUT_MINUTES},\"runner\":\"ubuntu-24.04-s390x\",\"tools_mode\":\"ibm\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; ibm-power10) - ROWS+=("{\"platform\":\"ibm-power10\",\"target\":\"powerpc64le-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"IBM POWER10 ppc64le\",\"artifact_suffix\":\"ibm-power10\",\"timeout_minutes\":${CT_IBM_TIMEOUT_MINUTES},\"runner\":\"ubuntu-24.04-ppc64le-p10\",\"tools_mode\":\"ibm\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":false,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"ibm-power10\",\"target\":\"powerpc64le-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"IBM POWER10 ppc64le\",\"artifact_suffix\":\"ibm-power10\",\"timeout_minutes\":${CT_IBM_TIMEOUT_MINUTES},\"runner\":\"ubuntu-24.04-ppc64le-p10\",\"tools_mode\":\"ibm\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; rise-riscv) - ROWS+=("{\"platform\":\"rise-riscv\",\"target\":\"riscv64gc-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"RISE RISC-V riscv64\",\"artifact_suffix\":\"rise-riscv\",\"timeout_minutes\":${CT_RISCV_TIMEOUT_MINUTES},\"runner\":\"ubuntu-24.04-riscv\",\"tools_mode\":\"none\",\"toolchain_components\":\"${COMPONENTS_CT}\",\"enable_magic_cache\":false,\"enable_rust_cache\":true}") + ROWS+=("{\"platform\":\"rise-riscv\",\"target\":\"riscv64gc-unknown-linux-gnu\",\"os\":\"linux\",\"display_name\":\"RISE RISC-V riscv64\",\"artifact_suffix\":\"rise-riscv\",\"timeout_minutes\":${CT_RISCV_TIMEOUT_MINUTES},\"runner\":\"ubuntu-24.04-riscv\",\"tools_mode\":\"none\",\"toolchain_components\":\"${COMPONENTS_CT}\"}") ;; *) echo "error: unsupported CT platform '$platform'" >&2 diff --git a/scripts/ci/install-tools.sh b/scripts/ci/install-tools.sh index 174074c9..55e9a65d 100755 --- a/scripts/ci/install-tools.sh +++ b/scripts/ci/install-tools.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Install CI tools through authenticated package-manager boundaries. -# Usage: install-tools.sh [standard|quality|release|semver|rail|ci|supply-chain|bench|structural-bench|profile|ibm|fuzz|coverage|ct-linux|minimal|none] +# Usage: install-tools.sh [standard|quality|release|rail|ci|supply-chain|bench|structural-bench|profile|ibm|fuzz|coverage|ct-linux|minimal|none] set -euo pipefail @@ -9,8 +9,7 @@ MODE=${1:-standard} CARGO_NEXTEST_VERSION=0.9.143 CARGO_DENY_VERSION=0.20.2 CARGO_AUDIT_VERSION=0.22.2 -CARGO_RAIL_VERSION=0.21.0 -CARGO_SEMVER_CHECKS_VERSION=0.50.0 +CARGO_RAIL_VERSION=0.22.2 JUST_VERSION=1.58.0 ZIZMOR_VERSION=1.29.0 CARGO_CRITERION_VERSION=1.1.0 @@ -297,7 +296,6 @@ case "$MODE" in install_cargo_tool cargo-deny "$CARGO_DENY_VERSION" install_cargo_tool cargo-audit "$CARGO_AUDIT_VERSION" install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION" - install_cargo_tool cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION" install_cargo_tool just "$JUST_VERSION" ;; quality) @@ -307,13 +305,9 @@ case "$MODE" in ;; release) install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION" - install_cargo_tool cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION" install_cargo_tool cargo-deny "$CARGO_DENY_VERSION" install_cargo_tool cargo-audit "$CARGO_AUDIT_VERSION" ;; - semver) - install_cargo_tool cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION" - ;; rail) install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION" ;; @@ -367,7 +361,7 @@ case "$MODE" in ;; *) echo "Unknown mode: $MODE" >&2 - echo "Usage: install-tools.sh [standard|quality|release|semver|rail|ci|supply-chain|bench|structural-bench|profile|ibm|fuzz|coverage|ct-linux|minimal|none]" >&2 + echo "Usage: install-tools.sh [standard|quality|release|rail|ci|supply-chain|bench|structural-bench|profile|ibm|fuzz|coverage|ct-linux|minimal|none]" >&2 exit 2 ;; esac diff --git a/scripts/ci/mlkem-aarch64-gate.sh b/scripts/ci/mlkem-aarch64-gate.sh index 20f6ed36..113d1a0b 100755 --- a/scripts/ci/mlkem-aarch64-gate.sh +++ b/scripts/ci/mlkem-aarch64-gate.sh @@ -8,8 +8,6 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" source "$SCRIPT_DIR/../lib/common.sh" cd "$REPO_ROOT" -maybe_disable_sccache - ARTIFACT_DIR="${MLKEM_AARCH64_GATE_ARTIFACT_DIR:-mlkem-aarch64-gate}" LOG_PATH="$ARTIFACT_DIR/output.txt" mkdir -p "$ARTIFACT_DIR" diff --git a/scripts/ci/native-check.sh b/scripts/ci/native-check.sh index dba8f39a..7e2d26ae 100755 --- a/scripts/ci/native-check.sh +++ b/scripts/ci/native-check.sh @@ -16,7 +16,6 @@ for arg in "$@"; do esac done -maybe_disable_sccache apply_ci_resource_profile echo "Native validation: $(rustc -vV | sed -n 's/^host: //p')" diff --git a/scripts/ci/pre-push-test.sh b/scripts/ci/pre-push-test.sh index 931918a4..2be26d17 100755 --- a/scripts/ci/pre-push-test.sh +++ b/scripts/ci/pre-push-test.sh @@ -42,10 +42,43 @@ printf 'just %s\n' "$*" >>"$MOCK_LOG" SH chmod +x "$fake_bin/just" -workspace_scope='{"scope_contract_version":2,"resolved_base":"base","resolved_head":"head","mode":"workspace","crates":[],"cargo_args":["--workspace"],"surfaces":{"bench":false,"build":true,"custom:cargo_graph":true,"docs":false,"infra":false,"test":true}}' -empty_scope='{"scope_contract_version":2,"resolved_base":"base","resolved_head":"head","mode":"empty","crates":[],"cargo_args":[],"surfaces":{"bench":false,"build":false,"custom:cargo_graph":false,"docs":false,"infra":false,"test":false}}' -plan="$(jq -cn --argjson scope "$workspace_scope" '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[{path:"Cargo.toml"}],scope:$scope}')" -empty_plan="$(jq -cn --argjson scope "$empty_scope" '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[],scope:$scope}')" +make_plan() { + local scope=$1 + local files=${2:-'[]'} + + jq -cn \ + --argjson scope "$scope" \ + --argjson files "$files" \ + '{ + plan_contract_version: 7, + inputs: {snapshot_id: "v1-sha256-test"}, + resolution_universe: { + mode: "declared_dependencies", + identity: "resolution-universe-v1:sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + files: $files, + scope: ($scope | del(.surfaces)), + surfaces: ( + $scope.surfaces + | with_entries(.value = { + enabled: .value, + reasons: [], + scope: ( + if .value then + ($scope | del(.scope_contract_version, .resolved_base, .resolved_head, .surfaces)) + else + {mode: "empty", crates: [], cargo_args: []} + end + ) + }) + ) + }' +} + +workspace_scope='{"scope_contract_version":4,"resolved_base":"base","resolved_head":"head","mode":"workspace","crates":[],"cargo_args":["--workspace"],"surfaces":{"bench":false,"build":true,"custom:cargo_graph":true,"docs":false,"infra":false,"test":true}}' +empty_scope='{"scope_contract_version":4,"resolved_base":"base","resolved_head":"head","mode":"empty","crates":[],"cargo_args":[],"surfaces":{"bench":false,"build":false,"custom:cargo_graph":false,"docs":false,"infra":false,"test":false}}' +plan="$(make_plan "$workspace_scope" '[{"path":"Cargo.toml"}]')" +empty_plan="$(make_plan "$empty_scope")" normal_output="$TMP_ROOT/normal.out" if ( @@ -141,8 +174,7 @@ run_pre_push_case missing-scope "$(jq -c 'del(.scope)' <<<"$plan")" '' 0 true run_pre_push_case valid-empty "$empty_plan" '' 0 false installer_scope=$(jq -c '.surfaces.infra = true' <<<"$empty_scope") -installer_plan=$(jq -cn --argjson scope "$installer_scope" \ - '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[{path:"scripts/ci/install-tools.sh"}],scope:$scope}') +installer_plan=$(make_plan "$installer_scope" '[{"path":"scripts/ci/install-tools.sh"}]') : >"$mock_log" if ! ( cd "$fixture" @@ -164,8 +196,7 @@ grep -Fq 'just check-actions' "$mock_log" || { exit 1 } -recovery_plan=$(jq -cn --argjson scope "$installer_scope" \ - '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[{path:"scripts/ci/release-ct-recovery-check.sh"}],scope:$scope}') +recovery_plan=$(make_plan "$installer_scope" '[{"path":"scripts/ci/release-ct-recovery-check.sh"}]') : >"$mock_log" if ! ( cd "$fixture" @@ -211,7 +242,8 @@ git -C "$plan_fixture" commit --quiet -m "edit script documentation" docs_plan=$(cd "$plan_fixture" && cargo rail plan --from HEAD~1 --to HEAD --json) jq -e ' - .result == "success" + .plan_contract_version == 7 + and .scope.scope_contract_version == 4 and (.files | length == 1) and .files[0].path == "scripts/README.md" and .files[0].kind == "docs" @@ -229,7 +261,8 @@ git -C "$plan_fixture" commit --quiet -m "add script" script_plan=$(cd "$plan_fixture" && cargo rail plan --from HEAD~1 --to HEAD --json) jq -e ' - .result == "success" + .plan_contract_version == 7 + and .scope.scope_contract_version == 4 and (.files | length == 1) and .files[0].path == "scripts/check.sh" and .files[0].kind == "script" diff --git a/scripts/ci/release-identity-test.sh b/scripts/ci/release-identity-test.sh index aa43fcab..26eaf513 100755 --- a/scripts/ci/release-identity-test.sh +++ b/scripts/ci/release-identity-test.sh @@ -26,6 +26,11 @@ version = 4 EOF cp "$REPO_ROOT/rust-toolchain.toml" "$fixture/rust-toolchain.toml" cp "$REPO_ROOT/.github/workflows/release.yaml" "$fixture/.github/workflows/release.yaml" +toolchain_channel=$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' "$fixture/rust-toolchain.toml") +[[ -n "$toolchain_channel" ]] || { + echo "release identity fixture could not resolve the toolchain channel" >&2 + exit 1 +} git -C "$fixture" add . git -C "$fixture" commit -qm "release fixture" commit=$(git -C "$fixture" rev-parse HEAD) @@ -88,7 +93,7 @@ write_manifest() { github_output="$TMP_ROOT/github-output" GITHUB_OUTPUT="$github_output" write_manifest >/dev/null manifest="$artifacts/rscrypto-1.2.3-release-manifest.json" -jq -e --arg commit "$commit" ' +jq -e --arg commit "$commit" --arg toolchain_channel "$toolchain_channel" ' .schema_version == 1 and .kind == "rscrypto.release-manifest" and .crate_version == "1.2.3" @@ -96,7 +101,7 @@ jq -e --arg commit "$commit" ' and (.release.tag_object | test("^[0-9a-f]{40}$")) and .release.git_commit == $commit and (.release.git_tree | test("^[0-9a-f]{40}$")) - and .toolchain.channel == "1.97.1" + and .toolchain.channel == $toolchain_channel and (.toolchain.manifest.sha256 | test("^[0-9a-f]{64}$")) and .evidence.git_commit == $commit and .evidence.mode == "exact_commit" diff --git a/scripts/ci/release-preflight.sh b/scripts/ci/release-preflight.sh index 7bc2b3e1..ca8ecf2e 100755 --- a/scripts/ci/release-preflight.sh +++ b/scripts/ci/release-preflight.sh @@ -115,7 +115,6 @@ cargo deny --locked check all # verification is implemented in `src/auth/rsa.rs`; keep this scoped to the # known Marvin advisory until the oracle dependency is removed or fixed. cargo audit --ignore RUSTSEC-2023-0071 -cargo semver-checks --package "$crate" --all-features # `cargo rail release check` is a pre-tag gate. The release run consumes # `.changes` files before creating the signed tag, so tag preflight diff --git a/scripts/ci/tool-integrity-test.sh b/scripts/ci/tool-integrity-test.sh index 3137d83c..a0ce47b0 100755 --- a/scripts/ci/tool-integrity-test.sh +++ b/scripts/ci/tool-integrity-test.sh @@ -386,7 +386,7 @@ package_temp="$TMP_ROOT/package-temp" mkdir -p "$package_home/.cargo/bin" mkdir -p "$package_temp" : >"$package_log" -for mode in standard quality release semver rail ci supply-chain ibm bench structural-bench profile fuzz coverage minimal none; do +for mode in standard quality release rail ci supply-chain ibm bench structural-bench profile fuzz coverage minimal none; do HOME="$package_home" \ RUNNER_TEMP="$package_temp" \ PATH="$package_bin:$PATH" \ @@ -583,8 +583,7 @@ for contract in \ 'cargo-nextest =0.9.143' \ 'cargo-deny =0.20.2' \ 'cargo-audit =0.22.2' \ - 'cargo-rail =0.21.0' \ - 'cargo-semver-checks =0.50.0' \ + 'cargo-rail =0.22.2' \ 'just =1.58.0' \ 'zizmor =1.29.0' \ 'cargo-criterion =1.1.0' \ diff --git a/scripts/ct/asm_heuristics.py b/scripts/ct/asm_heuristics.py index 07c6510a..1a6e0922 100755 --- a/scripts/ct/asm_heuristics.py +++ b/scripts/ct/asm_heuristics.py @@ -675,6 +675,19 @@ def is_ecdsa_scope(primitive_ids: list[str]) -> bool: return any(primitive_id.startswith("signature.ecdsa_") for primitive_id in primitive_ids) +def is_rsa_inverse_scope(primitive_ids: list[str], symbol: str) -> bool: + if "rsa.private_ops" not in primitive_ids: + return False + return any( + fragment in symbol + for fragment in ( + "diag_rsa_blinding_factor_inverse_with_scratch", + "private_modular_inverse_odd", + "private_i31_", + ) + ) + + def is_s390x_division(target: str, inst: str) -> bool: return target.startswith("s390x-") and inst in S390X_DIV_MNEMONICS @@ -814,7 +827,7 @@ def scan_symbol( ) ) elif ( - is_ecdsa_scope(primitive_ids) + (is_ecdsa_scope(primitive_ids) or is_rsa_inverse_scope(primitive_ids, symbol)) and (is_s390x_scalar_multiply(target, inst) or is_riscv_scalar_multiply(target, inst)) ): findings.append( @@ -825,7 +838,7 @@ def scan_symbol( line, "variable_latency_multiply", "fail", - "Scalar multiply is forbidden in s390x/RISC-V ECDSA secret arithmetic; use the fixed-work limb multiplier.", + "Scalar multiply is forbidden in this s390x/RISC-V secret-arithmetic scope; use the fixed-work limb multiplier.", scope=scope, primitive_ids=primitive_ids, roots=roots, diff --git a/scripts/ct/asm_heuristics_test.py b/scripts/ct/asm_heuristics_test.py index 3cee2469..d2680b97 100755 --- a/scripts/ct/asm_heuristics_test.py +++ b/scripts/ct/asm_heuristics_test.py @@ -3,7 +3,14 @@ from pathlib import Path -from asm_heuristics import FunctionBody, all_direct_callees, 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, + is_rsa_inverse_scope, +) def main() -> None: @@ -11,6 +18,18 @@ def main() -> None: assert is_call_relocation(relocation) assert is_riscv_conditional_branch("riscv64gc-unknown-linux-gnu", "bnez") assert not is_riscv_conditional_branch("x86_64-unknown-linux-gnu", "bnez") + assert is_rsa_inverse_scope( + ["rsa.private_ops"], + "rscrypto::auth::rsa::private_i31_co_reduce_mod", + ) + assert not is_rsa_inverse_scope( + ["rsa.private_ops"], + "rscrypto::auth::rsa::mont_mul_cios_portable", + ) + assert not is_rsa_inverse_scope( + ["rsa.private_key_material"], + "rscrypto::auth::rsa::private_i31_co_reduce_mod", + ) callee = "rscrypto::auth::ecdsa::sign_digest_p256_blinded" root = FunctionBody( diff --git a/scripts/ct/dudect_report.py b/scripts/ct/dudect_report.py index 88b9b9fe..b6e687af 100755 --- a/scripts/ct/dudect_report.py +++ b/scripts/ct/dudect_report.py @@ -368,6 +368,18 @@ "primitive": "rsa.private_ops", "left_class": "valid RSA-2048 key with 128-byte canonical CRT exponents", "right_class": "valid same-factor-width RSA-2048 key with a 127-byte canonical dQ", + "gate": "diagnostic", + "reason": "The unrelated valid keys have different public moduli, which may leak by policy; this comparison cannot attribute timing separation to canonical CRT exponent width.", + }, + "rsa_private_exponent_fixed_width_high_byte": { + "primitive": "rsa.private_ops", + "left_class": "fixed-width exponent with a nonzero high byte", + "right_class": "same fixed-width exponent with a zero high byte", + }, + "rsa_blinding_inverse_fixed_vs_random_factor": { + "primitive": "rsa.private_ops", + "left_class": "fixed valid blinding factor", + "right_class": "random valid same-width blinding factor", }, "rsa_oaep_decrypt_fixed_vs_random_plaintext": { "primitive": "rsa.private_ops", diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index e3b5ce49..9e654e87 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -118,15 +118,6 @@ get_crate_flags() { esac } -maybe_disable_sccache() { - if [[ -n "${RUSTC_WRAPPER:-}" && "${RUSTC_WRAPPER##*/}" == "sccache" ]]; then - if ! "$RUSTC_WRAPPER" rustc -vV >/dev/null 2>&1; then - echo "WARNING: sccache is configured but not usable; disabling RUSTC_WRAPPER for this run." - export RUSTC_WRAPPER= - fi - fi -} - apply_ci_resource_profile() { case "${RSCRYPTO_CI_RESOURCE_PROFILE:-}" in "" | default) diff --git a/scripts/lib/rail-plan.sh b/scripts/lib/rail-plan.sh index c9e9418d..9ca3dc2a 100644 --- a/scripts/lib/rail-plan.sh +++ b/scripts/lib/rail-plan.sh @@ -52,16 +52,6 @@ _rail_valid_scope_json() { and has("test") and all(.[]; type == "boolean"); - def valid_surface_records: - type == "object" - and has("bench") - and has("build") - and has("custom:cargo_graph") - and has("docs") - and has("infra") - and has("test") - and all(.[]; type == "object" and (.enabled | type == "boolean")); - def valid_crate: type == "string" and test("^[A-Za-z0-9][A-Za-z0-9_-]*$"); @@ -74,15 +64,11 @@ _rail_valid_scope_json() { [.crates[] | "-p", .] end; - def valid_scope: + def valid_package_scope: type == "object" - and (.scope_contract_version == 2 or .scope_contract_version == 3) - and (.resolved_base | type == "string" and length > 0) - and (.resolved_head | type == "string" and length > 0) and (.mode == "empty" or .mode == "workspace" or .mode == "crates") and (.crates | type == "array") and (.cargo_args | type == "array" and all(.[]; type == "string")) - and (.surfaces | valid_surfaces) and ( if .mode == "crates" then (.crates @@ -93,7 +79,27 @@ _rail_valid_scope_json() { (.crates | length == 0) end ) - and (.cargo_args == expected_cargo_args) + and (.cargo_args == expected_cargo_args); + + def valid_surface_records: + type == "object" + and has("bench") + and has("build") + and has("custom:cargo_graph") + and has("docs") + and has("infra") + and has("test") + and all(.[].enabled; type == "boolean") + and all(.[].reasons; type == "array" and all(.[]; type == "number" and floor == .)) + and all(.[].scope; valid_package_scope); + + def valid_scope: + type == "object" + and .scope_contract_version == 4 + and (.resolved_base | type == "string" and length > 0) + and (.resolved_head | type == "string" and length > 0) + and valid_package_scope + and (.surfaces | valid_surfaces) and ( if .mode == "empty" then (.surfaces.bench == false and .surfaces.build == false and .surfaces.test == false) @@ -105,23 +111,16 @@ _rail_valid_scope_json() { if $document_kind == "plan" then select( type == "object" - and .schema_version == 1 - and .command == "plan" - and .mode == "inspect" - and .result == "success" - and .exit_code == 0 + and .plan_contract_version == 7 + and .scope.scope_contract_version == 4 + and (.inputs.snapshot_id | type == "string" and length > 0) + and .resolution_universe.mode == "declared_dependencies" and ( - ( - .plan_contract_version == 3 - and .scope.scope_contract_version == 2 - and (.scope.surfaces | valid_surfaces) - ) - or ( - .plan_contract_version == 5 - and .scope.scope_contract_version == 3 - and (.surfaces | valid_surface_records) - ) + .resolution_universe.identity + | type == "string" + and test("^resolution-universe-v1:sha256:[0-9a-f]{64}$") ) + and (.surfaces | valid_surface_records) and (.files | type == "array") and all(.files[]; type == "object" @@ -130,16 +129,12 @@ _rail_valid_scope_json() { and length > 0 and (explode | all(. >= 32 and . != 127)))) ) - | if .plan_contract_version == 5 then - .scope + { - surfaces: ( - .surfaces - | with_entries(.value = .value.enabled) - ) - } - else - .scope - end + | .scope + { + surfaces: ( + .surfaces + | with_entries(.value = .value.enabled) + ) + } | select(valid_scope) elif $document_kind == "scope" then select(valid_scope) diff --git a/scripts/test/test-coverage.sh b/scripts/test/test-coverage.sh index b00d513e..a5fa9b1a 100755 --- a/scripts/test/test-coverage.sh +++ b/scripts/test/test-coverage.sh @@ -37,7 +37,6 @@ case "${1:-}" in ;; esac -maybe_disable_sccache apply_ci_resource_profile export RSCRYPTO_TEST_MODE=${RSCRYPTO_TEST_MODE:-${CARGO_RAIL_TEST_MODE:-local}} diff --git a/scripts/test/test-feature-matrix.sh b/scripts/test/test-feature-matrix.sh index abfba862..2f1e6edf 100755 --- a/scripts/test/test-feature-matrix.sh +++ b/scripts/test/test-feature-matrix.sh @@ -8,8 +8,6 @@ source "$SCRIPT_DIR/../lib/common.sh" # shellcheck source=../lib/feature-profiles.sh source "$SCRIPT_DIR/../lib/feature-profiles.sh" -maybe_disable_sccache - LOG_DIR=$(mktemp -d) TARGET_DIR="$REPO_ROOT/target/feature-matrix" diff --git a/scripts/test/test-fuzz-asan.sh b/scripts/test/test-fuzz-asan.sh index 5f786ef6..64ad9886 100755 --- a/scripts/test/test-fuzz-asan.sh +++ b/scripts/test/test-fuzz-asan.sh @@ -10,7 +10,6 @@ source "$SCRIPT_DIR/../lib/common.sh" # shellcheck source=../lib/fuzz-packages.sh source "$SCRIPT_DIR/../lib/fuzz-packages.sh" -maybe_disable_sccache activate_nightly_toolchain PACKAGE_SCOPE="full" diff --git a/scripts/test/test-fuzz.sh b/scripts/test/test-fuzz.sh index fab0dded..8cb51546 100755 --- a/scripts/test/test-fuzz.sh +++ b/scripts/test/test-fuzz.sh @@ -28,7 +28,6 @@ source "$SCRIPT_DIR/../lib/common.sh" # shellcheck source=../lib/fuzz-packages.sh source "$SCRIPT_DIR/../lib/fuzz-packages.sh" -maybe_disable_sccache activate_nightly_toolchain # Configuration (can be overridden via environment) diff --git a/scripts/test/test-miri.sh b/scripts/test/test-miri.sh index a2619b37..cc055040 100755 --- a/scripts/test/test-miri.sh +++ b/scripts/test/test-miri.sh @@ -33,10 +33,8 @@ echo "Running Memory Safety Tests via Miri..." echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -maybe_disable_sccache activate_nightly_toolchain -unset RUSTC_WRAPPER -unset CARGO_BUILD_RUSTC_WRAPPER +export CARGO_RAIL_CACHE=off # Miri cannot execute SIMD/CLMUL kernels directly. Force CRC families onto their # portable tiers so the lane spends time validating real pointer/length logic diff --git a/scripts/test/test-rsa-leakage.sh b/scripts/test/test-rsa-leakage.sh index 27294421..8226b5fb 100755 --- a/scripts/test/test-rsa-leakage.sh +++ b/scripts/test/test-rsa-leakage.sh @@ -10,8 +10,6 @@ echo "Running RSA first-order timing leakage gate..." echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -maybe_disable_sccache - export RSCRYPTO_RSA_LEAKAGE_SAMPLES="${RSCRYPTO_RSA_LEAKAGE_SAMPLES:-2000}" export RSCRYPTO_RSA_LEAKAGE_T_THRESHOLD="${RSCRYPTO_RSA_LEAKAGE_T_THRESHOLD:-8.0}" export RSCRYPTO_RSA_LEAKAGE_WARMUP="${RSCRYPTO_RSA_LEAKAGE_WARMUP:-64}" diff --git a/scripts/test/test.sh b/scripts/test/test.sh index 593ad75f..80804ac8 100755 --- a/scripts/test/test.sh +++ b/scripts/test/test.sh @@ -5,7 +5,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=../lib/common.sh source "$SCRIPT_DIR/../lib/common.sh" -maybe_disable_sccache apply_ci_resource_profile echo "Running Unit, Integration, and Property Tests via Nextest..." diff --git a/src/aead/chacha20/wasm32_simd128.rs b/src/aead/chacha20/wasm32_simd128.rs index aa853f1b..2ddd47ec 100644 --- a/src/aead/chacha20/wasm32_simd128.rs +++ b/src/aead/chacha20/wasm32_simd128.rs @@ -31,8 +31,8 @@ pub(super) unsafe fn xor_keystream( #[target_feature(enable = "simd128")] unsafe fn xor_keystream_impl(key: &[u8; KEY_SIZE], initial_counter: u32, nonce: &[u8; NONCE_SIZE], buffer: &mut [u8]) { let mut counter = initial_counter; - let mut batches = buffer.chunks_exact_mut(BLOCK_SIZE * BLOCKS_PER_BATCH); - for chunk in &mut batches { + let (batches, remainder) = buffer.as_chunks_mut::<{ BLOCK_SIZE * BLOCKS_PER_BATCH }>(); + for chunk in batches { debug_assert!(counter.checked_add(3).is_some()); let mut x0 = u32x4_splat(0x6170_7865); @@ -143,7 +143,6 @@ unsafe fn xor_keystream_impl(key: &[u8; KEY_SIZE], initial_counter: u32, nonce: counter = counter.wrapping_add(4); } - let remainder = batches.into_remainder(); if !remainder.is_empty() { xor_keystream_portable(key, counter, nonce, remainder); } diff --git a/src/aead/ghash.rs b/src/aead/ghash.rs index 55470c44..4a2d9cde 100644 --- a/src/aead/ghash.rs +++ b/src/aead/ghash.rs @@ -171,13 +171,10 @@ mod tests { // Manual: split into 16-byte blocks, pad last one. let mut manual = Ghash::new(&TEST_H); - let mut chunks = data.chunks_exact(BLOCK_SIZE); - for chunk in chunks.by_ref() { - let mut block = [0u8; BLOCK_SIZE]; - block.copy_from_slice(chunk); - manual.update_block(&block); + let (chunks, remainder) = data.as_chunks::(); + for block in chunks { + manual.update_block(block); } - let remainder = chunks.remainder(); if !remainder.is_empty() { let mut block = [0u8; BLOCK_SIZE]; block[..remainder.len()].copy_from_slice(remainder); diff --git a/src/aead/mod.rs b/src/aead/mod.rs index 366faeee..e4e30c22 100644 --- a/src/aead/mod.rs +++ b/src/aead/mod.rs @@ -118,7 +118,7 @@ mod test_vectors { #[track_caller] pub(super) fn hex_vec(hex: &str) -> Vec { - let mut out = vec![0u8; hex.as_bytes().chunks_exact(2).len()]; + let mut out = vec![0u8; hex.as_bytes().as_chunks::<2>().0.len()]; crate::hex::from_hex(hex, &mut out).expect("AEAD test vector must contain valid hexadecimal"); out } diff --git a/src/aead/polyval.rs b/src/aead/polyval.rs index cefe894b..50c82615 100644 --- a/src/aead/polyval.rs +++ b/src/aead/polyval.rs @@ -2361,13 +2361,10 @@ mod tests { // Manual: split into 16-byte blocks, pad last one. let mut manual = Polyval::new(&RFC_H); - let mut chunks = data.chunks_exact(BLOCK_SIZE); - for chunk in chunks.by_ref() { - let mut block = [0u8; BLOCK_SIZE]; - block.copy_from_slice(chunk); - manual.update_block(&block); + let (chunks, remainder) = data.as_chunks::(); + for block in chunks { + manual.update_block(block); } - let remainder = chunks.remainder(); if !remainder.is_empty() { let mut block = [0u8; BLOCK_SIZE]; block[..remainder.len()].copy_from_slice(remainder); diff --git a/src/auth/ed25519/aarch64_asm.rs b/src/auth/ed25519/aarch64_asm.rs index e35a2378..3e4b50c2 100644 --- a/src/auth/ed25519/aarch64_asm.rs +++ b/src/auth/ed25519/aarch64_asm.rs @@ -103,7 +103,7 @@ pub(super) fn double_scalar_basepoint_encoded( #[inline] fn words_from_le_bytes(bytes: &[u8; SECRET_KEY_LENGTH]) -> [u64; FIELD_LIMBS] { let mut words = [0u64; FIELD_LIMBS]; - for (word, chunk) in words.iter_mut().zip(bytes.chunks_exact(8)) { + for (word, chunk) in words.iter_mut().zip(bytes.as_chunks::<8>().0) { let mut limb = [0u8; 8]; limb.copy_from_slice(chunk); *word = u64::from_le_bytes(limb); @@ -116,7 +116,9 @@ fn encode_affine_point(point: &[u64; AFFINE_POINT_LIMBS]) -> [u8; PUBLIC_KEY_LEN let mut encoded = [0u8; PUBLIC_KEY_LENGTH]; for (dst, word) in encoded - .chunks_exact_mut(8) + .as_chunks_mut::<8>() + .0 + .iter_mut() .zip(point[FIELD_LIMBS..AFFINE_POINT_LIMBS].iter().copied()) { dst.copy_from_slice(&word.to_le_bytes()); diff --git a/src/auth/ed25519/x86_64_asm.rs b/src/auth/ed25519/x86_64_asm.rs index c0ac29d1..8df0bc75 100644 --- a/src/auth/ed25519/x86_64_asm.rs +++ b/src/auth/ed25519/x86_64_asm.rs @@ -79,7 +79,7 @@ fn basepoint_mul_affine(s: &[u8; SECRET_KEY_LENGTH]) -> [u64; AFFINE_POINT_LIMBS #[inline] fn words_from_le_bytes(bytes: &[u8; SECRET_KEY_LENGTH]) -> [u64; FIELD_LIMBS] { let mut words = [0u64; FIELD_LIMBS]; - for (word, chunk) in words.iter_mut().zip(bytes.chunks_exact(8)) { + for (word, chunk) in words.iter_mut().zip(bytes.as_chunks::<8>().0) { let mut limb = [0u8; 8]; limb.copy_from_slice(chunk); *word = u64::from_le_bytes(limb); @@ -92,7 +92,9 @@ fn encode_affine_point(point: &[u64; AFFINE_POINT_LIMBS]) -> [u8; PUBLIC_KEY_LEN let mut encoded = [0u8; PUBLIC_KEY_LENGTH]; for (dst, word) in encoded - .chunks_exact_mut(8) + .as_chunks_mut::<8>() + .0 + .iter_mut() .zip(point[FIELD_LIMBS..AFFINE_POINT_LIMBS].iter().copied()) { dst.copy_from_slice(&word.to_le_bytes()); @@ -113,7 +115,7 @@ fn extended_point_from_affine(words: &[u64; AFFINE_POINT_LIMBS]) -> point::Exten #[inline] fn field_element_from_words(words: &[u64]) -> field::FieldElement { let mut bytes = [0u8; PUBLIC_KEY_LENGTH]; - for (dst, word) in bytes.chunks_exact_mut(8).zip(words.iter().copied()) { + for (dst, word) in bytes.as_chunks_mut::<8>().0.iter_mut().zip(words.iter().copied()) { dst.copy_from_slice(&word.to_le_bytes()); } field::FieldElement::from_bytes(&bytes).expect("Ed25519 assembly must return canonical affine coordinates") diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 597d6945..10064e1a 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -322,10 +322,10 @@ pub use rsa::{ }; #[cfg(all(feature = "rsa", feature = "diag"))] pub use rsa::{ - diag_rsa_import_pkcs8_private_key_der_stage, diag_rsa_private_component_validation_32, - diag_rsa_private_exponentiate_fixed_width, diag_rsa_private_exponentiate_fixed_width_with_scratch, - diag_rsa_private_select_window_power_4, diag_rsa_validate_pkcs8_private_key_der, - diag_rsa_validate_pkcs8_private_key_der_stage, + diag_rsa_blinding_factor_inverse_with_scratch, diag_rsa_import_pkcs8_private_key_der_stage, + diag_rsa_private_component_validation_32, diag_rsa_private_exponentiate_fixed_width, + diag_rsa_private_exponentiate_fixed_width_with_scratch, diag_rsa_private_select_window_power_4, + diag_rsa_validate_pkcs8_private_key_der, diag_rsa_validate_pkcs8_private_key_der_stage, }; #[cfg(feature = "scrypt")] pub use scrypt::{Scrypt, ScryptError, ScryptParams}; diff --git a/src/auth/rsa.rs b/src/auth/rsa.rs index 055b3ce7..0fd13a3d 100644 --- a/src/auth/rsa.rs +++ b/src/auth/rsa.rs @@ -39,8 +39,9 @@ //! RSA signing, OAEP encryption/decryption, legacy RSAES-PKCS1-v1_5 //! encryption/decryption, private-key import, and private-key generation are //! available through [`RsaPrivateKey`] and [`RsaPublicKey`]. Private operations -//! are blinded and fault-checked; RNG-backed APIs require the `getrandom` -//! feature. +//! are blinded and fault-checked. Callers can provide signing entropy with the +//! `rsa` feature alone; OS-backed convenience APIs and key generation require +//! the `getrandom` feature. use alloc::{boxed::Box, vec, vec::Vec}; use core::{ @@ -289,7 +290,7 @@ pub enum RsaEncryptionError { InvalidLength, /// The message is too long for this key and encryption profile. MessageTooLong, - /// The platform entropy source was unavailable. + /// The caller-provided or platform entropy source was unavailable. EntropyUnavailable, /// The underlying RSA public operation failed. PublicOperationFailed, @@ -362,7 +363,7 @@ pub enum RsaPrivateOpError { RepresentativeOutOfRange, /// The supplied blinding factor and inverse are invalid for this modulus. InvalidBlindingFactor, - /// The platform entropy source was unavailable. + /// The caller-provided or platform entropy source was unavailable. EntropyUnavailable, /// RSA decryption padding was invalid. DecryptionFailed, @@ -1175,6 +1176,90 @@ pub fn diag_rsa_blinding_factor_inverse( key.components.blinding_factor_inverse(factor, out) } +/// Derive a blinding-factor inverse with reusable scratch for side-channel diagnostics. +/// +/// This diagnostic-only leaf exercises the production modular inverse without +/// per-call allocation. Normal callers should use the signing and decryption +/// APIs, which generate and clear blinding material internally. +#[cfg(feature = "diag")] +#[cfg_attr(docsrs, doc(cfg(feature = "diag")))] +#[doc(hidden)] +#[unsafe(no_mangle)] +#[inline(never)] +pub fn diag_rsa_blinding_factor_inverse_with_scratch( + key: &RsaPrivateKey, + factor: &[u8], + out: &mut [u8], + scratch: &mut RsaPrivateScratch, +) -> Result<(), RsaPrivateOpError> { + let len = key.signature_len(); + let result = scratch.ensure_len(len).and_then(|()| { + if factor.len() != len || out.len() != len { + return Err(RsaPrivateOpError::InvalidLength); + } + scratch.blinding_factor.as_mut_slice().copy_from_slice(factor); + key.components.blinding_factor_inverse_into_scratch(scratch)?; + out.copy_from_slice(scratch.blinding_inverse.as_slice()); + Ok(()) + }); + scratch.clear(); + clear_output_on_error(result, out) +} + +/// Exercise caller-random RSA-PSS success cleanup for optimized-code evidence. +#[cfg(feature = "diag")] +#[doc(hidden)] +#[unsafe(no_mangle)] +#[inline(never)] +pub fn diag_rsa_caller_random_signing_success( + key: &RsaPrivateKey, + out: &mut [u8], + scratch: &mut RsaPrivateScratch, +) -> u8 { + let modulus_len = key.signature_len(); + let result = key.sign_pss_with_random_fill_and_scratch( + RsaPssProfile::Sha256, + b"rscrypto RSA caller-random zeroize success evidence", + out, + scratch, + |random| { + if random.len() == modulus_len { + random.fill(0); + if let Some(last) = random.last_mut() { + *last = 1; + } + } else { + random.fill(0x5a); + } + Ok::<(), ()>(()) + }, + ); + core::hint::black_box(u8::from(result.is_ok()) ^ out.first().copied().unwrap_or_default()) +} + +/// Exercise caller-random RSA-PSS error cleanup for optimized-code evidence. +#[cfg(feature = "diag")] +#[doc(hidden)] +#[unsafe(no_mangle)] +#[inline(never)] +pub fn diag_rsa_caller_random_signing_error( + key: &RsaPrivateKey, + out: &mut [u8], + scratch: &mut RsaPrivateScratch, +) -> u8 { + let result = key.sign_pss_with_random_fill_and_scratch( + RsaPssProfile::Sha256, + b"rscrypto RSA caller-random zeroize error evidence", + out, + scratch, + |random| { + random.fill(0xa5); + Err::<(), ()>(()) + }, + ); + core::hint::black_box(u8::from(result == Err(RsaPrivateOpError::EntropyUnavailable))) +} + /// Public exponent policy for RSA public-key parsing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RsaPublicExponentPolicy { @@ -1336,9 +1421,9 @@ pub struct RsaPublicKey { /// screening detects weak factor shapes and compositeness evidence; it is not /// a mathematical proof of primality. /// -/// Private operations use either OS-backed blinding through the `getrandom` -/// feature or an explicit caller-supplied blinding factor and modular inverse -/// for deterministic tests and constrained integrations. +/// Private operations use caller-provided entropy, OS-backed entropy through +/// the `getrandom` feature, or an explicit caller-supplied blinding factor and +/// modular inverse for deterministic tests and constrained integrations. pub struct RsaPrivateKey { components: RsaPrivateKeyComponents, } @@ -1346,8 +1431,8 @@ pub struct RsaPrivateKey { /// Caller-owned scratch for RSA private operations. /// /// Reusing this scratch avoids top-level private-operation buffer allocation -/// for deterministic blinding APIs. Scratch is bound to a modulus width, not a -/// specific key; using it with a different width returns +/// for caller-random and deterministic blinding APIs. Scratch is bound to a +/// modulus width, not a specific key; using it with a different width returns /// [`RsaPrivateOpError::InvalidScratch`]. pub struct RsaPrivateScratch { encoded: SecretBigEndianBuffer, @@ -1361,6 +1446,7 @@ pub struct RsaPrivateScratch { one: SecretBigEndianBuffer, public_scratch: RsaPublicScratch, mul_scratch: RsaPrivateMulScratch, + inverse_scratch: RsaPrivateInverseScratch, exponent_scratch: RsaPrivateExponentScratch, } @@ -1631,17 +1717,130 @@ impl RsaPrivateKey { self.signer(algorithm.signature_profile()) } - /// Allocate reusable scratch space for deterministic private operations. + /// Allocate reusable scratch space for private operations. /// - /// Use this with the `*_with_blinding_factor_and_scratch` methods when a - /// caller supplies validated blinding material and wants steady-state signing - /// or decryption without top-level temporary buffer allocation. + /// Use this with the caller-random or explicit-blinding `*_and_scratch` + /// methods for steady-state private operations without top-level temporary + /// buffer allocation. #[inline] #[must_use] pub fn private_scratch(&self) -> RsaPrivateScratch { RsaPrivateScratch::new(self) } + /// Sign a message using a typed RSA signature profile and caller-provided entropy. + /// + /// PKCS#1 v1.5 profiles request one modulus-width blinding candidate per + /// attempt. PSS profiles first request the profile's salt length, then the + /// blinding candidates. Invalid or non-invertible candidates are rejected for + /// at most 128 attempts. Requested buffer lengths depend only on public key + /// and profile data. The callback count also depends on whether the supplied + /// candidates pass rejection sampling, subject to that public retry bound. + /// + /// The entropy callback's error type is intentionally unconstrained and is + /// never exposed. Any callback error becomes + /// [`RsaPrivateOpError::EntropyUnavailable`]. + /// + /// # Security + /// + /// On `Ok(())`, the callback must have overwritten every requested byte with + /// independent, uniformly distributed output from a cryptographically secure + /// random generator. A partial fill cannot be detected by this API and + /// invalidates the PSS salt and blinding guarantees. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError`] if entropy is unavailable, lengths are + /// invalid, the key is too small for the selected profile, no valid blinding + /// factor is found within the retry bound, or the post-signing public fault + /// check fails. `out` is cleared on every error. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_signature_with_random_fill( + &self, + profile: RsaSignatureProfile, + message: &[u8], + out: &mut [u8], + mut fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + if out.len() != self.signature_len() { + out.fill(0); + return Err(RsaPrivateOpError::InvalidLength); + } + if let RsaSignatureProfile::Pss { profile, salt_len } = profile + && !self.public_key().pss_salt_len_is_possible(profile, salt_len) + { + out.fill(0); + return Err(RsaPrivateOpError::MessageTooLong); + } + + let mut scratch = self.private_scratch(); + self.sign_signature_with_random_fill_and_scratch(profile, message, out, &mut scratch, &mut fill_random) + } + + /// Sign using a typed RSA signature profile, caller-provided entropy, and scratch. + /// + /// This is the allocation-free steady-state signing path after `scratch` + /// setup. The entropy request order, retry bound, and error normalization are + /// the same as [`Self::sign_signature_with_random_fill`]. Scratch and `out` + /// are cleared on every error; scratch is also cleared after success. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError`] if entropy is unavailable, lengths are + /// invalid, `scratch` was allocated for a different modulus width, the key is + /// too small for the selected profile, no valid blinding factor is found + /// within the retry bound, or the post-signing public fault check fails. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_signature_with_random_fill_and_scratch( + &self, + profile: RsaSignatureProfile, + message: &[u8], + out: &mut [u8], + scratch: &mut RsaPrivateScratch, + mut fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + let modulus_len = self.public_key().modulus().len(); + let result = scratch.ensure_len(modulus_len).and_then(|()| { + if out.len() != modulus_len { + return Err(RsaPrivateOpError::InvalidLength); + } + + match profile { + RsaSignatureProfile::Pss { profile, salt_len } => { + if !self.public_key().pss_salt_len_is_possible(profile, salt_len) { + return Err(RsaPrivateOpError::MessageTooLong); + } + { + let salt = scratch + .salt + .as_mut_slice() + .get_mut(..salt_len) + .ok_or(RsaPrivateOpError::MessageTooLong)?; + fill_random(salt).map_err(|_| RsaPrivateOpError::EntropyUnavailable)?; + } + self + .components + .random_blinding_factor_into_scratch_with(scratch, &mut fill_random) + .and_then(|()| { + self + .components + .sign_pss_with_stored_salt_and_blinding_and_scratch(profile, message, salt_len, out, scratch) + }) + } + RsaSignatureProfile::Pkcs1v15(profile) => self + .components + .random_blinding_factor_into_scratch_with(scratch, &mut fill_random) + .and_then(|()| { + self + .components + .sign_pkcs1v15_with_stored_blinding_and_scratch(profile, message, out, scratch) + }), + } + }); + scratch.clear(); + clear_output_on_error(result, out) + } + /// Sign a message using a typed RSA signature profile and OS-backed randomness. /// /// PKCS#1 v1.5 profiles use deterministic EMSA-PKCS1-v1_5 encoding. PSS @@ -1661,10 +1860,7 @@ impl RsaPrivateKey { message: &[u8], out: &mut [u8], ) -> Result<(), RsaPrivateOpError> { - match profile { - RsaSignatureProfile::Pss { profile, salt_len } => self.sign_pss_with_salt_len(profile, salt_len, message, out), - RsaSignatureProfile::Pkcs1v15(profile) => self.sign_pkcs1v15(profile, message, out), - } + self.sign_signature_with_random_fill(profile, message, out, getrandom::fill) } /// Sign a message using a typed RSA signature profile, OS-backed randomness, @@ -1690,12 +1886,7 @@ impl RsaPrivateKey { out: &mut [u8], scratch: &mut RsaPrivateScratch, ) -> Result<(), RsaPrivateOpError> { - match profile { - RsaSignatureProfile::Pss { profile, salt_len } => { - self.sign_pss_with_salt_len_and_scratch(profile, salt_len, message, out, scratch) - } - RsaSignatureProfile::Pkcs1v15(profile) => self.sign_pkcs1v15_with_scratch(profile, message, out, scratch), - } + self.sign_signature_with_random_fill_and_scratch(profile, message, out, scratch, getrandom::fill) } /// Sign using an X.509 signature `AlgorithmIdentifier` DER value. @@ -1751,6 +1942,60 @@ impl RsaPrivateKey { clear_output_on_error(result, out) } + /// Sign a TLS 1.3 `CertificateVerify` message with caller-provided entropy. + /// + /// Primitive helper only: this does not construct the TLS transcript message + /// or enforce certificate-chain policy. TLS 1.3 RSA handshake signatures are + /// RSASSA-PSS only; legacy PKCS#1 v1.5 scheme IDs are rejected before the + /// entropy callback is invoked. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError::UnsupportedAlgorithm`] if `scheme` is not an + /// accepted TLS 1.3 RSA signing scheme. Other errors match + /// [`Self::sign_signature_with_random_fill`]. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_tls13_signature_scheme_with_random_fill( + &self, + scheme: u16, + message: &[u8], + out: &mut [u8], + fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + let result = RsaSignatureProfile::from_tls13_signature_scheme(scheme) + .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) + .and_then(|profile| self.sign_signature_with_random_fill(profile, message, out, fill_random)); + clear_output_on_error(result, out) + } + + /// Sign a TLS 1.3 `CertificateVerify` message with caller entropy and scratch. + /// + /// Parsing and rejection happen before the entropy callback is invoked. The + /// scratch and entropy contracts match + /// [`Self::sign_signature_with_random_fill_and_scratch`]. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError::UnsupportedAlgorithm`] if `scheme` is not an + /// accepted TLS 1.3 RSA signing scheme. Other errors match + /// [`Self::sign_signature_with_random_fill_and_scratch`]. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_tls13_signature_scheme_with_random_fill_and_scratch( + &self, + scheme: u16, + message: &[u8], + out: &mut [u8], + scratch: &mut RsaPrivateScratch, + fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + let result = RsaSignatureProfile::from_tls13_signature_scheme(scheme) + .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) + .and_then(|profile| { + self.sign_signature_with_random_fill_and_scratch(profile, message, out, scratch, fill_random) + }); + clear_output_on_error(result, out) + } + /// Sign a TLS 1.3 `CertificateVerify` message using a parsed signature scheme. /// /// Primitive helper only: this does not construct the TLS transcript message @@ -1771,10 +2016,7 @@ impl RsaPrivateKey { message: &[u8], out: &mut [u8], ) -> Result<(), RsaPrivateOpError> { - let result = RsaSignatureProfile::from_tls13_signature_scheme(scheme) - .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) - .and_then(|profile| self.sign_signature(profile, message, out)); - clear_output_on_error(result, out) + self.sign_tls13_signature_scheme_with_random_fill(scheme, message, out, getrandom::fill) } /// Sign a TLS 1.3 `CertificateVerify` message using a parsed signature scheme and caller-owned @@ -1799,9 +2041,60 @@ impl RsaPrivateKey { out: &mut [u8], scratch: &mut RsaPrivateScratch, ) -> Result<(), RsaPrivateOpError> { - let result = RsaSignatureProfile::from_tls13_signature_scheme(scheme) + self.sign_tls13_signature_scheme_with_random_fill_and_scratch(scheme, message, out, scratch, getrandom::fill) + } + + /// Sign using a TLS certificate signature scheme and caller-provided entropy. + /// + /// Primitive helper only: this does not build or validate certificates. It + /// accepts the SHA-2 PKCS#1 v1.5 certificate-signature schemes and the TLS + /// 1.3 RSA-PSS schemes. Unsupported schemes are rejected before the entropy + /// callback is invoked. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError::UnsupportedAlgorithm`] if `scheme` is not an + /// accepted RSA certificate signing scheme. Other errors match + /// [`Self::sign_signature_with_random_fill`]. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_tls_certificate_signature_scheme_with_random_fill( + &self, + scheme: u16, + message: &[u8], + out: &mut [u8], + fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + let result = RsaSignatureProfile::from_tls_certificate_signature_scheme(scheme) .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) - .and_then(|profile| self.sign_signature_with_scratch(profile, message, out, scratch)); + .and_then(|profile| self.sign_signature_with_random_fill(profile, message, out, fill_random)); + clear_output_on_error(result, out) + } + + /// Sign using a TLS certificate signature scheme, caller entropy, and scratch. + /// + /// Parsing and rejection happen before the entropy callback is invoked. The + /// scratch and entropy contracts match + /// [`Self::sign_signature_with_random_fill_and_scratch`]. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError::UnsupportedAlgorithm`] if `scheme` is not an + /// accepted RSA certificate signing scheme. Other errors match + /// [`Self::sign_signature_with_random_fill_and_scratch`]. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_tls_certificate_signature_scheme_with_random_fill_and_scratch( + &self, + scheme: u16, + message: &[u8], + out: &mut [u8], + scratch: &mut RsaPrivateScratch, + fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + let result = RsaSignatureProfile::from_tls_certificate_signature_scheme(scheme) + .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) + .and_then(|profile| { + self.sign_signature_with_random_fill_and_scratch(profile, message, out, scratch, fill_random) + }); clear_output_on_error(result, out) } @@ -1825,10 +2118,7 @@ impl RsaPrivateKey { message: &[u8], out: &mut [u8], ) -> Result<(), RsaPrivateOpError> { - let result = RsaSignatureProfile::from_tls_certificate_signature_scheme(scheme) - .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) - .and_then(|profile| self.sign_signature(profile, message, out)); - clear_output_on_error(result, out) + self.sign_tls_certificate_signature_scheme_with_random_fill(scheme, message, out, getrandom::fill) } /// Sign using a TLS certificate signature scheme ID and caller-owned scratch. @@ -1852,10 +2142,13 @@ impl RsaPrivateKey { out: &mut [u8], scratch: &mut RsaPrivateScratch, ) -> Result<(), RsaPrivateOpError> { - let result = RsaSignatureProfile::from_tls_certificate_signature_scheme(scheme) - .map_err(|_| RsaPrivateOpError::UnsupportedAlgorithm) - .and_then(|profile| self.sign_signature_with_scratch(profile, message, out, scratch)); - clear_output_on_error(result, out) + self.sign_tls_certificate_signature_scheme_with_random_fill_and_scratch( + scheme, + message, + out, + scratch, + getrandom::fill, + ) } /// Sign a COSE Sig_structure using an already-parsed COSE algorithm ID. @@ -1925,15 +2218,7 @@ impl RsaPrivateKey { message: &[u8], out: &mut [u8], ) -> Result<(), RsaPrivateOpError> { - let result = self.components.random_blinding_factor().and_then(|blinding| { - self.components.sign_pkcs1v15_with_blinding_factor( - profile, - message, - RsaBlindingPair::trusted(blinding.factor(), blinding.inverse()), - out, - ) - }); - clear_output_on_error(result, out) + self.sign_signature(RsaSignatureProfile::pkcs1v15(profile), message, out) } /// Sign a message using RSASSA-PKCS1-v1_5, OS-backed blinding, and caller-owned scratch. @@ -1956,16 +2241,57 @@ impl RsaPrivateKey { out: &mut [u8], scratch: &mut RsaPrivateScratch, ) -> Result<(), RsaPrivateOpError> { - let result = self - .components - .random_blinding_factor_into_scratch(scratch) - .and_then(|()| { - self - .components - .sign_pkcs1v15_with_stored_blinding_and_scratch(profile, message, out, scratch) - }); - scratch.clear(); - clear_output_on_error(result, out) + self.sign_signature_with_scratch(RsaSignatureProfile::pkcs1v15(profile), message, out, scratch) + } + + /// Sign a message using RSASSA-PSS and caller-provided salt/blinding entropy. + /// + /// The salt length is the selected profile's digest length. The entropy + /// request and failure contracts match + /// [`Self::sign_signature_with_random_fill`]. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError`] if entropy is unavailable, lengths are + /// invalid, the key is too small for the selected profile, no valid blinding + /// factor is found within the retry bound, or the post-signing public fault + /// check fails. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_pss_with_random_fill( + &self, + profile: RsaPssProfile, + message: &[u8], + out: &mut [u8], + fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + self.sign_signature_with_random_fill(RsaSignatureProfile::pss(profile), message, out, fill_random) + } + + /// Sign using RSASSA-PSS, caller-provided entropy, and reusable scratch. + /// + /// The salt length is the selected profile's digest length. This path + /// performs no steady-state allocation after `scratch` setup. + /// + /// # Errors + /// + /// Returns [`RsaPrivateOpError`] under the same conditions as + /// [`Self::sign_signature_with_random_fill_and_scratch`]. + #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] + pub fn sign_pss_with_random_fill_and_scratch( + &self, + profile: RsaPssProfile, + message: &[u8], + out: &mut [u8], + scratch: &mut RsaPrivateScratch, + fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { + self.sign_signature_with_random_fill_and_scratch( + RsaSignatureProfile::pss(profile), + message, + out, + scratch, + fill_random, + ) } /// Sign a message using RSASSA-PSS and OS-backed random salt/blinding. @@ -1981,7 +2307,7 @@ impl RsaPrivateKey { #[cfg_attr(docsrs, doc(cfg(feature = "getrandom")))] #[must_use = "RSA signing failure must be checked; a dropped Result silently discards a failed signature"] pub fn sign_pss(&self, profile: RsaPssProfile, message: &[u8], out: &mut [u8]) -> Result<(), RsaPrivateOpError> { - self.sign_pss_with_salt_len(profile, profile.digest_len(), message, out) + self.sign_pss_with_random_fill(profile, message, out, getrandom::fill) } /// Sign a message using RSASSA-PSS, OS-backed random salt/blinding, and caller-owned scratch. @@ -2003,7 +2329,7 @@ impl RsaPrivateKey { out: &mut [u8], scratch: &mut RsaPrivateScratch, ) -> Result<(), RsaPrivateOpError> { - self.sign_pss_with_salt_len_and_scratch(profile, profile.digest_len(), message, out, scratch) + self.sign_pss_with_random_fill_and_scratch(profile, message, out, scratch, getrandom::fill) } /// Sign a message using RSASSA-PSS with an explicit random salt length. @@ -2027,26 +2353,12 @@ impl RsaPrivateKey { message: &[u8], out: &mut [u8], ) -> Result<(), RsaPrivateOpError> { - let result = if !self.public_key().pss_salt_len_is_possible(profile, salt_len) { - Err(RsaPrivateOpError::MessageTooLong) - } else { - let mut salt = vec![0u8; salt_len]; - let result = getrandom::fill(&mut salt) - .map_err(|_| RsaPrivateOpError::EntropyUnavailable) - .and_then(|()| self.components.random_blinding_factor()) - .and_then(|blinding| { - self.components.sign_pss_with_salt_and_blinding_factor( - profile, - message, - &salt, - RsaBlindingPair::trusted(blinding.factor(), blinding.inverse()), - out, - ) - }); - ct::zeroize(&mut salt); - result - }; - clear_output_on_error(result, out) + self.sign_signature_with_random_fill( + RsaSignatureProfile::pss_with_salt_len(profile, salt_len), + message, + out, + getrandom::fill, + ) } /// Sign a message using RSASSA-PSS with an explicit random salt length and caller-owned scratch. @@ -2071,27 +2383,13 @@ impl RsaPrivateKey { out: &mut [u8], scratch: &mut RsaPrivateScratch, ) -> Result<(), RsaPrivateOpError> { - let result = if !self.public_key().pss_salt_len_is_possible(profile, salt_len) { - Err(RsaPrivateOpError::MessageTooLong) - } else { - scratch.ensure_len(self.public_key().modulus().len()).and_then(|()| { - let salt = scratch - .salt - .as_mut_slice() - .get_mut(..salt_len) - .ok_or(RsaPrivateOpError::MessageTooLong)?; - getrandom::fill(salt) - .map_err(|_| RsaPrivateOpError::EntropyUnavailable) - .and_then(|()| self.components.random_blinding_factor_into_scratch(scratch)) - .and_then(|()| { - self - .components - .sign_pss_with_stored_salt_and_blinding_and_scratch(profile, message, salt_len, out, scratch) - }) - }) - }; - scratch.clear(); - clear_output_on_error(result, out) + self.sign_signature_with_random_fill_and_scratch( + RsaSignatureProfile::pss_with_salt_len(profile, salt_len), + message, + out, + scratch, + getrandom::fill, + ) } /// Decrypt an RSAES-OAEP ciphertext using OS-backed blinding. @@ -2658,6 +2956,7 @@ impl RsaPrivateScratch { one, public_scratch: components.public.public_scratch(), mul_scratch: RsaPrivateMulScratch::new(components.public.modulus.limbs.len()), + inverse_scratch: RsaPrivateInverseScratch::new(components.public.modulus.bits), exponent_scratch: RsaPrivateExponentScratch::new(components.public.modulus.limbs.len()), } } @@ -2701,6 +3000,7 @@ impl RsaPrivateScratch { ct::zeroize_words(&mut self.public_scratch.limbs[..]); ct::zeroize(&mut self.public_scratch.bytes[..]); self.mul_scratch.clear(); + self.inverse_scratch.clear(); self.exponent_scratch.clear(); } } @@ -2769,10 +3069,18 @@ impl RsaPrivateKeyComponents { #[cfg(feature = "getrandom")] fn random_blinding_factor(&self) -> Result { + self.random_blinding_factor_with(getrandom::fill) + } + + #[cfg(feature = "getrandom")] + fn random_blinding_factor_with( + &self, + mut fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result { let len = self.public.modulus().len(); for _ in 0..128 { let mut factor = vec![0u8; len]; - if getrandom::fill(&mut factor).is_err() { + if fill_random(&mut factor).is_err() { ct::zeroize(&mut factor); return Err(RsaPrivateOpError::EntropyUnavailable); } @@ -2812,16 +3120,22 @@ impl RsaPrivateKeyComponents { #[cfg(feature = "getrandom")] fn random_blinding_factor_into_scratch(&self, scratch: &mut RsaPrivateScratch) -> Result<(), RsaPrivateOpError> { + self.random_blinding_factor_into_scratch_with(scratch, getrandom::fill) + } + + fn random_blinding_factor_into_scratch_with( + &self, + scratch: &mut RsaPrivateScratch, + mut fill_random: impl FnMut(&mut [u8]) -> Result<(), E>, + ) -> Result<(), RsaPrivateOpError> { let len = self.public.modulus().len(); scratch.ensure_len(len)?; for _ in 0..128 { - if getrandom::fill(scratch.blinding_factor.as_mut_slice()).is_err() { + if fill_random(scratch.blinding_factor.as_mut_slice()).is_err() { ct::zeroize(scratch.blinding_factor.as_mut_slice()); return Err(RsaPrivateOpError::EntropyUnavailable); } - if is_zero_unsigned_be(scratch.blinding_factor.as_slice()) - || unsigned_be_cmp(scratch.blinding_factor.as_slice(), self.public.modulus()) != core::cmp::Ordering::Less - { + if !private_valid_blinding_factor(scratch.blinding_factor.as_slice(), self.public.modulus()) { ct::zeroize(scratch.blinding_factor.as_mut_slice()); continue; } @@ -2845,216 +3159,48 @@ impl RsaPrivateKeyComponents { if is_one { return Ok(()); } - } - ct::zeroize(scratch.blinding_factor.as_mut_slice()); - ct::zeroize(scratch.blinding_inverse.as_mut_slice()); - ct::zeroize(scratch.checked.as_mut_slice()); - } - Err(_) => { - ct::zeroize(scratch.blinding_factor.as_mut_slice()); - ct::zeroize(scratch.blinding_inverse.as_mut_slice()); - ct::zeroize(scratch.checked.as_mut_slice()); - } - } - } - Err(RsaPrivateOpError::InvalidBlindingFactor) - } - - #[cfg(feature = "getrandom")] - fn blinding_factor_inverse(&self, factor: &[u8], out: &mut [u8]) -> Result<(), RsaPrivateOpError> { - let n_len = self.public.modulus().len(); - if factor.len() != n_len || out.len() != n_len { - return Err(RsaPrivateOpError::InvalidLength); - } - - let mut scratch = RsaPrivateScratch::new_components(self); - scratch.blinding_factor.as_mut_slice().copy_from_slice(factor); - let result = self.blinding_factor_inverse_into_scratch(&mut scratch).map(|()| { - out.copy_from_slice(scratch.blinding_inverse.as_slice()); - }); - scratch.clear(); - result - } - - #[cfg(feature = "getrandom")] - fn blinding_factor_inverse_into_scratch(&self, scratch: &mut RsaPrivateScratch) -> Result<(), RsaPrivateOpError> { - let n_len = self.public.modulus().len(); - scratch.ensure_len(n_len)?; - if scratch.blinding_factor.as_slice().len() != n_len || scratch.blinding_inverse.as_slice().len() != n_len { - return Err(RsaPrivateOpError::InvalidLength); - } - - let prime_p = self.prime_p.as_bytes(); - let prime_q = self.prime_q.as_bytes(); - let modulus_p = &self.prime_p_modulus; - let modulus_q = &self.prime_q_modulus; - - { - let factor_p = scratch - .blinding_power - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?; - private_import_unsigned_be_mod_to_fixed( - scratch.blinding_factor.as_slice(), - modulus_p, - factor_p, - &mut scratch.exponent_scratch, - )?; - if is_zero_unsigned_be(factor_p) { - return Err(RsaPrivateOpError::InvalidBlindingFactor); - } - } - { - let factor_q = scratch - .checked - .as_mut_slice() - .get_mut(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?; - private_import_unsigned_be_mod_to_fixed( - scratch.blinding_factor.as_slice(), - modulus_q, - factor_q, - &mut scratch.exponent_scratch, - )?; - if is_zero_unsigned_be(factor_q) { - return Err(RsaPrivateOpError::InvalidBlindingFactor); - } - } - - private_sub_small_unsigned_be_to_fixed( - prime_p, - 2, - scratch - .blinded - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - )?; - private_sub_small_unsigned_be_to_fixed( - prime_q, - 2, - scratch - .encoded - .as_mut_slice() - .get_mut(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - )?; - - private_exponentiate_representative_with_scratch( - modulus_p, - scratch - .blinded - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .blinding_power - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .blinded_private_result - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - &mut scratch.exponent_scratch, - )?; - private_exponentiate_representative_with_scratch( - modulus_q, - scratch - .encoded - .as_slice() - .get(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .checked - .as_slice() - .get(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .blinded - .as_mut_slice() - .get_mut(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - &mut scratch.exponent_scratch, - )?; + } + ct::zeroize(scratch.blinding_factor.as_mut_slice()); + ct::zeroize(scratch.blinding_inverse.as_mut_slice()); + ct::zeroize(scratch.checked.as_mut_slice()); + } + Err(_) => { + ct::zeroize(scratch.blinding_factor.as_mut_slice()); + ct::zeroize(scratch.blinding_inverse.as_mut_slice()); + ct::zeroize(scratch.checked.as_mut_slice()); + } + } + } + Err(RsaPrivateOpError::InvalidBlindingFactor) + } - private_import_unsigned_be_mod_to_fixed( - scratch - .blinded - .as_slice() - .get(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - modulus_p, - scratch - .blinding_power - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - &mut scratch.exponent_scratch, - )?; - private_sub_mod_unsigned_be_to_fixed( - scratch - .blinded_private_result - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .blinding_power - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - prime_p, - scratch - .checked - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - )?; + #[cfg(feature = "getrandom")] + fn blinding_factor_inverse(&self, factor: &[u8], out: &mut [u8]) -> Result<(), RsaPrivateOpError> { + let n_len = self.public.modulus().len(); + if factor.len() != n_len || out.len() != n_len { + return Err(RsaPrivateOpError::InvalidLength); + } - left_pad_be( - self.coefficient.as_bytes(), - scratch - .blinding_power - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - )?; - mod_mul_representatives_with_scratch( - modulus_p, - scratch - .blinding_power - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .checked - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .blinded_private_result - .as_mut_slice() - .get_mut(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - &mut scratch.mul_scratch, - )?; + let mut scratch = RsaPrivateScratch::new_components(self); + scratch.blinding_factor.as_mut_slice().copy_from_slice(factor); + let result = self.blinding_factor_inverse_into_scratch(&mut scratch).map(|()| { + out.copy_from_slice(scratch.blinding_inverse.as_slice()); + }); + scratch.clear(); + result + } - private_product_add_unsigned_be_to_fixed( - prime_q, - scratch - .blinded_private_result - .as_slice() - .get(..prime_p.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, - scratch - .blinded - .as_slice() - .get(..prime_q.len()) - .ok_or(RsaPrivateOpError::InvalidScratch)?, + fn blinding_factor_inverse_into_scratch(&self, scratch: &mut RsaPrivateScratch) -> Result<(), RsaPrivateOpError> { + let n_len = self.public.modulus().len(); + scratch.ensure_len(n_len)?; + if scratch.blinding_factor.as_slice().len() != n_len || scratch.blinding_inverse.as_slice().len() != n_len { + return Err(RsaPrivateOpError::InvalidLength); + } + private_modular_inverse_odd( + &self.public.modulus, + scratch.blinding_factor.as_slice(), scratch.blinding_inverse.as_mut_slice(), - &mut scratch.mul_scratch, + &mut scratch.inverse_scratch, ) } @@ -3101,7 +3247,6 @@ impl RsaPrivateKeyComponents { result } - #[cfg(feature = "getrandom")] fn sign_pkcs1v15_with_stored_blinding_and_scratch( &self, profile: RsaPkcs1v15Profile, @@ -3195,7 +3340,6 @@ impl RsaPrivateKeyComponents { result } - #[cfg(feature = "getrandom")] fn sign_pss_with_stored_salt_and_blinding_and_scratch( &self, profile: RsaPssProfile, @@ -3274,7 +3418,6 @@ impl RsaPrivateKeyComponents { self.private_operation_with_blinding_factor_and_scratch(blinding, out, scratch) } - #[cfg(feature = "getrandom")] fn sign_encoded_message_with_stored_blinding_and_scratch( &self, out: &mut [u8], @@ -3572,7 +3715,6 @@ impl RsaPrivateKeyComponents { } } - #[cfg(feature = "getrandom")] fn private_operation_from_scratch_encoded_with_stored_blinding( &self, scratch: &mut RsaPrivateScratch, @@ -3999,6 +4141,86 @@ impl Drop for SecretLimbs { } } +struct SecretI31 { + words: Vec, +} + +impl SecretI31 { + fn zeroed(len: usize) -> Self { + Self { words: vec![0; len] } + } + + #[cfg(test)] + #[inline] + fn as_slice(&self) -> &[u32] { + &self.words + } + + #[inline] + fn as_mut_slice(&mut self) -> &mut [u32] { + &mut self.words + } +} + +impl Drop for SecretI31 { + fn drop(&mut self) { + ct::zeroize_words(&mut self.words); + } +} + +struct RsaPrivateInverseScratch { + a: SecretI31, + b: SecretI31, + u: SecretI31, + v: SecretI31, + modulus: SecretI31, + bit_len: usize, +} + +struct RsaPrivateInverseWorkspace<'a> { + a: &'a mut [u32], + b: &'a mut [u32], + u: &'a mut [u32], + v: &'a mut [u32], + modulus: &'a mut [u32], +} + +impl RsaPrivateInverseScratch { + fn new(bit_len: usize) -> Self { + let word_count = private_i31_word_count(bit_len); + Self { + a: SecretI31::zeroed(word_count), + b: SecretI31::zeroed(word_count), + u: SecretI31::zeroed(word_count), + v: SecretI31::zeroed(word_count), + modulus: SecretI31::zeroed(word_count), + bit_len, + } + } + + fn workspace(&mut self, bit_len: usize) -> Result, RsaPrivateOpError> { + if bit_len != self.bit_len { + return Err(RsaPrivateOpError::InvalidScratch); + } + let word_count = private_i31_word_count(bit_len); + Ok(RsaPrivateInverseWorkspace { + a: private_i31_scratch_prefix(&mut self.a, word_count)?, + b: private_i31_scratch_prefix(&mut self.b, word_count)?, + u: private_i31_scratch_prefix(&mut self.u, word_count)?, + v: private_i31_scratch_prefix(&mut self.v, word_count)?, + modulus: private_i31_scratch_prefix(&mut self.modulus, word_count)?, + }) + } + + fn clear(&mut self) { + ct::zeroize_words(self.a.as_mut_slice()); + ct::zeroize_words(self.b.as_mut_slice()); + ct::zeroize_words(self.u.as_mut_slice()); + ct::zeroize_words(self.v.as_mut_slice()); + ct::zeroize_words(self.modulus.as_mut_slice()); + } +} + struct RsaPrivateMulScratch { t: SecretLimbs, left_limbs: SecretLimbs, @@ -4153,6 +4375,13 @@ fn private_scratch_prefix(limbs: &mut SecretLimbs, len: usize) -> Result<&mut [u .ok_or(RsaPrivateOpError::InvalidScratch) } +fn private_i31_scratch_prefix(words: &mut SecretI31, len: usize) -> Result<&mut [u32], RsaPrivateOpError> { + words + .as_mut_slice() + .get_mut(..len) + .ok_or(RsaPrivateOpError::InvalidScratch) +} + /// X.509 RSA public-key algorithm constraints. /// /// `rsaEncryption` keys are unconstrained RSA public keys. `id-RSASSA-PSS` @@ -8922,48 +9151,477 @@ fn private_sub_unsigned_be_to_fixed(left: &[u8], right: &[u8], out: &mut [u8]) - } } -#[cfg(feature = "getrandom")] -fn private_sub_unsigned_be_to_len( - left: &[u8], - right: &[u8], - len: usize, -) -> Result { - if left.len() > len.strict_add(1) || right.len() > len || unsigned_be_cmp(left, right) == core::cmp::Ordering::Less { - return Err(RsaPrivateOpError::InvalidLength); - } +#[cfg(feature = "getrandom")] +fn private_sub_unsigned_be_to_len( + left: &[u8], + right: &[u8], + len: usize, +) -> Result { + if left.len() > len.strict_add(1) || right.len() > len || unsigned_be_cmp(left, right) == core::cmp::Ordering::Less { + return Err(RsaPrivateOpError::InvalidLength); + } + + let mut out = vec![0u8; len.strict_add(1)]; + let mut borrow = 0i16; + for index in 0..out.len() { + let left_byte = left + .len() + .checked_sub(index.strict_add(1)) + .and_then(|src| left.get(src)) + .copied() + .unwrap_or(0); + let right_byte = right + .len() + .checked_sub(index.strict_add(1)) + .and_then(|src| right.get(src)) + .copied() + .unwrap_or(0); + let mut difference = i16::from(left_byte) + .strict_sub(i16::from(right_byte)) + .strict_sub(borrow); + if difference < 0 { + difference = difference.strict_add(256); + borrow = 1; + } else { + borrow = 0; + } + let dst = out.len().strict_sub(index).strict_sub(1); + out[dst] = difference.to_le_bytes()[0]; + } + + if borrow != 0 { + return Err(RsaPrivateOpError::InvalidLength); + } + Ok(private_import_canonical_unsigned_be(out)) +} + +fn private_modular_inverse_odd( + modulus: &RsaPublicModulus, + input: &[u8], + out: &mut [u8], + scratch: &mut RsaPrivateInverseScratch, +) -> Result<(), RsaPrivateOpError> { + #[cfg(all(target_arch = "aarch64", not(miri)))] + let _dit_guard = Aarch64DitGuard::enter(); + + let bytes = modulus.bytes.len(); + if input.len() != bytes || out.len() != bytes || modulus.limbs.first().copied().unwrap_or(0) & 1 == 0 { + ct::zeroize(out); + return Err(RsaPrivateOpError::InvalidLength); + } + if !private_valid_blinding_factor(input, &modulus.bytes) { + ct::zeroize(out); + return Err(RsaPrivateOpError::InvalidBlindingFactor); + } + + let result = (|| { + let RsaPrivateInverseWorkspace { + a, + b, + u, + v, + modulus: modulus_i31, + } = scratch.workspace(modulus.bits)?; + private_be_to_i31(input, a); + private_be_to_i31(&modulus.bytes, modulus_i31); + b.copy_from_slice(modulus_i31); + u.fill(0); + v.fill(0); + *u.first_mut().ok_or(RsaPrivateOpError::InvalidScratch)? = 1; + + // The extended-binary-GCD state maintains: + // a ≡ input * u (mod modulus) + // b ≡ input * v (mod modulus) + // starting from (a, b, u, v) = (input, modulus, 1, 0). Each batch + // simulates 31 binary steps from fixed low/high approximations, then + // applies the resulting matrix to the full-width values. The approximation + // guarantees at least 30 effective reductions per batch. Binary GCD needs + // at most 2*k - 2 reductions for a k-bit odd modulus, so this public-width + // schedule includes one conservative batch beyond 2*k/30. + let batch_count = modulus.bits.strict_mul(2).strict_add(30) / 30; + for _ in 0..batch_count { + let (pa, pb, qa, qb) = private_i31_reduction_factors(a, b); + let signs = private_i31_co_reduce(a, b, pa, pb, qa, qb); + let a_negative = i64::from(signs & 1); + let b_negative = i64::from((signs >> 1) & 1); + let pa = private_i31_cond_negate_factor(pa, a_negative); + let pb = private_i31_cond_negate_factor(pb, a_negative); + let qa = private_i31_cond_negate_factor(qa, b_negative); + let qb = private_i31_cond_negate_factor(qb, b_negative); + private_i31_co_reduce_mod( + u, + v, + pa, + pb, + qa, + qb, + modulus_i31, + private_i31_low_u32(modulus.n0) & PRIVATE_I31_MASK, + ); + } + + let mut gcd_difference = (a.first().copied().unwrap_or(0) | b.first().copied().unwrap_or(0)) ^ 1; + for index in 1..a.len() { + gcd_difference |= a[index] | b[index]; + u[index] |= v[index]; + } + if ct_nonzero_u64(u64::from(gcd_difference)) != 0 { + return Err(RsaPrivateOpError::InvalidBlindingFactor); + } + u[0] |= v[0]; + private_i31_to_be(u, out); + Ok(()) + })(); + + scratch.clear(); + if result.is_err() { + ct::zeroize(out); + } + result +} + +const PRIVATE_I31_MASK: u32 = 0x7fff_ffff; + +#[inline(always)] +const fn private_i31_low_u32(value: u64) -> u32 { + let bytes = value.to_le_bytes(); + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +#[inline(always)] +fn private_i31_cond_negate_factor(value: i64, negate: i64) -> i64 { + let mask = 0i64.wrapping_sub(negate); + (value ^ mask).wrapping_add(negate) +} + +#[cfg(any(test, target_arch = "riscv32", target_arch = "riscv64", target_arch = "s390x"))] +#[inline(never)] +fn private_i31_ct_mul_u64_low(lhs: u64, rhs: u64) -> u64 { + let mut product = 0u64; + let mut multiplicand = lhs; + let mut multiplier = rhs; + for _ in 0..u64::BITS { + // Keep LLVM from recognizing this fixed-work product and lowering it back + // to a target multiply. The CT artifact gate independently rejects scalar + // multiply in RISC-V and s390x evidence closures. + let selected_bit = core::hint::black_box(multiplier & 1); + product = product.wrapping_add(multiplicand & 0u64.wrapping_sub(selected_bit)); + multiplicand <<= 1; + multiplier >>= 1; + } + product +} + +#[cfg(any(test, target_arch = "riscv32", target_arch = "riscv64", target_arch = "s390x"))] +#[inline(never)] +fn private_i31_ct_mul_u32_low(lhs: u32, rhs: u32) -> u32 { + let mut product = 0u32; + let mut multiplicand = lhs; + let mut multiplier = rhs; + for _ in 0..u32::BITS { + let selected_bit = core::hint::black_box(multiplier & 1); + product = product.wrapping_add(multiplicand & 0u32.wrapping_sub(selected_bit)); + multiplicand <<= 1; + multiplier >>= 1; + } + product +} + +#[inline(always)] +fn private_i31_mul_u64_low(lhs: u64, rhs: u64) -> u64 { + #[cfg(any(target_arch = "riscv32", target_arch = "riscv64", target_arch = "s390x"))] + { + private_i31_ct_mul_u64_low(lhs, rhs) + } + + #[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64", target_arch = "s390x")))] + { + lhs.wrapping_mul(rhs) + } +} + +#[inline(always)] +fn private_i31_mul_u32_low(lhs: u32, rhs: u32) -> u32 { + #[cfg(any(target_arch = "riscv32", target_arch = "riscv64", target_arch = "s390x"))] + { + private_i31_ct_mul_u32_low(lhs, rhs) + } + + #[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64", target_arch = "s390x")))] + { + lhs.wrapping_mul(rhs) + } +} + +fn private_valid_blinding_factor(factor: &[u8], modulus: &[u8]) -> bool { + if factor.len() != modulus.len() { + return false; + } + let mut nonzero = 0u8; + let mut borrow = 0u16; + for index in (0..factor.len()).rev() { + nonzero |= factor[index]; + let (difference, first_borrow) = u16::from(factor[index]).overflowing_sub(u16::from(modulus[index])); + let (_, carry_borrow) = difference.overflowing_sub(borrow); + borrow = u16::from(first_borrow) | u16::from(carry_borrow); + } + ct_nonzero_u64(u64::from(nonzero)) & u64::from(borrow) == 1 +} + +#[inline] +fn private_i31_word_count(bit_len: usize) -> usize { + bit_len.strict_add(30) / 31 +} - let mut out = vec![0u8; len.strict_add(1)]; - let mut borrow = 0i16; - for index in 0..out.len() { - let left_byte = left - .len() - .checked_sub(index.strict_add(1)) - .and_then(|src| left.get(src)) - .copied() - .unwrap_or(0); - let right_byte = right - .len() - .checked_sub(index.strict_add(1)) - .and_then(|src| right.get(src)) - .copied() - .unwrap_or(0); - let mut difference = i16::from(left_byte) - .strict_sub(i16::from(right_byte)) - .strict_sub(borrow); - if difference < 0 { - difference = difference.strict_add(256); - borrow = 1; - } else { - borrow = 0; +fn private_be_to_i31(input: &[u8], out: &mut [u32]) { + out.fill(0); + let mut accumulator = 0u64; + let mut accumulator_bits = 0usize; + let mut word_index = 0usize; + for &byte in input.iter().rev() { + accumulator |= u64::from(byte) << accumulator_bits; + accumulator_bits = accumulator_bits.strict_add(8); + if accumulator_bits >= 31 { + out[word_index] = private_i31_low_u32(accumulator) & PRIVATE_I31_MASK; + word_index = word_index.strict_add(1); + accumulator >>= 31; + accumulator_bits = accumulator_bits.strict_sub(31); } - let dst = out.len().strict_sub(index).strict_sub(1); - out[dst] = difference.to_le_bytes()[0]; } + if word_index < out.len() { + out[word_index] = private_i31_low_u32(accumulator); + } +} - if borrow != 0 { - return Err(RsaPrivateOpError::InvalidLength); +fn private_i31_to_be(input: &[u32], out: &mut [u8]) { + let mut accumulator = 0u64; + let mut accumulator_bits = 0usize; + let mut word_index = 0usize; + for byte in out.iter_mut().rev() { + if accumulator_bits < 8 { + let word = input.get(word_index).copied().unwrap_or(0); + accumulator |= u64::from(word) << accumulator_bits; + accumulator_bits = accumulator_bits.strict_add(31); + word_index = word_index.strict_add(1); + } + *byte = accumulator.to_le_bytes()[0]; + accumulator >>= 8; + accumulator_bits = accumulator_bits.strict_sub(8); + } +} + +/* + * The batched extended-binary-GCD structure below is derived from BearSSL's + * i31 modular-division implementation: + * https://www.bearssl.org/gitweb/?p=BearSSL;a=blob;f=src/int/i31_moddiv.c + * + * Copyright (c) 2018 Thomas Pornin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +fn private_i31_reduction_factors(a: &[u32], b: &[u32]) -> (i64, i64, i64, i64) { + debug_assert_eq!(a.len(), b.len()); + let mut first_mask = u32::MAX; + let mut second_mask = u32::MAX; + let mut a_top = 0u32; + let mut a_next = 0u32; + let mut b_top = 0u32; + let mut b_next = 0u32; + for index in (0..a.len()).rev() { + let a_word = a[index]; + let b_word = b[index]; + a_top ^= (a_top ^ a_word) & first_mask; + a_next ^= (a_next ^ a_word) & second_mask; + b_top ^= (b_top ^ b_word) & first_mask; + b_next ^= (b_next ^ b_word) & second_mask; + second_mask = first_mask; + first_mask &= ((a_word | b_word).wrapping_add(PRIVATE_I31_MASK) >> 31).wrapping_sub(1); + } + + a_next |= a_top & second_mask; + a_top &= !second_mask; + b_next |= b_top & second_mask; + b_top &= !second_mask; + let mut a_high = (u64::from(a_top) << 31).wrapping_add(u64::from(a_next)); + let mut b_high = (u64::from(b_top) << 31).wrapping_add(u64::from(b_next)); + let mut a_low = a.first().copied().unwrap_or(0); + let mut b_low = b.first().copied().unwrap_or(0); + let mut pa = 1i64; + let mut pb = 0i64; + let mut qa = 0i64; + let mut qb = 1i64; + + for bit in 0..31 { + let difference = b_high.wrapping_sub(a_high); + let a_greater = private_i31_low_u32((difference ^ ((a_high ^ b_high) & (a_high ^ difference))) >> 63); + let a_odd = (a_low >> bit) & 1; + let b_odd = (b_low >> bit) & 1; + let subtract_b_from_a = a_odd & b_odd & a_greater; + let subtract_a_from_b = a_odd & b_odd & (a_greater ^ 1); + let shift_a = subtract_b_from_a | (a_odd ^ 1); + + let a_mask_u32 = 0u32.wrapping_sub(subtract_b_from_a); + let a_mask_u64 = 0u64.wrapping_sub(u64::from(subtract_b_from_a)); + let a_mask_i64 = 0i64.wrapping_sub(i64::from(subtract_b_from_a)); + a_low = a_low.wrapping_sub(b_low & a_mask_u32); + a_high = a_high.wrapping_sub(b_high & a_mask_u64); + pa = pa.wrapping_sub(qa & a_mask_i64); + pb = pb.wrapping_sub(qb & a_mask_i64); + + let b_mask_u32 = 0u32.wrapping_sub(subtract_a_from_b); + let b_mask_u64 = 0u64.wrapping_sub(u64::from(subtract_a_from_b)); + let b_mask_i64 = 0i64.wrapping_sub(i64::from(subtract_a_from_b)); + b_low = b_low.wrapping_sub(a_low & b_mask_u32); + b_high = b_high.wrapping_sub(a_high & b_mask_u64); + qa = qa.wrapping_sub(pa & b_mask_i64); + qb = qb.wrapping_sub(pb & b_mask_i64); + + let keep_a_mask_u32 = shift_a.wrapping_sub(1); + let keep_a_mask_i64 = i64::from(shift_a).wrapping_sub(1); + a_low = a_low.wrapping_add(a_low & keep_a_mask_u32); + pa = pa.wrapping_add(pa & keep_a_mask_i64); + pb = pb.wrapping_add(pb & keep_a_mask_i64); + let shift_a_mask_u64 = 0u64.wrapping_sub(u64::from(shift_a)); + a_high ^= (a_high ^ (a_high >> 1)) & shift_a_mask_u64; + + let keep_b_mask_u32 = 0u32.wrapping_sub(shift_a); + let keep_b_mask_i64 = 0i64.wrapping_sub(i64::from(shift_a)); + b_low = b_low.wrapping_add(b_low & keep_b_mask_u32); + qa = qa.wrapping_add(qa & keep_b_mask_i64); + qb = qb.wrapping_add(qb & keep_b_mask_i64); + let shift_b_mask_u64 = u64::from(shift_a).wrapping_sub(1); + b_high ^= (b_high ^ (b_high >> 1)) & shift_b_mask_u64; + } + + (pa, pb, qa, qb) +} + +fn private_i31_co_reduce(a: &mut [u32], b: &mut [u32], pa: i64, pb: i64, qa: i64, qb: i64) -> u32 { + debug_assert_eq!(a.len(), b.len()); + let mut a_carry = 0i64; + let mut b_carry = 0i64; + for index in 0..a.len() { + let a_word = u64::from(a[index]); + let b_word = u64::from(b[index]); + let a_value = private_i31_mul_u64_low(a_word, pa.cast_unsigned()) + .wrapping_add(private_i31_mul_u64_low(b_word, pb.cast_unsigned())) + .wrapping_add(a_carry.cast_unsigned()); + let b_value = private_i31_mul_u64_low(a_word, qa.cast_unsigned()) + .wrapping_add(private_i31_mul_u64_low(b_word, qb.cast_unsigned())) + .wrapping_add(b_carry.cast_unsigned()); + if index > 0 { + a[index.strict_sub(1)] = private_i31_low_u32(a_value) & PRIVATE_I31_MASK; + b[index.strict_sub(1)] = private_i31_low_u32(b_value) & PRIVATE_I31_MASK; + } + a_carry = ((a_value >> 31) ^ (1u64 << 32)).wrapping_sub(1u64 << 32).cast_signed(); + b_carry = ((b_value >> 31) ^ (1u64 << 32)).wrapping_sub(1u64 << 32).cast_signed(); + } + let last = a.len().strict_sub(1); + a[last] = private_i31_low_u32(a_carry.cast_unsigned()); + b[last] = private_i31_low_u32(b_carry.cast_unsigned()); + let a_negative = private_i31_low_u32(a_carry.cast_unsigned() >> 63); + let b_negative = private_i31_low_u32(b_carry.cast_unsigned() >> 63); + private_i31_cond_negate(a, a_negative); + private_i31_cond_negate(b, b_negative); + a_negative | (b_negative << 1) +} + +fn private_i31_cond_negate(value: &mut [u32], negate: u32) { + let mut carry = negate; + let mask = 0u32.wrapping_sub(negate) >> 1; + for word in value { + let negated = (*word ^ mask).wrapping_add(carry); + *word = negated & PRIVATE_I31_MASK; + carry = negated >> 31; + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the two-row batched co-reduction matrix and immutable modulus are one arithmetic operation" +)] +fn private_i31_co_reduce_mod( + u: &mut [u32], + v: &mut [u32], + pa: i64, + pb: i64, + qa: i64, + qb: i64, + modulus: &[u32], + modulus_inverse: u32, +) { + debug_assert_eq!(u.len(), v.len()); + debug_assert_eq!(u.len(), modulus.len()); + let u_factor = private_i31_mul_u32_low( + private_i31_mul_u32_low(u[0], private_i31_low_u32(pa.cast_unsigned())) + .wrapping_add(private_i31_mul_u32_low(v[0], private_i31_low_u32(pb.cast_unsigned()))), + modulus_inverse, + ) & PRIVATE_I31_MASK; + let v_factor = private_i31_mul_u32_low( + private_i31_mul_u32_low(u[0], private_i31_low_u32(qa.cast_unsigned())) + .wrapping_add(private_i31_mul_u32_low(v[0], private_i31_low_u32(qb.cast_unsigned()))), + modulus_inverse, + ) & PRIVATE_I31_MASK; + let mut u_carry = 0i64; + let mut v_carry = 0i64; + for index in 0..u.len() { + let u_word = u64::from(u[index]); + let v_word = u64::from(v[index]); + let modulus_word = u64::from(modulus[index]); + let u_value = private_i31_mul_u64_low(u_word, pa.cast_unsigned()) + .wrapping_add(private_i31_mul_u64_low(v_word, pb.cast_unsigned())) + .wrapping_add(private_i31_mul_u64_low(modulus_word, u64::from(u_factor))) + .wrapping_add(u_carry.cast_unsigned()); + let v_value = private_i31_mul_u64_low(u_word, qa.cast_unsigned()) + .wrapping_add(private_i31_mul_u64_low(v_word, qb.cast_unsigned())) + .wrapping_add(private_i31_mul_u64_low(modulus_word, u64::from(v_factor))) + .wrapping_add(v_carry.cast_unsigned()); + if index > 0 { + u[index.strict_sub(1)] = private_i31_low_u32(u_value) & PRIVATE_I31_MASK; + v[index.strict_sub(1)] = private_i31_low_u32(v_value) & PRIVATE_I31_MASK; + } + u_carry = ((u_value >> 31) ^ (1u64 << 32)).wrapping_sub(1u64 << 32).cast_signed(); + v_carry = ((v_value >> 31) ^ (1u64 << 32)).wrapping_sub(1u64 << 32).cast_signed(); + } + let last = u.len().strict_sub(1); + u[last] = private_i31_low_u32(u_carry.cast_unsigned()); + v[last] = private_i31_low_u32(v_carry.cast_unsigned()); + private_i31_finish_mod(u, modulus, private_i31_low_u32(u_carry.cast_unsigned() >> 63)); + private_i31_finish_mod(v, modulus, private_i31_low_u32(v_carry.cast_unsigned() >> 63)); +} + +fn private_i31_finish_mod(value: &mut [u32], modulus: &[u32], negative: u32) { + debug_assert_eq!(value.len(), modulus.len()); + let mut borrow = 0u32; + for index in 0..value.len() { + borrow = value[index].wrapping_sub(modulus[index]).wrapping_sub(borrow) >> 31; + } + + let negative_mask = 0u32.wrapping_sub(negative) >> 1; + let modulus_mask = 0u32.wrapping_sub(negative | 1u32.wrapping_sub(borrow)); + let mut carry = negative; + for index in 0..value.len() { + let modulus_word = (modulus[index] ^ negative_mask) & modulus_mask; + let reduced = value[index].wrapping_sub(modulus_word).wrapping_sub(carry); + value[index] = reduced & PRIVATE_I31_MASK; + carry = reduced >> 31; } - Ok(private_import_canonical_unsigned_be(out)) } fn private_exponentiate_representative( @@ -9603,32 +10261,6 @@ fn private_import_decrement_unsigned_be_to_fixed(bytes: &[u8], out: &mut [u8]) - } } -#[cfg(feature = "getrandom")] -fn private_sub_small_unsigned_be_to_fixed( - bytes: &[u8], - decrement: u8, - out: &mut [u8], -) -> Result<(), RsaPrivateOpError> { - if bytes.is_empty() || bytes.len() != out.len() || decrement == 0 { - return Err(RsaPrivateOpError::InvalidLength); - } - - out.copy_from_slice(bytes); - let mut borrow = u16::from(decrement); - for byte in out.iter_mut().rev() { - let low = (borrow & 0xff) as u8; - let (difference, overflow) = byte.overflowing_sub(low); - *byte = difference; - borrow = (borrow >> 8).strict_add(u16::from(overflow)); - } - - if borrow == 0 { - Ok(()) - } else { - Err(RsaPrivateOpError::InvalidLength) - } -} - #[cfg(feature = "getrandom")] fn private_import_unsigned_be_mod(value: &[u8], modulus: &[u8]) -> SecretBigEndianBuffer { if is_zero_unsigned_be(modulus) { @@ -11331,10 +11963,59 @@ f70203010001a3533051301d0603551d0e04160414fd0e576ce3f05b08884ad67ef3e8b4d39039c6 one: SecretBigEndianBuffer::zeroed(wrong_len), public_scratch: key.public_key().public_scratch(), mul_scratch: RsaPrivateMulScratch::new(key.components.public.modulus.limbs.len()), + inverse_scratch: RsaPrivateInverseScratch::new(key.components.public.modulus.bits), exponent_scratch: RsaPrivateExponentScratch::new(key.components.public.modulus.limbs.len()), } } + fn assert_private_scratch_is_clear(scratch: &RsaPrivateScratch) { + for bytes in [ + scratch.encoded.as_slice(), + scratch.salt.as_slice(), + scratch.blinding_factor.as_slice(), + scratch.blinding_inverse.as_slice(), + scratch.blinding_power.as_slice(), + scratch.blinded.as_slice(), + scratch.blinded_private_result.as_slice(), + scratch.checked.as_slice(), + scratch.one.as_slice(), + &scratch.public_scratch.bytes, + ] { + assert!(bytes.iter().all(|&byte| byte == 0)); + } + for words in [ + &scratch.public_scratch.limbs[..], + scratch.mul_scratch.t.as_slice(), + scratch.mul_scratch.left_limbs.as_slice(), + scratch.mul_scratch.right_limbs.as_slice(), + scratch.mul_scratch.left_mont.as_slice(), + scratch.mul_scratch.right_mont.as_slice(), + scratch.mul_scratch.product_mont.as_slice(), + scratch.mul_scratch.product.as_slice(), + scratch.exponent_scratch.t.as_slice(), + scratch.exponent_scratch.representative.as_slice(), + scratch.exponent_scratch.one.as_slice(), + scratch.exponent_scratch.base.as_slice(), + scratch.exponent_scratch.acc.as_slice(), + scratch.exponent_scratch.squared.as_slice(), + scratch.exponent_scratch.multiplied.as_slice(), + scratch.exponent_scratch.selected.as_slice(), + scratch.exponent_scratch.reduced.as_slice(), + scratch.exponent_scratch.table.as_slice(), + ] { + assert!(words.iter().all(|&word| word == 0)); + } + for words in [ + scratch.inverse_scratch.a.as_slice(), + scratch.inverse_scratch.b.as_slice(), + scratch.inverse_scratch.u.as_slice(), + scratch.inverse_scratch.v.as_slice(), + scratch.inverse_scratch.modulus.as_slice(), + ] { + assert!(words.iter().all(|&word| word == 0)); + } + } + #[test] fn pkcs1_private_key_parser_preserves_components_and_public_key() { let der = test_pkcs1_private_key(); @@ -11711,6 +12392,312 @@ f70203010001a3533051301d0603551d0e04160414fd0e576ce3f05b08884ad67ef3e8b4d39039c6 assert_eq!(&pkcs1v15_decrypted[..pkcs1v15_decrypted_len], pkcs1v15_plaintext); } + #[test] + fn private_key_caller_random_signing_rejects_candidates_then_roundtrips() { + let key = prevalidated_test_private_key(); + let message = b"rscrypto caller-random rejection stream"; + let (factor, _) = factor_two_and_inverse(key.public_key().modulus()); + let mut modulus_candidate = key.public_key().modulus().to_vec(); + let mut noninvertible_candidate = vec![0u8; key.signature_len()]; + left_pad_be(&rsa_private_prime_p(), &mut noninvertible_candidate).expect("prime factor must fit modulus width"); + let salt = [0x5au8; Sha256::OUTPUT_SIZE]; + let mut calls = 0usize; + let mut signature = vec![0u8; key.signature_len()]; + let mut scratch = key.private_scratch(); + + key + .sign_pss_with_random_fill_and_scratch(RsaPssProfile::Sha256, message, &mut signature, &mut scratch, |out| { + match calls { + 0 => { + assert_eq!(out.len(), salt.len()); + out.copy_from_slice(&salt); + } + 1 => out.fill(0), + 2 => out.copy_from_slice(&modulus_candidate), + 3 => out.copy_from_slice(&noninvertible_candidate), + 4 => out.copy_from_slice(&factor), + _ => return Err(()), + } + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }) + .expect("a deterministic stream ending in an invertible factor must sign"); + + modulus_candidate.fill(0); + assert_eq!(calls, 5); + key + .public_key() + .verify_pss(RsaPssProfile::Sha256, message, &signature) + .expect("the caller-random signature must verify"); + assert_private_scratch_is_clear(&scratch); + } + + #[test] + fn private_key_caller_random_signing_covers_profiles_and_tls_mappings() { + let key = prevalidated_test_private_key(); + let message = b"rscrypto caller-random RSA profile coverage"; + let (factor, _) = factor_two_and_inverse(key.public_key().modulus()); + let mut signature = vec![0u8; key.signature_len()]; + let mut scratch = key.private_scratch(); + + for profile in [RsaPssProfile::Sha256, RsaPssProfile::Sha384, RsaPssProfile::Sha512] { + let mut calls = 0usize; + key + .sign_pss_with_random_fill_and_scratch(profile, message, &mut signature, &mut scratch, |out| { + if calls == 0 { + assert_eq!(out.len(), profile.digest_len()); + out.fill(0x3c); + } else { + assert_eq!(out.len(), factor.len()); + out.copy_from_slice(&factor); + } + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }) + .expect("every supported PSS profile must sign with caller entropy"); + assert_eq!(calls, 2); + key + .public_key() + .verify_pss(profile, message, &signature) + .expect("every supported caller-random PSS signature must verify"); + assert_private_scratch_is_clear(&scratch); + } + + for scheme in [0x0804, 0x0805, 0x0806, 0x0809, 0x080a, 0x080b] { + let profile = RsaSignatureProfile::from_tls13_signature_scheme(scheme) + .expect("the TLS PSS scheme fixture must map to a profile"); + let mut calls = 0usize; + key + .sign_tls13_signature_scheme_with_random_fill_and_scratch( + scheme, + message, + &mut signature, + &mut scratch, + |out| { + if calls == 0 { + out.fill(0x7d); + } else { + out.copy_from_slice(&factor); + } + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }, + ) + .expect("TLS RSAE-PSS and PSS-PSS schemes must sign with caller entropy"); + assert_eq!(calls, 2); + key + .public_key() + .verify_signature(profile, message, &signature) + .expect("the caller-random TLS PSS signature must verify"); + assert_private_scratch_is_clear(&scratch); + } + + for scheme in [0x0401, 0x0501, 0x0601] { + let profile = RsaSignatureProfile::from_tls_certificate_signature_scheme(scheme) + .expect("the TLS certificate RSA scheme fixture must map to a profile"); + let mut calls = 0usize; + key + .sign_tls_certificate_signature_scheme_with_random_fill_and_scratch( + scheme, + message, + &mut signature, + &mut scratch, + |out| { + assert_eq!(out.len(), factor.len()); + out.copy_from_slice(&factor); + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }, + ) + .expect("TLS certificate PKCS#1 v1.5 schemes must sign with caller entropy"); + assert_eq!(calls, 1); + key + .public_key() + .verify_signature(profile, message, &signature) + .expect("the caller-random TLS certificate signature must verify"); + assert_private_scratch_is_clear(&scratch); + } + + let explicit_salt = RsaSignatureProfile::pss_with_salt_len(RsaPssProfile::Sha512, 24); + let mut calls = 0usize; + key + .sign_signature_with_random_fill(explicit_salt, message, &mut signature, |out| { + if calls == 0 { + assert_eq!(out.len(), 24); + out.fill(0x91); + } else { + out.copy_from_slice(&factor); + } + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }) + .expect("the general profile boundary must preserve an explicit PSS salt length"); + assert_eq!(calls, 2); + key + .public_key() + .verify_signature(explicit_salt, message, &signature) + .expect("the explicit-salt caller-random signature must verify"); + } + + #[test] + fn private_key_caller_random_failures_are_bounded_opaque_and_clear_state() { + #[derive(Debug)] + struct CallbackError; + + let key = prevalidated_test_private_key(); + let message = b"rscrypto caller-random failure schedule"; + let profile = RsaSignatureProfile::pkcs1v15(RsaPkcs1v15Profile::Sha256); + let mut signature = vec![0xa5; key.signature_len()]; + let mut scratch = key.private_scratch(); + + for fail_at in 0usize..128 { + signature.fill(0xa5); + let mut calls = 0usize; + assert_eq!( + key.sign_signature_with_random_fill_and_scratch(profile, message, &mut signature, &mut scratch, |out| { + let call = calls; + calls = calls.strict_add(1); + out.fill(0xa5); + if call == fail_at { + Err(CallbackError) + } else { + out.fill(0); + Ok(()) + } + },), + Err(RsaPrivateOpError::EntropyUnavailable) + ); + assert_eq!(calls, fail_at.strict_add(1)); + assert!(is_zero_unsigned_be(&signature)); + assert_private_scratch_is_clear(&scratch); + } + + signature.fill(0xa5); + let mut calls = 0usize; + assert_eq!( + key.sign_signature_with_random_fill_and_scratch(profile, message, &mut signature, &mut scratch, |out| { + calls = calls.strict_add(1); + out.fill(0); + Ok::<(), CallbackError>(()) + }), + Err(RsaPrivateOpError::InvalidBlindingFactor) + ); + assert_eq!(calls, 128); + assert!(is_zero_unsigned_be(&signature)); + assert_private_scratch_is_clear(&scratch); + + let (factor, _) = factor_two_and_inverse(key.public_key().modulus()); + key + .sign_signature_with_random_fill_and_scratch(profile, message, &mut signature, &mut scratch, |out| { + out.copy_from_slice(&factor); + Ok::<(), CallbackError>(()) + }) + .expect("scratch must be reusable after callback failure and retry exhaustion"); + key + .public_key() + .verify_signature(profile, message, &signature) + .expect("a signature after scratch reuse must verify"); + assert_private_scratch_is_clear(&scratch); + } + + #[test] + fn private_key_caller_random_rejects_public_shape_before_entropy() { + let key = prevalidated_test_private_key(); + let message = b"rscrypto caller-random public rejection"; + let mut signature = vec![0xa5; key.signature_len()]; + let mut scratch = key.private_scratch(); + + let mut calls = 0usize; + let impossible = RsaSignatureProfile::pss_with_salt_len(RsaPssProfile::Sha512, usize::MAX); + assert_eq!( + key.sign_signature_with_random_fill_and_scratch(impossible, message, &mut signature, &mut scratch, |_| { + calls = calls.strict_add(1); + Ok::<(), ()>(()) + },), + Err(RsaPrivateOpError::MessageTooLong) + ); + assert_eq!(calls, 0); + assert!(is_zero_unsigned_be(&signature)); + assert_private_scratch_is_clear(&scratch); + + signature.fill(0xa5); + assert_eq!( + key.sign_tls13_signature_scheme_with_random_fill(0x0401, message, &mut signature, |_| { + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }), + Err(RsaPrivateOpError::UnsupportedAlgorithm) + ); + assert_eq!(calls, 0); + assert!(is_zero_unsigned_be(&signature)); + + signature.fill(0xa5); + assert_eq!( + key.sign_tls_certificate_signature_scheme_with_random_fill_and_scratch( + 0x0201, + message, + &mut signature, + &mut scratch, + |_| { + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }, + ), + Err(RsaPrivateOpError::UnsupportedAlgorithm) + ); + assert_eq!(calls, 0); + assert!(is_zero_unsigned_be(&signature)); + assert_private_scratch_is_clear(&scratch); + + let mut short_signature = vec![0xa5; key.signature_len().strict_sub(1)]; + assert_eq!( + key.sign_pss_with_random_fill_and_scratch( + RsaPssProfile::Sha256, + message, + &mut short_signature, + &mut scratch, + |_| { + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }, + ), + Err(RsaPrivateOpError::InvalidLength) + ); + assert_eq!(calls, 0); + assert!(is_zero_unsigned_be(&short_signature)); + assert_private_scratch_is_clear(&scratch); + + signature.fill(0xa5); + let mut wrong_scratch = wrong_width_private_scratch(&key); + assert_eq!( + key.sign_pss_with_random_fill_and_scratch( + RsaPssProfile::Sha256, + message, + &mut signature, + &mut wrong_scratch, + |_| { + calls = calls.strict_add(1); + Ok::<(), ()>(()) + }, + ), + Err(RsaPrivateOpError::InvalidScratch) + ); + assert_eq!(calls, 0); + assert!(is_zero_unsigned_be(&signature)); + assert_private_scratch_is_clear(&wrong_scratch); + + signature.fill(0xa5); + assert_eq!( + key.sign_pss_with_random_fill_and_scratch(RsaPssProfile::Sha256, message, &mut signature, &mut scratch, |out| { + out.fill(0xa5); + Err::<(), ()>(()) + },), + Err(RsaPrivateOpError::EntropyUnavailable) + ); + assert!(is_zero_unsigned_be(&signature)); + assert_private_scratch_is_clear(&scratch); + } + #[test] fn public_encryption_random_fill_failures_clear_output() { let key = prevalidated_test_private_key(); @@ -12895,6 +13882,171 @@ f70203010001a3533051301d0603551d0e04160414fd0e576ce3f05b08884ad67ef3e8b4d39039c6 assert_eq!(check.last().copied(), Some(1)); } + #[test] + fn private_i31_fixed_work_multiply_matches_wrapping_products() { + const U64_EDGES: [u64; 8] = [0, 1, 2, 0xffff_ffff, 1u64 << 32, 1u64 << 63, u64::MAX - 1, u64::MAX]; + const U32_EDGES: [u32; 7] = [0, 1, 2, 0x7fff_ffff, 1u32 << 31, u32::MAX - 1, u32::MAX]; + + for lhs in U64_EDGES { + for rhs in U64_EDGES { + assert_eq!(private_i31_ct_mul_u64_low(lhs, rhs), lhs.wrapping_mul(rhs)); + } + } + for lhs in U32_EDGES { + for rhs in U32_EDGES { + assert_eq!(private_i31_ct_mul_u32_low(lhs, rhs), lhs.wrapping_mul(rhs)); + } + } + + let mut lhs = 0x243f_6a88_85a3_08d3u64; + let mut rhs = 0x1319_8a2e_0370_7344u64; + for _ in 0..1024 { + lhs = lhs.wrapping_mul(0x9e37_79b9_7f4a_7c15).wrapping_add(1); + rhs = rhs.wrapping_mul(0xd134_2543_de82_ef95).wrapping_add(1); + let lhs_low = private_i31_low_u32(lhs); + let rhs_low = private_i31_low_u32(rhs); + assert_eq!(private_i31_ct_mul_u64_low(lhs, rhs), lhs.wrapping_mul(rhs)); + assert_eq!( + private_i31_ct_mul_u32_low(lhs_low, rhs_low), + lhs_low.wrapping_mul(rhs_low) + ); + } + } + + #[test] + fn private_modular_inverse_matches_exhaustive_small_domain() { + for modulus_value in (3u16..=255).step_by(2) { + let modulus_byte = u8::try_from(modulus_value).expect("the exhaustive modulus is at most one byte"); + let modulus_bytes = [modulus_byte]; + let modulus = RsaPublicModulus::new(&modulus_bytes, unsigned_be_bit_len(&modulus_bytes)); + let mut scratch = RsaPrivateInverseScratch::new(modulus.bits); + + for input_value in 1u16..modulus_value { + let input = [u8::try_from(input_value).expect("the exhaustive input is at most one byte")]; + let mut out = [0xa5]; + let expected = (1u16..modulus_value).find(|candidate| input_value.strict_mul(*candidate) % modulus_value == 1); + let result = private_modular_inverse_odd(&modulus, &input, &mut out, &mut scratch); + + if let Some(expected) = expected { + assert_eq!(result, Ok(()), "inverse rejected for {input_value} mod {modulus_value}"); + assert_eq!( + u16::from(out[0]), + expected, + "wrong inverse for {input_value} mod {modulus_value}" + ); + } else { + assert_eq!( + result, + Err(RsaPrivateOpError::InvalidBlindingFactor), + "non-invertible input accepted for {input_value} mod {modulus_value}" + ); + assert_eq!(out, [0]); + } + for words in [ + scratch.a.as_slice(), + scratch.b.as_slice(), + scratch.u.as_slice(), + scratch.v.as_slice(), + scratch.modulus.as_slice(), + ] { + assert!(words.iter().all(|&word| word == 0)); + } + } + } + } + + #[test] + fn private_modular_inverse_matches_biguint_for_full_width_inputs() { + let modulus_bytes = rsa_private_modulus(); + let modulus = RsaPublicModulus::new(&modulus_bytes, unsigned_be_bit_len(&modulus_bytes)); + let modulus_oracle = rsa::BigUint::from_bytes_be(&modulus_bytes); + let mut scratch = RsaPrivateInverseScratch::new(modulus.bits); + let mut factor = vec![0u8; modulus_bytes.len()]; + let mut inverse = vec![0u8; modulus_bytes.len()]; + let mut state = 0x243f_6a88_85a3_08d3u64; + + for _ in 0..64 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let candidate = state | 1; + factor.fill(0); + let candidate_start = factor.len().strict_sub(8); + factor[candidate_start..].copy_from_slice(&candidate.to_be_bytes()); + + private_modular_inverse_odd(&modulus, &factor, &mut inverse, &mut scratch) + .expect("a nonzero 64-bit factor cannot share either 1024-bit prime factor"); + let product = rsa::BigUint::from(candidate) * rsa::BigUint::from_bytes_be(&inverse); + assert_eq!(product % &modulus_oracle, rsa::BigUint::from(1u8)); + for words in [ + scratch.a.as_slice(), + scratch.b.as_slice(), + scratch.u.as_slice(), + scratch.v.as_slice(), + scratch.modulus.as_slice(), + ] { + assert!(words.iter().all(|&word| word == 0)); + } + } + + // Exercise modulus-width factors independently of the small-candidate + // cases above. Since the RSA modulus is odd, every n - 2^k candidate is + // coprime to n and therefore has an inverse. + for sample in 0usize..64 { + let shift = sample.strict_mul(modulus.bits.strict_sub(2)) / 63; + let candidate = &modulus_oracle - (rsa::BigUint::from(1u8) << shift); + let candidate_bytes = candidate.to_bytes_be(); + factor.fill(0); + let candidate_start = factor.len().strict_sub(candidate_bytes.len()); + factor[candidate_start..].copy_from_slice(&candidate_bytes); + + private_modular_inverse_odd(&modulus, &factor, &mut inverse, &mut scratch) + .expect("n - 2^k must be invertible modulo an odd RSA modulus"); + let product = &candidate * rsa::BigUint::from_bytes_be(&inverse); + assert_eq!(product % &modulus_oracle, rsa::BigUint::from(1u8)); + for words in [ + scratch.a.as_slice(), + scratch.b.as_slice(), + scratch.u.as_slice(), + scratch.v.as_slice(), + scratch.modulus.as_slice(), + ] { + assert!(words.iter().all(|&word| word == 0)); + } + } + + factor.copy_from_slice(&modulus_bytes); + let mut borrow = 1u16; + for byte in factor.iter_mut().rev() { + let difference = u16::from(*byte).wrapping_sub(borrow); + *byte = difference.to_le_bytes()[0]; + borrow = u16::from(difference > 0xff); + } + private_modular_inverse_odd(&modulus, &factor, &mut inverse, &mut scratch) + .expect("n - 1 must be self-inverse modulo n"); + assert_eq!(inverse, factor); + + factor.fill(0); + let prime = rsa_private_prime_p(); + let prime_start = factor.len().strict_sub(prime.len()); + factor[prime_start..].copy_from_slice(&prime); + inverse.fill(0xa5); + assert_eq!( + private_modular_inverse_odd(&modulus, &factor, &mut inverse, &mut scratch), + Err(RsaPrivateOpError::InvalidBlindingFactor) + ); + assert!(inverse.iter().all(|&byte| byte == 0)); + for words in [ + scratch.a.as_slice(), + scratch.b.as_slice(), + scratch.u.as_slice(), + scratch.v.as_slice(), + scratch.modulus.as_slice(), + ] { + assert!(words.iter().all(|&word| word == 0)); + } + } + #[cfg(feature = "getrandom")] #[test] fn private_key_blinding_inverse_rejects_non_invertible_factor() { @@ -14294,7 +15446,7 @@ f70203010001a3533051301d0603551d0e04160414fd0e576ce3f05b08884ad67ef3e8b4d39039c6 ))] macro_rules! assert_aarch64_rsa_montgomery_backend_matches_portable { ($backend:ident) => { - for words in [32usize, 48, 64, 128] { + for words in [16usize, 24, 32, 48, 64, 128] { assert!($backend::supports_bignum_mont_words(words)); let modulus = rsa_montgomery_test_modulus(words); let a = rsa_montgomery_test_limbs(words, 0x243f_6a88_85a3_08d3); @@ -14324,6 +15476,10 @@ f70203010001a3533051301d0603551d0e04160414fd0e576ce3f05b08884ad67ef3e8b4d39039c6 mont_mul_cios_portable(&mut portable_out, &a, &b, &modulus, &mut portable_t); assert_limbs_match_as_bytes("mont_mul_in_place_left", words, &asm_left, &portable_out); + if words < 32 { + continue; + } + asm_t.fill(0); portable_t.fill(0); if words == 32 { @@ -14389,7 +15545,7 @@ f70203010001a3533051301d0603551d0e04160414fd0e576ce3f05b08884ad67ef3e8b4d39039c6 ))] #[test] fn x86_64_linux_rsa_montgomery_asm_matches_portable_across_supported_widths() { - for words in [32usize, 48, 64, 128] { + for words in [16usize, 24, 32, 48, 64, 128] { assert!( rsa_x86_64_asm::supports_bignum_mont_words(words), "RSA x86-64 assembly evidence requires BMI2 and ADX" diff --git a/src/auth/rsa_aarch64_asm.rs b/src/auth/rsa_aarch64_asm.rs index c8d02b7e..e98a2f01 100644 --- a/src/auth/rsa_aarch64_asm.rs +++ b/src/auth/rsa_aarch64_asm.rs @@ -38,7 +38,7 @@ unsafe extern "C" { #[inline] pub(super) fn supports_bignum_mont_words(words: usize) -> bool { - matches!(words, 32 | 48 | 64 | 128) + matches!(words, 16 | 24 | 32 | 48 | 64 | 128) } #[inline] @@ -65,9 +65,10 @@ pub(super) fn mont_mul_cios_words( // SAFETY: RSA Montgomery assembly call because: // 1. This module is compiled only for macOS AArch64 and embeds the matching Darwin symbol. - // 2. The caller checks all slices have one of the supported public RSA limb widths: 32, 48, 64, or - // 128 `u64` limbs. The `t` capacity check preserves the shared RSA helper contract; this kernel - // uses stack scratch internally. + // 2. The caller checks all slices have one of the supported RSA public or half-width CRT sizes: + // 16, 24, 32, 48, 64, or 128 `u64` limbs. Every width is a multiple of eight as required by the + // generated kernel's square path. The `t` capacity check preserves the shared RSA helper + // contract; this kernel uses stack scratch internally. // 3. All pointers are derived from live Rust slices and are valid for the assembly's fixed // read/write ranges. The assembly does not retain pointers after returning. // 4. `out` does not alias `a`, `b`, or `modulus` in current non-in-place callers. @@ -98,8 +99,9 @@ pub(super) fn mont_square_cios_words_in_place( // SAFETY: RSA in-place Montgomery square assembly call because: // 1. This module is compiled only for macOS AArch64 and embeds the matching Darwin symbol. - // 2. The caller checks `value` and `modulus` are 32, 48, 64, or 128 `u64` limbs. The `t` capacity - // check preserves the shared RSA helper contract; this kernel uses stack scratch internally. + // 2. The caller checks `value` and `modulus` are 16, 24, 32, 48, 64, or 128 `u64` limbs. Every + // width is a multiple of eight as required by the generated square path. The `t` capacity check + // preserves the shared RSA helper contract; this kernel uses stack scratch internally. // 3. `value.as_mut_ptr()` is intentionally passed as `out`, `a`, and `b`; the assembly consumes all // input limbs before writing `out`. // 4. All pointers are derived from live Rust slices and the assembly does not retain them. @@ -132,8 +134,9 @@ pub(super) fn mont_mul_cios_words_in_place_left( // SAFETY: RSA in-place Montgomery multiply assembly call because: // 1. This module is compiled only for macOS AArch64 and embeds the matching Darwin symbol. - // 2. The caller checks `left`, `right`, and `modulus` are 32, 48, 64, or 128 `u64` limbs. The `t` - // capacity check preserves the shared RSA helper contract; this kernel uses stack scratch + // 2. The caller checks `left`, `right`, and `modulus` are 16, 24, 32, 48, 64, or 128 `u64` limbs. + // Every width is a multiple of eight as required by the generated kernel's square path. The + // `t` capacity check preserves the shared RSA helper contract; this kernel uses stack scratch // internally. // 3. `left.as_mut_ptr()` is intentionally passed as both `out` and `a`; the assembly consumes all // input limbs before writing `out`. @@ -205,8 +208,9 @@ pub(super) fn public_e65537_mont_words( // SAFETY: RSA public e=65537 Montgomery chain because: // 1. This module is compiled only for macOS AArch64 and embeds the matching Darwin symbols. - // 2. The caller checks all slices have one of the supported public RSA limb widths: 32, 48, 64, or - // 128 `u64` limbs, with `t` sized to the shared scratch contract. + // 2. The caller checks all slices have one of the supported RSA public or half-width CRT sizes: + // 16, 24, 32, 48, 64, or 128 `u64` limbs, with `t` sized to the shared scratch contract. This + // public operation reaches only full public-key widths. // 3. `out` and `acc` are distinct caller-owned scratch limbs. `out` first preserves the Montgomery // base, while `acc` is squared in place and multiplied by that preserved base. // 4. All pointers are derived from live Rust slices and the assembly does not retain them. diff --git a/src/auth/rsa_aarch64_linux_asm.rs b/src/auth/rsa_aarch64_linux_asm.rs index 73872ed8..ece9ee08 100644 --- a/src/auth/rsa_aarch64_linux_asm.rs +++ b/src/auth/rsa_aarch64_linux_asm.rs @@ -38,7 +38,7 @@ unsafe extern "C" { #[inline] pub(super) fn supports_bignum_mont_words(words: usize) -> bool { - matches!(words, 32 | 48 | 64 | 128) + matches!(words, 16 | 24 | 32 | 48 | 64 | 128) } #[inline] @@ -65,9 +65,10 @@ pub(super) fn mont_mul_cios_words( // SAFETY: RSA Montgomery assembly call because: // 1. This module is compiled only for Linux AArch64 and embeds the matching ELF symbol. - // 2. The caller checks all slices have one of the supported public RSA limb widths: 32, 48, 64, or - // 128 `u64` limbs. The `t` capacity check preserves the shared RSA helper contract; this kernel - // uses stack scratch internally. + // 2. The caller checks all slices have one of the supported RSA public or half-width CRT sizes: + // 16, 24, 32, 48, 64, or 128 `u64` limbs. Every width is a multiple of eight as required by the + // generated kernel's square path. The `t` capacity check preserves the shared RSA helper + // contract; this kernel uses stack scratch internally. // 3. All pointers are derived from live Rust slices and are valid for the assembly's fixed // read/write ranges. The assembly does not retain pointers after returning. // 4. `out` does not alias `a`, `b`, or `modulus` in current non-in-place callers. @@ -98,8 +99,9 @@ pub(super) fn mont_square_cios_words_in_place( // SAFETY: RSA in-place Montgomery square assembly call because: // 1. This module is compiled only for Linux AArch64 and embeds the matching ELF symbol. - // 2. The caller checks `value` and `modulus` are 32, 48, 64, or 128 `u64` limbs. The `t` capacity - // check preserves the shared RSA helper contract; this kernel uses stack scratch internally. + // 2. The caller checks `value` and `modulus` are 16, 24, 32, 48, 64, or 128 `u64` limbs. Every + // width is a multiple of eight as required by the generated square path. The `t` capacity check + // preserves the shared RSA helper contract; this kernel uses stack scratch internally. // 3. `value.as_mut_ptr()` is intentionally passed as `out`, `a`, and `b`; the assembly consumes all // input limbs before writing `out`. // 4. All pointers are derived from live Rust slices and the assembly does not retain them. @@ -132,8 +134,9 @@ pub(super) fn mont_mul_cios_words_in_place_left( // SAFETY: RSA in-place Montgomery multiply assembly call because: // 1. This module is compiled only for Linux AArch64 and embeds the matching ELF symbol. - // 2. The caller checks `left`, `right`, and `modulus` are 32, 48, 64, or 128 `u64` limbs. The `t` - // capacity check preserves the shared RSA helper contract; this kernel uses stack scratch + // 2. The caller checks `left`, `right`, and `modulus` are 16, 24, 32, 48, 64, or 128 `u64` limbs. + // Every width is a multiple of eight as required by the generated kernel's square path. The + // `t` capacity check preserves the shared RSA helper contract; this kernel uses stack scratch // internally. // 3. `left.as_mut_ptr()` is intentionally passed as both `out` and `a`; the assembly consumes all // input limbs before writing `out`. @@ -205,8 +208,9 @@ pub(super) fn public_e65537_mont_words( // SAFETY: RSA public e=65537 Montgomery chain because: // 1. This module is compiled only for Linux AArch64 and embeds the matching ELF symbols. - // 2. The caller checks all slices have one of the supported public RSA limb widths: 32, 48, 64, or - // 128 `u64` limbs, with `t` sized to the shared scratch contract. + // 2. The caller checks all slices have one of the supported RSA public or half-width CRT sizes: + // 16, 24, 32, 48, 64, or 128 `u64` limbs, with `t` sized to the shared scratch contract. This + // public operation reaches only full public-key widths. // 3. `out` and `acc` are distinct caller-owned scratch limbs. `out` first preserves the Montgomery // base, while `acc` is squared in place and multiplied by that preserved base. // 4. All pointers are derived from live Rust slices and the assembly does not retain them. diff --git a/src/auth/rsa_x86_64_asm.rs b/src/auth/rsa_x86_64_asm.rs index 224e2e9a..7001beb5 100644 --- a/src/auth/rsa_x86_64_asm.rs +++ b/src/auth/rsa_x86_64_asm.rs @@ -31,12 +31,12 @@ unsafe extern "C" { #[inline] pub(super) fn supports_bignum_mont_words(words: usize) -> bool { - matches!(words, 32 | 48 | 64 | 128) && caps().has(x86::BMI2) && caps().has(x86::ADX) + matches!(words, 16 | 24 | 32 | 48 | 64 | 128) && caps().has(x86::BMI2) && caps().has(x86::ADX) } #[inline] pub(super) fn supports_bignum_mont_square_words(words: usize) -> bool { - matches!(words, 32 | 48 | 64 | 128) && caps().has(x86::BMI2) && caps().has(x86::ADX) + matches!(words, 16 | 24 | 32 | 48 | 64 | 128) && caps().has(x86::BMI2) && caps().has(x86::ADX) } #[inline] @@ -65,9 +65,10 @@ pub(super) fn mont_mul_cios_words( // 1. This module is compiled only for Linux x86-64 and embeds the matching ELF symbol. // 2. `supports_bignum_mont_words` checks BMI2 and ADX before this call, matching the kernel's // `mulx/adcx/adox` instruction requirements. - // 3. The caller checks all slices have one of the supported public RSA limb widths: 32, 48, 64, or - // 128 `u64` limbs. The `t` capacity check preserves the shared RSA helper contract; this kernel - // uses stack scratch internally. + // 3. The caller checks all slices have one of the supported RSA public or half-width CRT sizes: + // 16, 24, 32, 48, 64, or 128 `u64` limbs. Every width is a multiple of four for the multiply + // kernel and of eight for the square kernel. The `t` capacity check preserves the shared RSA + // helper contract; this kernel uses stack scratch internally. // 4. All pointers are derived from live Rust slices and are valid for the assembly's fixed // read/write ranges. The assembly does not retain pointers after returning. let _ = unsafe { @@ -136,9 +137,10 @@ pub(super) fn mont_mul_cios_words_in_place_left( // 1. This module is compiled only for Linux x86-64 and embeds the matching ELF symbol. // 2. `supports_bignum_mont_words` checks BMI2 and ADX before this call, matching the kernel's // `mulx/adcx/adox` instruction requirements. - // 3. The caller checks `left`, `right`, and `modulus` are 32, 48, 64, or 128 `u64` limbs. The - // kernel writes its result after consuming operands into internal stack scratch, so passing - // `left` as both `out` and `a` preserves the in-place helper contract. + // 3. The caller checks `left`, `right`, and `modulus` are 16, 24, 32, 48, 64, or 128 `u64` limbs. + // Every width is a multiple of four for the multiply kernel. The kernel writes its result after + // consuming operands into internal stack scratch, so passing `left` as both `out` and `a` + // preserves the in-place helper contract. // 4. All pointers are derived from live Rust slices and the assembly does not retain them. let _ = unsafe { rscrypto_rsa_bn_mulx4x_mont_x86_64_elf( diff --git a/src/auth/scrypt.rs b/src/auth/scrypt.rs index deb05c2b..bed4ef40 100644 --- a/src/auth/scrypt.rs +++ b/src/auth/scrypt.rs @@ -334,12 +334,12 @@ mod x86_sse2 { #[inline] fn shuffle_words(blocks: &mut [u8], pivot: &[usize; 16]) { debug_assert_eq!(blocks.len() % super::BLOCK_SIZE, 0); - for chunk in blocks.chunks_exact_mut(super::BLOCK_SIZE) { + for chunk in blocks.as_chunks_mut::<{ super::BLOCK_SIZE }>().0 { let mut words = [0u32; super::BLOCK_WORDS]; for (src, word) in chunk.as_chunks::<4>().0.iter().zip(&mut words) { *word = u32::from_le_bytes(*src); } - for (i, dst) in chunk.chunks_exact_mut(4).enumerate() { + for (i, dst) in chunk.as_chunks_mut::<4>().0.iter_mut().enumerate() { dst.copy_from_slice(&words[pivot[i]].to_le_bytes()); } } @@ -382,7 +382,7 @@ mod x86_sse2 { let mut c = _mm_loadu_si128(last.as_ptr().add(32).cast()); let mut d = _mm_loadu_si128(last.as_ptr().add(48).cast()); - for (i, chunk) in input.chunks_exact(super::BLOCK_SIZE).enumerate() { + for (i, chunk) in input.as_chunks::<{ super::BLOCK_SIZE }>().0.iter().enumerate() { let pos = if i & 1 == 0 { (i >> 1).strict_mul(super::BLOCK_SIZE) } else { diff --git a/src/checksum/crc32/aarch64.rs b/src/checksum/crc32/aarch64.rs index f594b125..e0ec4429 100644 --- a/src/checksum/crc32/aarch64.rs +++ b/src/checksum/crc32/aarch64.rs @@ -96,7 +96,7 @@ unsafe fn crc32_armv8(crc: u32, data: &[u8]) -> u32 { let mut len = data.len(); // Align to 8-byte boundary for the hot loop. - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { state = __crc32b(state, *buf); buf = buf.add(1); len = len.strict_sub(1); @@ -170,7 +170,7 @@ unsafe fn crc32c_armv8(crc: u32, data: &[u8]) -> u32 { let mut len = data.len(); // Align to 8-byte boundary for the hot loop. - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { state = __crc32cb(state, *buf); buf = buf.add(1); len = len.strict_sub(1); @@ -727,13 +727,13 @@ unsafe fn crc32c_iscsi_pmull_v12e_v1(mut crc0: u32, mut buf: *const u8, mut len: // All pointer arithmetic stays within bounds via loop guards on `len`. // All SIMD intrinsics operate on valid register values after loads. unsafe { - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { crc0 = __crc32cb(crc0, *buf); buf = buf.add(1); len = len.strict_sub(1); } - if (buf as usize & 8) != 0 && len >= 8 { + if (buf.addr() & 8) != 0 && len >= 8 { crc0 = __crc32cd(crc0, load_u64(buf)); buf = buf.add(8); len = len.strict_sub(8); @@ -892,13 +892,13 @@ unsafe fn crc32_iso_hdlc_pmull_v12e_v1(mut crc0: u32, mut buf: *const u8, mut le // All pointer arithmetic stays within bounds via loop guards on `len`. // All SIMD intrinsics operate on valid register values after loads. unsafe { - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { crc0 = __crc32b(crc0, *buf); buf = buf.add(1); len = len.strict_sub(1); } - if (buf as usize & 8) != 0 && len >= 8 { + if (buf.addr() & 8) != 0 && len >= 8 { crc0 = __crc32d(crc0, load_u64(buf)); buf = buf.add(8); len = len.strict_sub(8); @@ -1069,13 +1069,13 @@ unsafe fn crc32c_iscsi_pmull_v9s3x2e_s3(mut crc0: u32, mut buf: *const u8, mut l // All SIMD intrinsics operate on valid register values after loads. unsafe { // Non-EOR3 equivalent of fast-crc32 neon_eor3 CRC32C (v9s3x2e_s3). - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { crc0 = __crc32cb(crc0, *buf); buf = buf.add(1); len = len.strict_sub(1); } - if (buf as usize & 8) != 0 && len >= 8 { + if (buf.addr() & 8) != 0 && len >= 8 { crc0 = __crc32cd(crc0, load_u64(buf)); buf = buf.add(8); len = len.strict_sub(8); @@ -1275,13 +1275,13 @@ unsafe fn crc32_iso_hdlc_pmull_v9s3x2e_s3(mut crc0: u32, mut buf: *const u8, mut // All SIMD intrinsics operate on valid register values after loads. unsafe { // Non-EOR3 equivalent of fast-crc32 neon_eor3 ISO-HDLC (v9s3x2e_s3). - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { crc0 = __crc32b(crc0, *buf); buf = buf.add(1); len = len.strict_sub(1); } - if (buf as usize & 8) != 0 && len >= 8 { + if (buf.addr() & 8) != 0 && len >= 8 { crc0 = __crc32d(crc0, load_u64(buf)); buf = buf.add(8); len = len.strict_sub(8); @@ -1483,13 +1483,13 @@ unsafe fn crc32c_iscsi_pmull_eor3_v9s3x2e_s3(mut crc0: u32, mut buf: *const u8, // All SIMD intrinsics operate on valid register values after loads. unsafe { // Ported from fast-crc32 neon_eor3 CRC32C (v9s3x2e_s3). - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { crc0 = __crc32cb(crc0, *buf); buf = buf.add(1); len = len.strict_sub(1); } - if (buf as usize & 8) != 0 && len >= 8 { + if (buf.addr() & 8) != 0 && len >= 8 { crc0 = __crc32cd(crc0, load_u64(buf)); buf = buf.add(8); len = len.strict_sub(8); @@ -1740,13 +1740,13 @@ unsafe fn crc32_iso_hdlc_pmull_eor3_v9s3x2e_s3(mut crc0: u32, mut buf: *const u8 // All SIMD intrinsics operate on valid register values after loads. unsafe { // Ported from fast-crc32 neon_eor3 ISO-HDLC (v9s3x2e_s3). - while len > 0 && (buf as usize & 7) != 0 { + while len > 0 && (buf.addr() & 7) != 0 { crc0 = __crc32b(crc0, *buf); buf = buf.add(1); len = len.strict_sub(1); } - if (buf as usize & 8) != 0 && len >= 8 { + if (buf.addr() & 8) != 0 && len >= 8 { crc0 = __crc32d(crc0, load_u64(buf)); buf = buf.add(8); len = len.strict_sub(8); diff --git a/src/hashes/crypto/sha384/kernel_test.rs b/src/hashes/crypto/sha384/kernel_test.rs index 798388a3..6c140c88 100644 --- a/src/hashes/crypto/sha384/kernel_test.rs +++ b/src/hashes/crypto/sha384/kernel_test.rs @@ -50,7 +50,7 @@ fn digest_oneshot_with_kernel(id: Sha384KernelId, data: &[u8]) -> [u8; 48] { compress(&mut state, &block); let mut out = [0u8; 48]; - for (chunk, &word) in out.chunks_exact_mut(8).zip(state.iter()) { + for (chunk, &word) in out.as_chunks_mut::<8>().0.iter_mut().zip(state.iter()) { chunk.copy_from_slice(&word.to_be_bytes()); } out diff --git a/src/hashes/crypto/sha512/kernel_test.rs b/src/hashes/crypto/sha512/kernel_test.rs index 63275d98..de5e256f 100644 --- a/src/hashes/crypto/sha512/kernel_test.rs +++ b/src/hashes/crypto/sha512/kernel_test.rs @@ -50,7 +50,7 @@ fn digest_oneshot_with_kernel(id: Sha512KernelId, data: &[u8]) -> [u8; 64] { compress(&mut state, &block); let mut out = [0u8; 64]; - for (chunk, &word) in out.chunks_exact_mut(8).zip(state.iter()) { + for (chunk, &word) in out.as_chunks_mut::<8>().0.iter_mut().zip(state.iter()) { chunk.copy_from_slice(&word.to_be_bytes()); } out diff --git a/src/hashes/crypto/sha512_256/kernel_test.rs b/src/hashes/crypto/sha512_256/kernel_test.rs index 1d13ac90..2b9716cc 100644 --- a/src/hashes/crypto/sha512_256/kernel_test.rs +++ b/src/hashes/crypto/sha512_256/kernel_test.rs @@ -50,7 +50,7 @@ fn digest_oneshot_with_kernel(id: Sha512_256KernelId, data: &[u8]) -> [u8; 32] { compress(&mut state, &block); let mut out = [0u8; 32]; - for (chunk, &word) in out.chunks_exact_mut(8).zip(state.iter()) { + for (chunk, &word) in out.as_chunks_mut::<8>().0.iter_mut().zip(state.iter()) { chunk.copy_from_slice(&word.to_be_bytes()); } out diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 00950740..15ab544c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -7,7 +7,9 @@ pub(crate) fn decode_hex_vec(hex: &str) -> Vec { assert_eq!(hex.len() % 2, 0, "hex length must be even"); hex .as_bytes() - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { let high = nibble(pair[0]).expect("hex input must contain only hexadecimal digits"); let low = nibble(pair[1]).expect("hex input must contain only hexadecimal digits"); diff --git a/tests/mlkem_acvp.rs b/tests/mlkem_acvp.rs index de0d781e..fa44c39d 100644 --- a/tests/mlkem_acvp.rs +++ b/tests/mlkem_acvp.rs @@ -98,7 +98,7 @@ fn decode_hex(hex: &str) -> Vec { assert_eq!(hex.len() % 2, 0, "hex input must have even length"); let mut out = Vec::with_capacity(hex.len() / 2); - for pair in hex.as_bytes().chunks_exact(2) { + for pair in hex.as_bytes().as_chunks::<2>().0 { let high = hex_nibble(pair[0]).expect("ACVP fixture must contain only hexadecimal digits"); let low = hex_nibble(pair[1]).expect("ACVP fixture must contain only hexadecimal digits"); out.push((high << 4) | low); diff --git a/tests/root_surface.rs b/tests/root_surface.rs index 266339c3..49b20392 100644 --- a/tests/root_surface.rs +++ b/tests/root_surface.rs @@ -414,6 +414,33 @@ fn root_surface_rsa_exports_compile() { assert_eq!(advertised.len(), 3); assert!(advertised.contains(0x0804)); let _maximum_scheme_count = RsaTlsSignatureSchemes::MAX_LEN; + fn consume_result(_: Result) {} + let _caller_random_signing_surface = |key: &RsaPrivateKey, out: &mut [u8], scratch: &mut RsaPrivateScratch| { + let message = b"root-surface-caller-random-rsa"; + let profile = RsaSignatureProfile::pss(RsaPssProfile::Sha256); + consume_result(key.sign_signature_with_random_fill(profile, message, out, |_| Ok::<(), ()>(()))); + consume_result( + key.sign_signature_with_random_fill_and_scratch(profile, message, out, scratch, |_| Ok::<(), ()>(())), + ); + consume_result(key.sign_pss_with_random_fill(RsaPssProfile::Sha256, message, out, |_| Ok::<(), ()>(()))); + consume_result( + key.sign_pss_with_random_fill_and_scratch(RsaPssProfile::Sha256, message, out, scratch, |_| Ok::<(), ()>(())), + ); + consume_result(key.sign_tls13_signature_scheme_with_random_fill(0x0804, message, out, |_| Ok::<(), ()>(()))); + consume_result( + key.sign_tls13_signature_scheme_with_random_fill_and_scratch(0x0804, message, out, scratch, |_| Ok::<(), ()>(())), + ); + consume_result( + key.sign_tls_certificate_signature_scheme_with_random_fill(0x0401, message, out, |_| Ok::<(), ()>(())), + ); + consume_result(key.sign_tls_certificate_signature_scheme_with_random_fill_and_scratch( + 0x0401, + message, + out, + scratch, + |_| Ok::<(), ()>(()), + )); + }; } #[test] diff --git a/tests/rsa_allocations.rs b/tests/rsa_allocations.rs index f45d0edf..b54afe9c 100644 --- a/tests/rsa_allocations.rs +++ b/tests/rsa_allocations.rs @@ -533,6 +533,7 @@ fn reused_scratch_rsa_operations_do_not_allocate() { assert_one_shot_protocol_rejects_fail_before_scratch_allocation(); let private_key = private_key(); + assert_private_caller_random_rejects_fail_before_scratch_allocation(&private_key); #[cfg(feature = "getrandom")] assert_private_protocol_signing_rejects_fail_before_entropy_allocation(&private_key); assert_private_scratch_operations_do_not_allocate(&private_key); @@ -540,6 +541,29 @@ fn reused_scratch_rsa_operations_do_not_allocate() { assert_rng_private_scratch_operations_do_not_allocate(&private_key); } +fn assert_private_caller_random_rejects_fail_before_scratch_allocation(key: &RsaPrivateKey) { + let mut signature = vec![0xa5; key.signature_len()]; + let mut entropy_calls = 0usize; + + reset_allocations(); + assert!( + key + .sign_signature_with_random_fill( + RsaSignatureProfile::pss_with_salt_len(RsaPssProfile::Sha256, usize::MAX), + b"private caller-random allocation reject", + &mut signature, + |_| { + entropy_calls = entropy_calls.strict_add(1); + Ok::<(), ()>(()) + }, + ) + .is_err() + ); + assert_eq!(allocation_count(), 0); + assert_eq!(entropy_calls, 0); + assert!(signature.iter().all(|&byte| byte == 0)); +} + #[cfg(feature = "getrandom")] fn assert_private_protocol_signing_rejects_fail_before_entropy_allocation(key: &RsaPrivateKey) { let mut scratch = key.private_scratch(); @@ -688,6 +712,28 @@ fn assert_private_scratch_operations_do_not_allocate(key: &RsaPrivateKey) { .expect("scratch-backed blinded PSS signing must succeed"); assert_eq!(allocation_count(), 0); + let mut entropy_calls = 0usize; + reset_allocations(); + key + .sign_pss_with_random_fill_and_scratch( + RsaPssProfile::Sha256, + b"private scratch allocation caller-random PSS", + &mut signature, + &mut scratch, + |out| { + if entropy_calls == 0 { + out.fill(0x39); + } else { + out.copy_from_slice(&blinding_factor); + } + entropy_calls = entropy_calls.strict_add(1); + Ok::<(), ()>(()) + }, + ) + .expect("scratch-backed caller-random PSS signing must succeed"); + assert_eq!(entropy_calls, 2); + assert_eq!(allocation_count(), 0); + let label = b"private-scratch-allocation"; let plaintext = b"private scratch allocation OAEP"; let seed = [0x52; 32]; diff --git a/tests/rsa_leakage.rs b/tests/rsa_leakage.rs index 2f86cb87..a5a1f2e7 100644 --- a/tests/rsa_leakage.rs +++ b/tests/rsa_leakage.rs @@ -153,7 +153,7 @@ where fn hex_to_vec(hex: &str) -> Vec { assert_eq!(hex.len() % 2, 0); let mut out = Vec::with_capacity(hex.len() / 2); - for chunk in hex.as_bytes().chunks_exact(2) { + for chunk in hex.as_bytes().as_chunks::<2>().0 { let high = hex_value(chunk[0]).expect("leakage fixture must contain hexadecimal digits"); let low = hex_value(chunk[1]).expect("leakage fixture must contain hexadecimal digits"); out.push((high << 4) | low); diff --git a/tests/rsa_nist_cavp.rs b/tests/rsa_nist_cavp.rs index afe96076..70c56cba 100644 --- a/tests/rsa_nist_cavp.rs +++ b/tests/rsa_nist_cavp.rs @@ -88,7 +88,7 @@ fn hex_to_vec(hex: &str) -> Vec { }; let mut out = Vec::with_capacity(hex.len() / 2); - for chunk in hex.as_bytes().chunks_exact(2) { + for chunk in hex.as_bytes().as_chunks::<2>().0 { let high = hex_value(chunk[0]).expect("CAVP fixture must contain hexadecimal digits"); let low = hex_value(chunk[1]).expect("CAVP fixture must contain hexadecimal digits"); out.push((high << 4) | low); diff --git a/tests/rsa_public_key.rs b/tests/rsa_public_key.rs index 6f5dea71..b3a1befc 100644 --- a/tests/rsa_public_key.rs +++ b/tests/rsa_public_key.rs @@ -1493,6 +1493,77 @@ fn private_key_der_exports_roundtrip_with_rustcrypto_rsa() { assert_eq!(rustcrypto_public_from_spki.e(), rustcrypto_key.e()); } +#[test] +fn caller_random_signatures_verify_with_independent_implementations_without_getrandom() { + #[derive(Debug)] + struct ExternalEntropyError; + + let rustcrypto_key = rustcrypto_fixture_private_key(); + let rustcrypto_pkcs1 = + EncodeRsaPrivateKey::to_pkcs1_der(&rustcrypto_key).expect("the RustCrypto fixture must encode as PKCS#1"); + let key = + RsaPrivateKey::from_pkcs1_der_with_policy(rustcrypto_pkcs1.as_bytes(), &RsaPublicKeyPolicy::legacy_verification()) + .expect("rscrypto must decode the RustCrypto private-key fixture"); + let public_pkcs1 = key.public_key().to_pkcs1_der(); + let public_spki = key.public_key().to_spki_der(); + let message = b"rscrypto caller-random signing external-oracle check"; + let mut factor = vec![0u8; key.signature_len()]; + *factor.last_mut().expect("an RSA modulus has a final byte") = 2; + let mut signature = vec![0u8; key.signature_len()]; + + let mut calls = 0usize; + key + .sign_tls13_signature_scheme_with_random_fill(0x0804, message, &mut signature, |out| { + if calls == 0 { + assert_eq!(out.len(), 32); + out.fill(0x6d); + } else { + assert_eq!(out.len(), factor.len()); + out.copy_from_slice(&factor); + } + calls = calls.strict_add(1); + Ok::<(), ExternalEntropyError>(()) + }) + .expect("TLS RSA-PSS signing with caller entropy must succeed"); + assert_eq!(calls, 2); + key + .public_key() + .verify_pss(RsaPssProfile::Sha256, message, &signature) + .expect("rscrypto must verify its caller-random PSS signature"); + assert_rustcrypto_pss_sha256(&public_pkcs1, message, &signature, true); + assert_ring_cavp("pss", "SHA256", &public_pkcs1, message, &signature, true); + assert_aws_lc_rs_cavp("pss", "SHA256", &public_pkcs1, message, &signature, true); + assert_aws_lc_sys_cavp("pss", "SHA256", Some(32), &public_pkcs1, message, &signature, true); + assert_openssl_signature( + "-sha256", + &public_spki, + message, + &signature, + &["rsa_padding_mode:pss", "rsa_pss_saltlen:32", "rsa_mgf1_md:sha256"], + true, + ); + + calls = 0; + key + .sign_tls_certificate_signature_scheme_with_random_fill(0x0401, message, &mut signature, |out| { + assert_eq!(out.len(), factor.len()); + out.copy_from_slice(&factor); + calls = calls.strict_add(1); + Ok::<(), ExternalEntropyError>(()) + }) + .expect("TLS certificate PKCS#1 v1.5 signing with caller entropy must succeed"); + assert_eq!(calls, 1); + key + .public_key() + .verify_pkcs1v15(RsaPkcs1v15Profile::Sha256, message, &signature) + .expect("rscrypto must verify its caller-random PKCS#1 v1.5 signature"); + assert_rustcrypto_pkcs1v15_sha256(&public_pkcs1, message, &signature, true); + assert_ring_cavp("pkcs1v15", "SHA256", &public_pkcs1, message, &signature, true); + assert_aws_lc_rs_cavp("pkcs1v15", "SHA256", &public_pkcs1, message, &signature, true); + assert_aws_lc_sys_cavp("pkcs1v15", "SHA256", None, &public_pkcs1, message, &signature, true); + assert_openssl_signature("-sha256", &public_spki, message, &signature, &[], true); +} + #[cfg(feature = "getrandom")] #[test] fn private_key_outputs_verify_and_decrypt_with_rustcrypto_rsa() { diff --git a/tests/rsa_wycheproof.rs b/tests/rsa_wycheproof.rs index 3b8ed77d..cce3fc4a 100644 --- a/tests/rsa_wycheproof.rs +++ b/tests/rsa_wycheproof.rs @@ -67,7 +67,7 @@ enum WycheproofResult { fn hex_to_vec(hex: &str) -> Vec { assert_eq!(hex.len() % 2, 0); let mut out = Vec::with_capacity(hex.len() / 2); - for chunk in hex.as_bytes().chunks_exact(2) { + for chunk in hex.as_bytes().as_chunks::<2>().0 { let high = hex_value(chunk[0]).expect("Wycheproof fixture must contain hexadecimal digits"); let low = hex_value(chunk[1]).expect("Wycheproof fixture must contain hexadecimal digits"); out.push((high << 4) | low); diff --git a/tools/ct-binsec-harness/src/main.rs b/tools/ct-binsec-harness/src/main.rs index c6ac58c1..25591db0 100644 --- a/tools/ct-binsec-harness/src/main.rs +++ b/tools/ct-binsec-harness/src/main.rs @@ -1,3 +1,8 @@ +#![allow( + suspicious_runtime_symbol_definitions, + reason = "the freestanding Binsec artifact intentionally provides its own C memory-runtime ABI" +)] + use core::ptr; use rscrypto::aead::expert::AeadWithNonce; diff --git a/tools/ct-dudect/src/main.rs b/tools/ct-dudect/src/main.rs index 7577b35b..a3a65d31 100644 --- a/tools/ct-dudect/src/main.rs +++ b/tools/ct-dudect/src/main.rs @@ -33,9 +33,9 @@ use rscrypto::{ diag_mlkem_ntt_input_digest, diag_mlkem_to_montgomery_product_domain_input_digest, diag_mlkem512_keygen_secret_noise_digest, diag_mlkem768_keygen_secret_noise_digest, diag_mlkem1024_keygen_secret_noise_digest, diag_mlkem1024_multiply_ntts_accumulate_input_digest, - diag_rsa_import_pkcs8_private_key_der_stage, diag_rsa_private_component_validation_32, - diag_rsa_private_exponentiate_fixed_width_with_scratch, diag_rsa_validate_pkcs8_private_key_der, - diag_rsa_validate_pkcs8_private_key_der_stage, + diag_rsa_blinding_factor_inverse_with_scratch, diag_rsa_import_pkcs8_private_key_der_stage, + diag_rsa_private_component_validation_32, diag_rsa_private_exponentiate_fixed_width_with_scratch, + diag_rsa_validate_pkcs8_private_key_der, diag_rsa_validate_pkcs8_private_key_der_stage, }, traits::Kem as _, }; @@ -161,7 +161,9 @@ fn hex_to_vec(hex: &str) -> Vec { let bytes = hex.as_bytes(); assert!(bytes.len().is_multiple_of(2), "hex string must have even length"); bytes - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { let high = hex_nibble(pair[0]).expect("RSA fixture must contain hexadecimal bytes"); let low = hex_nibble(pair[1]).expect("RSA fixture must contain hexadecimal bytes"); @@ -1629,6 +1631,34 @@ fn rsa_private_exponent_fixed_width_high_byte(runner: &mut CtRunner, rng: &mut B } } +fn rsa_blinding_inverse_fixed_vs_random_factor(runner: &mut CtRunner, rng: &mut BenchRng) { + let key = rsa_ct_fixture_key(RSA_CT_KEY_A_INDEX); + let len = key.signature_len(); + let state = RefCell::new((key.private_scratch(), vec![0u8; len])); + let mut inputs = Vec::with_capacity(samples()); + for class in balanced_classes(rng, samples()) { + let mut factor = vec![0u8; len]; + if matches!(class, Class::Left) { + *factor.last_mut().expect("RSA blinding factors must be nonempty") = 3; + } else { + let random = rand_array::<8>(rng); + let start = len.strict_sub(random.len()); + factor[start..].copy_from_slice(&random); + *factor.last_mut().expect("RSA blinding factors must be nonempty") |= 1; + } + inputs.push((class, factor)); + } + + for (class, factor) in inputs { + runner.run_one(class, || { + let mut state = state.borrow_mut(); + let (scratch, out) = &mut *state; + let result = diag_rsa_blinding_factor_inverse_with_scratch(&key, &factor, out, scratch); + (result.is_ok(), core::hint::black_box(out.as_slice())[0]) + }); + } +} + fn rsa_oaep_decrypt_fixed_vs_random_plaintext(runner: &mut CtRunner, rng: &mut BenchRng) { let key = rsa_ct_fixture_key(RSA_CT_KEY_A_INDEX); let sig_len = key.signature_len(); @@ -2152,7 +2182,7 @@ mod tests { let classes = balanced_classes(&mut rng, 65); assert_eq!(classes.len(), 65); - for block in classes[..64].chunks_exact(32) { + for block in classes[..64].as_chunks::<32>().0 { let left = block.iter().filter(|class| matches!(class, Class::Left)).count(); assert_eq!(left, 16); } @@ -2342,6 +2372,7 @@ ctbench_main_with_seeds!( Some(0x7273615f6372746c) ), (rsa_private_exponent_fixed_width_high_byte, Some(0x7273615f65787068)), + (rsa_blinding_inverse_fixed_vs_random_factor, Some(0x7273615f696e7662)), (rsa_oaep_decrypt_fixed_vs_random_plaintext, Some(0x7273615f6f616570)), (rsa_pkcs1v15_decrypt_fixed_vs_random_plaintext, Some(0x7273615f64656331)), ( diff --git a/tools/wasm-runtime-vectors/Cargo.toml b/tools/wasm-runtime-vectors/Cargo.toml index 4f2bf2cd..34c5242d 100644 --- a/tools/wasm-runtime-vectors/Cargo.toml +++ b/tools/wasm-runtime-vectors/Cargo.toml @@ -8,4 +8,4 @@ publish = false [workspace] [dependencies] -rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes"] } +rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes", "rsa"] } diff --git a/tools/wasm-runtime-vectors/fixtures/rsa2048_private_pkcs1.txt b/tools/wasm-runtime-vectors/fixtures/rsa2048_private_pkcs1.txt new file mode 100644 index 00000000..8d107dc0 --- /dev/null +++ b/tools/wasm-runtime-vectors/fixtures/rsa2048_private_pkcs1.txt @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvTHHoCaR0tlYfvapRv94hUTMrdSymIrWIIZ5Kmv5bIYWtK0T +MX0icLkB0PzR2IDLj1L7hzBKUljBGzjf6ujfZwru5+odDZ344A6AhH5B5Zie1ALU +TnizD+8XtWcdOtv4aF5NwgRJns0YY+HVr/KKfPZurfMf7JI2wSCt0TRRUixkfJgy +pnLNZNMowcMiGD9GYdCb2mC43V8DKNpUIIIUJK/auxqAxdEnY6GwI4zYnQdCv8UL +ai/LcB2CQhj5gm9PeKI6K1qkKs5/F1N2+2y9srrSk7pYPU0xxrj5Ap5GsTaJJJhV +9QV1bgDiJaakWhh2m9jSs6SsufHCPT5RiCVh5QIDAQABAoIBAAdH1SDKmy38AzXP +lDARQLgQL7g4yS6pmRfjVOJXCWAtwM3vIx/yatLfq7w5HnIwcqTvUpKfPxzwIW5E +wAkwMPisKQlvrvKJM7ybkkKHAU4uk14hRb5HUsy4LLdkYnGTaXnKtz9SHusnhVrC +pBCDSuUILrYu9vmJnuXLtiSo2MBKt+3PeP4TXQ+Aj2O13m4HF3m/HZ6q+NPvllSG +8sUmJeblemUce70Bl8mcBXs4YKMmDIZKEbPPIrpE3jo7PzEX5k6/mpH2Juuz88Jt +Gwu4B1NGjUv2qZeygzXyDtjDhNdVg2CprpdVZh+jdJhGsXTmvzMOUtCZQfbM195Q +BHefm7kCgYEA/j0jjTdqOFbZWS/UlhwXp/sPo51ELp3yLn7aEVxkjFy3ON+J6pLY +N4VY0NnBzz2L/3QNN0OgFApqdSPpF2wpU7LBHX9EaRz4vsKzT7WcZJU1mDMZSIEY +wDEYrnRF5w30Zs6YZxJg8F1QaM53fal+K6hHeUkFAM60/39izsqaFH8CgYEAvoFK +8mvzwnEVvHWV0NEqGvdxP+yod65ubYWIJe2j0ZJwR3T0Lhrhtn8XOejEWgR2OIBw ++lRbfMlrikQAO8jQf95z9bzdGCaDldzChCtQI/8Us1I4Jge3F5peozCED8RQRdhu +CsxP6xNfCrm3zmuOtfWldfKiqN4pnA0/UG30h5sCgYBhu0z0Vy4M9E3e/AUDo07w +sxFC+wyfwvW7K+ZWMCcy0d1ZDo1s0AAv76HBv0PCj81GaO0/pk5eBdcAqspKx7hn +RVr5JNg+c/WnFx9o785r1/PfSx1YAtBp6TWssmPAu1uNPbPWQ3liTOzBLUU/2Cz5 +SVoYZJp4wf1QDj8EcVujrwKBgGZJ2+kgp6jOuYIQjNmrhXUnSYquUqK4aRirazga +Siy7+UeU94tN6cXopZr1bYB8BtI9vOvcoab2K6T48DspjzIpSpA20WwnOd4cw+Dd +tNEvJHlIe1eIKvokbA4pfa9NlGB8XlGz9HFfXFfQ8m2GeQEgnbAgENClQShGQKm5 +8GNjAoGAOexgb1yfHzPZZGBuEo6ekM/5MncsVxWqdZgW1QTv4sRiBvM3mqYdMfiO +cmtQ3YC4zoVYoNdAhSX1jDOZzjLiOdcJQfEtIed2HAQ+CwIiDSX/0DVxx65bvVnD +H0gSBnb518Nwp0nYSRvfzqAWYqE2yF00m2xxD8b26/inp5tircM= +-----END RSA PRIVATE KEY----- diff --git a/tools/wasm-runtime-vectors/src/main.rs b/tools/wasm-runtime-vectors/src/main.rs index 4854c89a..2a6bf876 100644 --- a/tools/wasm-runtime-vectors/src/main.rs +++ b/tools/wasm-runtime-vectors/src/main.rs @@ -1,4 +1,8 @@ -use rscrypto::{Blake2b512, Blake3, Digest, Sha256, Sha512}; +use rscrypto::{ + Blake2b512, Blake3, Digest, RsaPrivateKey, RsaPrivateOpError, RsaPssProfile, RsaPublicKeyPolicy, Sha256, Sha512, +}; + +const RSA_PRIVATE_KEY_PEM: &str = include_str!("../fixtures/rsa2048_private_pkcs1.txt"); fn hex_value(byte: u8) -> Option { match byte { @@ -11,7 +15,7 @@ fn hex_value(byte: u8) -> Option { fn assert_hex(actual: &[u8], expected: &str) { assert_eq!(actual.len().strict_mul(2), expected.len()); - for (i, chunk) in expected.as_bytes().chunks_exact(2).enumerate() { + for (i, chunk) in expected.as_bytes().as_chunks::<2>().0.iter().enumerate() { let high = hex_value(chunk[0]).expect("known hash vector must contain hexadecimal digits"); let low = hex_value(chunk[1]).expect("known hash vector must contain hexadecimal digits"); let byte = high.strict_shl(4) | low; @@ -19,6 +23,44 @@ fn assert_hex(actual: &[u8], expected: &str) { } } +fn base64_value(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte.strict_sub(b'A')), + b'a'..=b'z' => Some(byte.strict_sub(b'a').strict_add(26)), + b'0'..=b'9' => Some(byte.strict_sub(b'0').strict_add(52)), + b'+' => Some(62), + b'/' => Some(63), + b'=' => Some(0), + _ => None, + } +} + +fn decode_base64(input: &str) -> Vec { + let mut encoded = Vec::with_capacity(input.len()); + for line in input.lines() { + if !line.starts_with("-----") { + encoded.extend_from_slice(line.as_bytes()); + } + } + assert_eq!(encoded.len() % 4, 0, "PEM body must contain complete base64 quanta"); + + let mut out = Vec::with_capacity(encoded.len().strict_div(4).strict_mul(3)); + for quantum in encoded.as_chunks::<4>().0 { + let a = base64_value(quantum[0]).expect("RSA fixture must contain valid base64"); + let b = base64_value(quantum[1]).expect("RSA fixture must contain valid base64"); + let c = base64_value(quantum[2]).expect("RSA fixture must contain valid base64"); + let d = base64_value(quantum[3]).expect("RSA fixture must contain valid base64"); + out.push(a.strict_shl(2) | b.strict_shr(4)); + if quantum[2] != b'=' { + out.push((b & 0x0f).strict_shl(4) | c.strict_shr(2)); + } + if quantum[3] != b'=' { + out.push((c & 0x03).strict_shl(6) | d); + } + } + out +} + fn patterned_bytes(len: usize) -> Vec { (0..len) .map(|i| { @@ -84,6 +126,50 @@ fn assert_streaming_hashes_match_oneshot_across_block_boundaries() { assert_eq!(blake3.finalize(), blake3_oneshot); } +fn assert_rsa_caller_random_signing_roundtrips() { + // The fixture is the first RSA-2048 key from Wycheproof's + // `rsa_pkcs1_2048_sig_gen_test.json`; only the key is copied here so this + // runtime executable does not embed the complete vector corpus. + let private_key_der = decode_base64(RSA_PRIVATE_KEY_PEM); + let key = RsaPrivateKey::from_pkcs1_der_with_policy(&private_key_der, &RsaPublicKeyPolicy::legacy_verification()) + .expect("Wycheproof RSA-2048 private key must import under the explicit legacy policy"); + let profile = RsaPssProfile::Sha256; + let message = b"rscrypto caller-random RSA signing on wasm32-wasip1"; + let modulus_len = key.signature_len(); + let mut signature = vec![0u8; modulus_len]; + let mut scratch = key.private_scratch(); + + let mut requests = 0usize; + key + .sign_pss_with_random_fill_and_scratch(profile, message, &mut signature, &mut scratch, |random| { + if requests == 0 { + assert_eq!(random.len(), Sha256::OUTPUT_SIZE); + random.fill(0x5a); + } else { + assert_eq!(random.len(), modulus_len); + random.fill(0); + random[modulus_len.strict_sub(1)] = 2; + } + requests = requests.strict_add(1); + Ok::<(), ()>(()) + }) + .expect("caller-random RSA-PSS signing must succeed without OS entropy"); + assert_eq!(requests, 2, "factor two must be accepted on the first bounded attempt"); + key + .public_key() + .verify_pss(profile, message, &signature) + .expect("caller-random RSA-PSS signature must verify"); + + let error = key + .sign_pss_with_random_fill_and_scratch(profile, message, &mut signature, &mut scratch, |random| { + random.fill(0xa5); + Err::<(), ()>(()) + }) + .expect_err("caller entropy failure must fail closed"); + assert_eq!(error, RsaPrivateOpError::EntropyUnavailable); + assert!(signature.iter().all(|&byte| byte == 0)); +} + #[cfg(target_feature = "simd128")] fn assert_simd128_runtime_caps_are_detected() { assert!(rscrypto::platform::caps().has(rscrypto::platform::caps::wasm::SIMD128)); @@ -95,5 +181,6 @@ fn assert_simd128_runtime_caps_are_detected() {} fn main() { assert_core_hash_vectors_match_known_outputs(); assert_streaming_hashes_match_oneshot_across_block_boundaries(); + assert_rsa_caller_random_signing_roundtrips(); assert_simd128_runtime_caps_are_detected(); }