From b84a87420cf171dfe1e5743f34819009322dc999 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 30 Oct 2026 17:29:55 +0000 Subject: [PATCH 1/4] chore(python): prepare Authenticode signing for Windows wheels --- .github/workflows/publish-python.yml | 107 +++++++++++++- .github/workflows/windows-signing.yml | 45 ++++++ AGENTS.md | 6 + docs/start-python.md | 9 ++ knowledge/log.md | 4 + knowledge/operations/release-process.md | 19 +++ knowledge/runtimes/python-package.md | 34 +++++ knowledge/security/threat-model.md | 13 ++ scripts/tests/test_windows_signing.py | 182 ++++++++++++++++++++++++ scripts/verify_windows_signatures.ps1 | 21 +++ scripts/windows-signing.json | 8 ++ scripts/windows_wheel.py | 100 +++++++++++++ 12 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/windows-signing.yml create mode 100644 scripts/tests/test_windows_signing.py create mode 100644 scripts/verify_windows_signatures.ps1 create mode 100644 scripts/windows-signing.json create mode 100644 scripts/windows_wheel.py diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 0b79efdf7..e0c47252b 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -6,6 +6,8 @@ # Prerequisites: # - PyPI trusted publisher configured for this repo + workflow # - GitHub environment "release-python" created in repo settings +# - "release-windows-signing" environment restricted to release tags +# - Validated Public Trust profile + profile-scoped OIDC signer permission name: Publish Python @@ -117,7 +119,8 @@ jobs: - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: - name: pypi_files-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'manylinux' }} + # Unsigned Windows wheels must never match the publisher's glob. + name: ${{ matrix.os == 'windows' && 'pypi_unsigned' || 'pypi_files' }}-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'manylinux' }} path: crates/bashkit-python/dist # Reduced-feature Pyodide/Emscripten wheel (browser / JupyterLite). @@ -217,6 +220,12 @@ jobs: merge-multiple: true path: dist + - name: Download unsigned Windows wheel for inspection + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: pypi_unsigned-windows-x86_64-manylinux + path: dist + - name: List dist files run: | ls -lhR dist/ @@ -226,6 +235,98 @@ jobs: - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - run: uvx twine check dist/* + # Credentials live only here, after the release source and unsigned archives + # have been inspected. No compiler, native import, or smoke test runs here. + # THREAT[TM-INF-035]: never give build scripts or the native module signer access. + sign-windows: + name: Sign Windows wheel + needs: [inspect] + runs-on: windows-latest + environment: release-windows-signing + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: pypi_unsigned-windows-x86_64-manylinux + path: unsigned-dist + + - name: Install wheel tooling + run: >- + python -m pip install --only-binary=:all: wheel==0.48.0 + + - name: Unpack and retain unsigned file hashes + id: unpack + run: python scripts/windows_wheel.py prepare --source unsigned-dist --work wheel-work + + - name: Read public signing configuration + id: config + shell: pwsh + run: | + $config = Get-Content scripts/windows-signing.json -Raw | ConvertFrom-Json + foreach ($key in @("tenant-id", "subscription-id", "client-id", "endpoint", "signing-account-name", "certificate-profile-name")) { + $value = $config.PSObject.Properties[$key].Value + if ([string]::IsNullOrWhiteSpace($value) -or $value -match "[\r\n]") { + throw "Invalid signing configuration: $key" + } + $name = $key.Replace("-", "_") + "$name=$value" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + + - name: Authenticate release signer with OIDC + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 + with: + client-id: ${{ steps.config.outputs.client_id }} + tenant-id: ${{ steps.config.outputs.tenant_id }} + subscription-id: ${{ steps.config.outputs.subscription_id }} + + - name: Sign native files and timestamp + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2 + with: + endpoint: ${{ steps.config.outputs.endpoint }} + signing-account-name: ${{ steps.config.outputs.signing_account_name }} + certificate-profile-name: ${{ steps.config.outputs.certificate_profile_name }} + files-folder: ${{ steps.unpack.outputs.root }} + files-folder-filter: pyd,dll,exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify every native signature + shell: pwsh + env: + WHEEL_ROOT: ${{ steps.unpack.outputs.root }} + run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT + + - name: Repack with regenerated RECORD and verify archive hashes + run: python scripts/windows_wheel.py repack --work wheel-work --output signed-dist + + - name: Extract the final archive for signature verification + id: final + run: python scripts/windows_wheel.py prepare --source signed-dist --work final-check + + - name: Verify signatures from the final wheel + shell: pwsh + env: + WHEEL_ROOT: ${{ steps.final.outputs.root }} + run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: pypi_files-windows-x86_64-manylinux + path: signed-dist + # Smoke-test the single cp39-abi3 wheel on every targeted Python version. # With abi3 there is one wheel per platform, so this is the check that it # actually installs and runs across the full 3.9–3.14 range it claims — not @@ -233,7 +334,7 @@ jobs: # the include entries only map each os to its runner. test-builds: name: Test wheel on ${{ matrix.os }} (Python ${{ matrix.python-version }}) - needs: [build] + needs: [build, sign-windows] strategy: fail-fast: false matrix: @@ -270,7 +371,7 @@ jobs: # Publish to PyPI using trusted publishing (OIDC) publish: name: Publish to PyPI - needs: [inspect, test-builds] + needs: [inspect, sign-windows, test-builds] if: success() runs-on: ubuntu-latest diff --git a/.github/workflows/windows-signing.yml b/.github/workflows/windows-signing.yml new file mode 100644 index 000000000..636b375b0 --- /dev/null +++ b/.github/workflows/windows-signing.yml @@ -0,0 +1,45 @@ +# Test packaging and real WinVerifyTrust without production signing credentials. +# This hosted Windows Server job is not a SAC enforcement test. +name: Windows signing + +on: + push: + branches: [main] + paths: + - ".github/workflows/windows-signing.yml" + - ".github/workflows/publish-python.yml" + - "scripts/windows_wheel.py" + - "scripts/windows-signing.json" + - "scripts/verify_windows_signatures.ps1" + - "scripts/tests/test_windows_signing.py" + pull_request: + branches: [main] + paths: + - ".github/workflows/windows-signing.yml" + - ".github/workflows/publish-python.yml" + - "scripts/windows_wheel.py" + - "scripts/windows-signing.json" + - "scripts/verify_windows_signatures.ps1" + - "scripts/tests/test_windows_signing.py" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Windows wheel integrity and Authenticode + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" + - name: Install test tooling + run: >- + python -m pip install --only-binary=:all: wheel==0.48.0 PyYAML==6.0.3 + - name: Test RECORD repair and Windows signature validation + run: python -m unittest discover -s scripts/tests -p test_windows_signing.py -v diff --git a/AGENTS.md b/AGENTS.md index ba8c8322d..eb39d7c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,12 @@ Do not mix criterion `.md` files into `crates/bashkit-bench/results/`. - Tests: `pytest crates/bashkit-python/tests/ -v` (requires `maturin develop` first) - CI: `.github/workflows/python.yml` (lint, test on 3.9/3.12/3.13, build wheel) +Windows wheel signing checks: `.github/workflows/windows-signing.yml` runs +`python -m unittest discover -s scripts/tests -p test_windows_signing.py -v` +with pinned wheel tooling on Windows. Packaging tests also run on other hosts; +WinVerifyTrust tests require Windows. Production signing prerequisites and the +unsigned/signed artifact boundary live in `knowledge/runtimes/python-package.md`. + ### Pre-PR Checklist 1. `just pre-pr` (runs 2-4 automatically) diff --git a/docs/start-python.md b/docs/start-python.md index d1741ddf7..e16a3bcad 100644 --- a/docs/start-python.md +++ b/docs/start-python.md @@ -211,6 +211,15 @@ to select a trusted signing provider. A self-signed certificate or a PyPI attestation alone does not supply that trust. Signing credentials and a SAC-On validation host are required before claiming this fix is shipped. +The release workflow's signing path uses Azure Artifact Signing with a validated +Public Trust profile. It keeps unsigned wheels separate from publishable +artifacts, signs native files in a dedicated job, regenerates `RECORD`, and +verifies native signatures from the final wheel. A failure blocks publishing. +Windows CI also tests the packaging and signature checks without production +credentials; those tests do not replace the SAC-On comparison above. The +reported pin remains the workaround until a new signed release has passed that +comparison on the affected machine. + ## Next steps - [Sandbox configuration & limits](configuration.md), resource limits and sandbox options. diff --git a/knowledge/log.md b/knowledge/log.md index cbab86070..4b64244c3 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-30 + +* **Windows signing preparation**: Separate unsigned Windows build outputs from publishable wheels. Add a credential-isolated Azure OIDC signing job, native signature/timestamp checks, RECORD regeneration, and a Windows packaging test job. Rollout requires verified publisher identity, a Public Trust profile, profile-scoped signer permission, and a GitHub environment restricted to release tags; the signing gate must remain unmerged until those prerequisites are ready. See [Python Package](runtimes/python-package.md#authenticode-release-path). + ## 2026-10-29 * **Directory copy**: Preserve final-dot operands so `cp -r source/. destination` copies contents without an extra source directory; ordinary directory operands retain their basename. Regression fixtures compare nested/hidden entries, symlinks, no-clobber and rejection paths with GNU bash. See [Builtins](foundations/builtins.md). diff --git a/knowledge/operations/release-process.md b/knowledge/operations/release-process.md index 8192b7073..8b01ddb05 100644 --- a/knowledge/operations/release-process.md +++ b/knowledge/operations/release-process.md @@ -297,6 +297,25 @@ to PyPI via trusted publishing (OIDC, no secrets; environment `release-python` must exist in repo settings). Python version is read dynamically from `Cargo.toml` via maturin (`dynamic = ["version"]`). +Windows wheels additionally require Authenticode signing in the separate +`release-windows-signing` environment. Its Azure OIDC identity has only the +Certificate Profile Signer role at the validated Public Trust profile's scope; +the environment must restrict deployment to release tags. The account, tenant, +client and profile identifiers in `scripts/windows-signing.json` are public +configuration, not secrets. Publisher identity validation is a human portal +step; do not substitute a Private Trust or test profile. + +The source inspection job gates signing credentials. Unsigned Windows build +outputs use the `pypi_unsigned-*` namespace; only the signing job's verified, +repacked archive enters `pypi_files-*`. Native files require valid embedded +signatures and timestamps, and Python/metadata content cannot change during +signing. A signing/configuration/verification failure blocks publication rather +than falling back to the original wheel. The credential-free smoke jobs consume +the final archive. Unit and Windows integration coverage lives in +`scripts/tests/test_windows_signing.py`; see +[Python Package](../runtimes/python-package.md#authenticode-release-path) for +provisioning prerequisites and the remaining SAC enforcement check. + ### publish-js.yml Trigger: Release published (parallel). Builds native NAPI-RS bindings diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index 0b5324f93..8d53bd657 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -92,6 +92,40 @@ files before final wheel packing (or regenerate `RECORD` when repacking), then publish a new patch version. Standard hosted Windows smoke jobs do not prove SAC enforcement. See [Release Process](../operations/release-process.md). +### Authenticode release path + +The Python release workflow retains the build's Windows wheel as +`pypi_unsigned-windows-x86_64-manylinux`, outside the publisher's `pypi_files-*` +glob. After source/tag and archive inspection, `sign-windows` unpacks it, +Authenticode-signs `.pyd`/`.dll`/`.exe` files with a SHA-256 timestamp, verifies +every native signature, repacks with fresh `RECORD` hashes, and verifies the +native signatures extracted from the final archive. Only this job creates the +publishable Windows artifact; smoke tests and publishing depend on its success. +There is no unsigned fallback. + +`scripts/windows_wheel.py` uses the pinned wheel CLI and refuses changes to +Python/metadata files or the file set during signing. Its manifest stays outside +the package. `scripts/verify_windows_signatures.ps1` requires embedded, valid, +timestamped signatures without installing trust roots. Signing runs in a +separate OIDC job that never compiles or imports the extension. Public resource +identifiers live in `scripts/windows-signing.json`; no private key or client +secret belongs in the repository. + +Before enabling this release path, complete publisher identity validation, +create the `bashkit-public` **Public Trust** certificate profile, configure +`release-windows-signing` to accept only release tags, and grant the GitHub +service principal Certificate Profile Signer at that profile's scope. The Azure +federated subject must be exactly +`repo:everruns/bashkit:environment:release-windows-signing`. +Do not merge the release gate before those external prerequisites are ready. + +`.github/workflows/windows-signing.yml` exercises real Windows signature +verification with a copy of the already-signed Python interpreter, plus corrupt +signatures, unsigned dependencies, wheel hash repair, and metadata tampering. +This needs neither production credentials nor a self-signed test root. It tests +packaging on Windows Server, not Windows 11 SAC enforcement. Production signing +and a SAC-On native import/pytest check remain required release evidence. + ## Public API Full signatures: `crates/bashkit-python/bashkit/_bashkit.pyi`. Runnable diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 3d880797c..acfa4f5fd 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -486,6 +486,19 @@ steps from a compromised compiler dependency persisting on the same runner. Workflow execution regressions inspect verification/upload environments in `scripts/tests/test_release_security.py`. +**TM-INF-035 — Windows signing authority**: Compiler dependencies and native +modules must not inherit the production code-signing identity. Windows builds +upload unsigned artifacts in a namespace the PyPI publisher never downloads. +Only a separate, source-validated release job receives Azure OIDC signing +access; it compiles no code and never imports the wheel. Its federated identity +is bound to `release-windows-signing`, whose deployment policy must restrict +release tags, and its signer role is scoped to the validated Public Trust +profile. Python/metadata files and the file set must survive signing unchanged; +every native signature and timestamp is checked again after packing. Missing +credentials, signatures, timestamps or repaired RECORD hashes block publishing. +External environment/profile provisioning must complete before this release +gate is merged. Regression coverage: `scripts/tests/test_windows_signing.py`. + **TM-INF-022**: Generalizes TM-INF-016 to the whole builtin surface. Originating bug: `builtins/jq/` formatted jaq compile/parse errors with `{:?}`, leaking the jaq `File` struct, the prepended compat-defs source, and raw `Undefined::Filter(N)` tags. Fixed via diff --git a/scripts/tests/test_windows_signing.py b/scripts/tests/test_windows_signing.py new file mode 100644 index 000000000..f762a7ae6 --- /dev/null +++ b/scripts/tests/test_windows_signing.py @@ -0,0 +1,182 @@ +"""Exercise wheel RECORD repair and real Windows Authenticode validation.""" + +import base64 +import csv +import hashlib +import importlib.util +import io +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("windows_wheel", ROOT / "scripts/windows_wheel.py") +wheel_helper = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(wheel_helper) + + +def make_wheel(directory, *, corrupt=False, native=True): + files = { + "bashkit/__init__.py": b"# package\n", + "bashkit-1.2.3.dist-info/METADATA": b"Metadata-Version: 2.1\nName: bashkit\nVersion: 1.2.3\n", + "bashkit-1.2.3.dist-info/WHEEL": ( + b"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: cp39-abi3-win_amd64\n" + ), + } + if native: + files["bashkit/_bashkit.pyd"] = b"MZunsigned test module" + record = io.StringIO(newline="") + writer = csv.writer(record) + for name, content in files.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode() + writer.writerow((name, "sha256=" + digest, len(content))) + record_name = "bashkit-1.2.3.dist-info/RECORD" + writer.writerow((record_name, "", "")) + files[record_name] = record.getvalue().encode() + if corrupt: + files["bashkit/__init__.py"] += b"# modified without updating RECORD\n" + path = directory / "bashkit-1.2.3-cp39-abi3-win_amd64.whl" + with zipfile.ZipFile(path, "w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + return path + + +@unittest.skipUnless(importlib.util.find_spec("wheel"), "requires wheel; installed by Windows signing CI") +class WheelSigningTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.source = self.base / "source" + self.source.mkdir() + self.work = self.base / "work" + self.output = self.base / "signed" + + def test_native_change_regenerates_record_and_preserves_other_contents(self): + original = make_wheel(self.source) + package = wheel_helper.prepare(self.source, self.work) + native = package / "bashkit/_bashkit.pyd" + signed = native.read_bytes() + b"test signature bytes" + native.write_bytes(signed) + result = wheel_helper.repack(self.work, self.output) + self.assertEqual(result.name, original.name) + with zipfile.ZipFile(result) as archive: + self.assertEqual(archive.read("bashkit/_bashkit.pyd"), signed) + self.assertEqual(archive.read("bashkit/__init__.py"), b"# package\n") + rows = dict((row[0], row[1:]) for row in csv.reader(io.StringIO( + archive.read("bashkit-1.2.3.dist-info/RECORD").decode() + ))) + expected = base64.urlsafe_b64encode(hashlib.sha256(signed).digest()).rstrip(b"=").decode() + self.assertEqual(rows["bashkit/_bashkit.pyd"], ["sha256=" + expected, str(len(signed))]) + + def test_rejects_corrupt_unsigned_wheel(self): + make_wheel(self.source, corrupt=True) + with self.assertRaises(subprocess.CalledProcessError): + wheel_helper.prepare(self.source, self.work) + + def test_rejects_non_native_changes_and_file_additions_or_removals(self): + for operation in ("modify", "add", "remove"): + with self.subTest(operation=operation), tempfile.TemporaryDirectory() as temp: + base = Path(temp) + source = base / "source" + source.mkdir() + make_wheel(source) + package = wheel_helper.prepare(source, base / "work") + if operation == "modify": + (package / "bashkit/__init__.py").write_text("changed") + elif operation == "add": + (package / "bashkit/extra.dll").write_bytes(b"extra") + else: + (package / "bashkit/_bashkit.pyd").unlink() + with self.assertRaises(ValueError): + wheel_helper.repack(base / "work", base / "signed") + + def test_rejects_missing_native_module_and_existing_work_directory(self): + make_wheel(self.source, native=False) + with self.assertRaises(ValueError): + wheel_helper.prepare(self.source, self.work) + with self.assertRaises(FileExistsError): + wheel_helper.prepare(self.source, self.work) + + def test_rejects_missing_or_multiple_wheels(self): + with self.assertRaises(ValueError): + wheel_helper.prepare(self.source, self.work) + path = make_wheel(self.source) + shutil.copyfile(path, self.source / path.name.replace("1.2.3", "1.2.4")) + with self.assertRaises(ValueError): + wheel_helper.prepare(self.source, self.work) + + +@unittest.skipUnless(os.name == "nt", "requires Windows WinVerifyTrust") +class AuthenticodeTests(unittest.TestCase): + def verify(self, root): + return subprocess.run([ + "pwsh", "-NoProfile", "-File", str(ROOT / "scripts/verify_windows_signatures.ps1"), + "-Root", str(root), + ], capture_output=True, text=True) + + def test_accepts_timestamped_python_signature_on_pe_bytes(self): + # Hosted setup-python uses the signed python.org distribution. Renaming + # a copy to .pyd checks the same PE verifier used for our native module. + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + shutil.copyfile(sys.executable, root / "_bashkit.pyd") + result = self.verify(root) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_rejects_tampering_unsigned_files_and_empty_directory(self): + for case in ("empty", "unsigned", "tampered", "unsigned_dependency"): + with self.subTest(case=case), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + if case == "unsigned": + (root / "_bashkit.pyd").write_bytes(b"MZunsigned") + elif case in ("tampered", "unsigned_dependency"): + native = root / "_bashkit.pyd" + shutil.copyfile(sys.executable, native) + if case == "tampered": + content = bytearray(native.read_bytes()) + content[1024] ^= 1 + native.write_bytes(content) + else: + (root / "dependency.dll").write_bytes(b"MZunsigned") + self.assertNotEqual(self.verify(root).returncode, 0) + + +class SigningWorkflowTests(unittest.TestCase): + def test_unsigned_artifacts_cannot_reach_pypi(self): + jobs = yaml.safe_load((ROOT / ".github/workflows/publish-python.yml").read_text())["jobs"] + self.assertIn("sign-windows", jobs["publish"]["needs"]) + self.assertIn("sign-windows", jobs["test-builds"]["needs"]) + signing = jobs["sign-windows"] + self.assertIn("inspect", signing["needs"]) + self.assertEqual(signing["environment"], "release-windows-signing") + self.assertEqual(signing["permissions"], {"contents": "read", "id-token": "write"}) + upload = next(s for s in jobs["build"]["steps"] if s.get("uses", "").startswith("actions/upload-artifact@")) + self.assertIn("pypi_unsigned", upload["with"]["name"]) + self.assertIn("matrix.os == 'windows'", upload["with"]["name"]) + sign_step = next(s for s in signing["steps"] if s.get("uses", "").startswith("azure/artifact-signing-action@")) + self.assertEqual(sign_step["with"]["files-folder-filter"], "pyd,dll,exe") + self.assertEqual(sign_step["with"]["timestamp-digest"], "SHA256") + publish_downloads = [s for s in jobs["publish"]["steps"] + if s.get("uses", "").startswith("actions/download-artifact@")] + self.assertTrue(publish_downloads) + for step in publish_downloads: + self.assertEqual(step["with"]["pattern"], "pypi_files-*") + signed_upload = next(s for s in signing["steps"] + if s.get("uses", "").startswith("actions/upload-artifact@")) + self.assertEqual(signed_upload["with"]["path"], "signed-dist") + self.assertEqual(signed_upload["with"]["name"], "pypi_files-windows-x86_64-manylinux") + for name in ("build", "build-sdist", "build-emscripten", "test-builds"): + self.assertNotEqual(jobs[name].get("permissions", {}).get("id-token"), "write") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_windows_signatures.ps1 b/scripts/verify_windows_signatures.ps1 new file mode 100644 index 000000000..b9d8435ba --- /dev/null +++ b/scripts/verify_windows_signatures.ps1 @@ -0,0 +1,21 @@ +# Verify the actual PE bytes, including timestamps, on a clean Windows runner. +# No certificate is installed or trusted by this script. Hosted Windows CI is +# a packaging/signature check; it does not establish SAC-On enforcement. +param([Parameter(Mandatory = $true)][string]$Root) + +$ErrorActionPreference = "Stop" +$nativeFiles = @(Get-ChildItem -LiteralPath $Root -Recurse -File | + Where-Object { $_.Extension.ToLowerInvariant() -in @(".pyd", ".dll", ".exe") }) +if ($nativeFiles.Count -eq 0) { + throw "No native files found under $Root" +} +foreach ($file in $nativeFiles) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne "Valid" -or $signature.SignatureType -ne "Authenticode") { + throw "Invalid embedded Authenticode signature on $($file.FullName): $($signature.Status)" + } + if ($null -eq $signature.SignerCertificate -or $null -eq $signature.TimeStamperCertificate) { + throw "Missing signer or timestamp on $($file.FullName)" + } + Write-Output "$($file.Name): Valid; publisher=$($signature.SignerCertificate.Subject); timestamped" +} diff --git a/scripts/windows-signing.json b/scripts/windows-signing.json new file mode 100644 index 000000000..9899b5441 --- /dev/null +++ b/scripts/windows-signing.json @@ -0,0 +1,8 @@ +{ + "tenant-id": "7dfb7e6b-b084-4364-8a35-11c3432c8984", + "subscription-id": "717d8bd5-a53b-43a8-b193-36ac59a12071", + "client-id": "c2f1bbc7-8e1d-4c63-853f-1fa5ece1e5b8", + "endpoint": "https://eus.codesigning.azure.net/", + "signing-account-name": "bashkit-signing-717d8bd5", + "certificate-profile-name": "bashkit-public" +} diff --git a/scripts/windows_wheel.py b/scripts/windows_wheel.py new file mode 100644 index 000000000..912abea6d --- /dev/null +++ b/scripts/windows_wheel.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Sign only native files between wheel unpack/pack; never reuse stale RECORDs. + +The pinned wheel CLI owns the wheel format and integrity checks. This helper +additionally rejects changes to Python/metadata files and isolates the signed +archive from unsigned build outputs. It never imports the packaged extension. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +NATIVE_SUFFIXES = {".pyd", ".dll", ".exe"} + + +def _files(root): + result = {} + for path in root.rglob("*"): + if path.is_symlink(): + raise ValueError("wheel contains a symbolic link") + if path.is_file(): + result[path.relative_to(root).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + return result + + +def prepare(source, work): + wheels = list(source.glob("*.whl")) + if len(wheels) != 1 or not wheels[0].name.startswith("bashkit-") or not wheels[0].name.endswith("-win_amd64.whl"): + raise ValueError("expected exactly one Bashkit Windows x64 wheel") + work.mkdir(parents=True, exist_ok=False) + subprocess.run([ + sys.executable, "-m", "wheel", "unpack", str(wheels[0].resolve()), "-d", str(work.resolve()), + ], check=True) + roots = [path for path in work.iterdir() if path.is_dir()] + if len(roots) != 1 or not (roots[0] / "bashkit/_bashkit.pyd").is_file(): + raise ValueError("wheel must contain bashkit/_bashkit.pyd") + root = roots[0] + (work / "unsigned-manifest.json").write_text(json.dumps({ + "root": root.name, "wheel": wheels[0].name, "files": _files(root), + }), encoding="utf-8") + return root.resolve() + + +def repack(work, output): + manifest = json.loads((work / "unsigned-manifest.json").read_text(encoding="utf-8")) + root = work / manifest["root"] + if root.resolve().parent != work.resolve(): + raise ValueError("invalid wheel root") + current = _files(root) + original = manifest["files"] + if current.keys() != original.keys(): + raise ValueError("signing added or removed wheel files") + for name, digest in original.items(): + if Path(name).suffix.lower() not in NATIVE_SUFFIXES and current[name] != digest: + raise ValueError("signing changed a non-native file: " + name) + output.mkdir(parents=True, exist_ok=False) + subprocess.run([ + sys.executable, "-m", "wheel", "pack", str(root.resolve()), "-d", str(output.resolve()), + ], check=True) + wheels = list(output.glob("*.whl")) + if len(wheels) != 1 or wheels[0].name != manifest["wheel"]: + raise ValueError("repacking changed the wheel identity") + # Read every member through WheelFile so corrupt/stale RECORD hashes fail + # before this directory can become a publishable artifact. + from wheel.wheelfile import WheelFile + with WheelFile(str(wheels[0])) as archive: + for member in archive.infolist(): + if not member.is_dir(): + archive.read(member.filename) + return wheels[0].resolve() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + unpack = commands.add_parser("prepare") + unpack.add_argument("--source", type=Path, required=True) + unpack.add_argument("--work", type=Path, required=True) + pack = commands.add_parser("repack") + pack.add_argument("--work", type=Path, required=True) + pack.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.command == "prepare": + name, value = "root", prepare(args.source, args.work) + else: + name, value = "wheel", repack(args.work, args.output) + print(json.dumps({name: str(value)})) + if os.environ.get("GITHUB_OUTPUT"): + if "\n" in str(value) or "\r" in str(value): + raise ValueError("invalid action output path") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() From af55acd4d1f6ef2f508bf323b97c2e5ebde8d812 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 30 Oct 2026 17:34:47 +0000 Subject: [PATCH 2/4] test(python): compare reported Windows wheel artifacts in CI --- .github/workflows/windows-signing.yml | 39 +++++++++++++++++++++++++++ knowledge/runtimes/python-package.md | 3 +++ scripts/tests/test_windows_signing.py | 13 +++++++++ 3 files changed, 55 insertions(+) diff --git a/.github/workflows/windows-signing.yml b/.github/workflows/windows-signing.yml index 636b375b0..b93174181 100644 --- a/.github/workflows/windows-signing.yml +++ b/.github/workflows/windows-signing.yml @@ -27,6 +27,45 @@ permissions: contents: read jobs: + published-baseline: + name: Published wheel ${{ matrix.version }} (Windows baseline) + runs-on: windows-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - version: "0.20.0" + sha256: 4bcee1e7bf4bed6b55d80c04694c95f1116685d847fbd9bfce42f76740e7915c + - version: "0.20.1" + sha256: 948f3f73a3e1289caeba7581330732fd145d5b26e5c018d97345c1bd92ce9099 + steps: + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11.9" + - name: Verify published bytes, record signature, and test native execution + shell: pwsh + env: + VERSION: ${{ matrix.version }} + EXPECTED_SHA256: ${{ matrix.sha256 }} + run: | + # No production identity or signing permission belongs in this job. + Get-ComputerInfo | Select-Object WindowsProductName, OsVersion, OsBuildNumber + python --version + python -m pip download --index-url https://pypi.org/simple --only-binary=:all: --no-deps --no-cache-dir --dest published "bashkit==$env:VERSION" + if ($LASTEXITCODE -ne 0) { throw "Wheel download failed" } + $wheels = @(Get-ChildItem published -Filter *.whl) + if ($wheels.Count -ne 1) { throw "Expected one published wheel" } + $digest = (Get-FileHash $wheels[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if ($digest -ne $env:EXPECTED_SHA256) { throw "Published wheel hash differs from the inspected artifact" } + python -m pip install --no-index --find-links published --no-deps "bashkit==$env:VERSION" + if ($LASTEXITCODE -ne 0) { throw "Wheel installation failed" } + $site = python -c "import sysconfig; print(sysconfig.get_path('platlib'))" + Get-FileHash "$site\bashkit\_bashkit.pyd" -Algorithm SHA256 + Get-AuthenticodeSignature "$site\bashkit\_bashkit.pyd" | Format-List Path, Status, StatusMessage, SignerCertificate + python -c "from bashkit import BashTool; r = BashTool().execute_sync('echo hello'); assert r.exit_code == 0; assert r.stdout.strip() == 'hello'; print(r.stdout)" + if ($LASTEXITCODE -ne 0) { throw "Native import/execution failed" } + test: name: Windows wheel integrity and Authenticode runs-on: windows-latest diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index 8d53bd657..b52114a02 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -125,6 +125,9 @@ signatures, unsigned dependencies, wheel hash repair, and metadata tampering. This needs neither production credentials nor a self-signed test root. It tests packaging on Windows Server, not Windows 11 SAC enforcement. Production signing and a SAC-On native import/pytest check remain required release evidence. +The same workflow downloads the exact two reported PyPI wheels, verifies their +recorded SHA-256 digests, records native signatures, and imports/executes each +under Python 3.11.9 as a Windows Server baseline. Its success is not a SAC result. ## Public API diff --git a/scripts/tests/test_windows_signing.py b/scripts/tests/test_windows_signing.py index f762a7ae6..f317d228e 100644 --- a/scripts/tests/test_windows_signing.py +++ b/scripts/tests/test_windows_signing.py @@ -151,6 +151,19 @@ def test_rejects_tampering_unsigned_files_and_empty_directory(self): class SigningWorkflowTests(unittest.TestCase): + def test_published_comparison_uses_exact_reported_wheels_without_signing_credentials(self): + jobs = yaml.safe_load((ROOT / ".github/workflows/windows-signing.yml").read_text())["jobs"] + baseline = jobs["published-baseline"] + versions = baseline["strategy"]["matrix"]["include"] + self.assertEqual([entry["version"] for entry in versions], ["0.20.0", "0.20.1"]) + self.assertTrue(all(len(entry["sha256"]) == 64 for entry in versions)) + self.assertNotIn("environment", baseline) + self.assertNotEqual(baseline.get("permissions", {}).get("id-token"), "write") + commands = "\n".join(step.get("run", "") for step in baseline["steps"]) + self.assertIn("EXPECTED_SHA256", commands) + self.assertIn("Get-AuthenticodeSignature", commands) + self.assertIn("BashTool().execute_sync", commands) + def test_unsigned_artifacts_cannot_reach_pypi(self): jobs = yaml.safe_load((ROOT / ".github/workflows/publish-python.yml").read_text())["jobs"] self.assertIn("sign-windows", jobs["publish"]["needs"]) From f49cc68b6ae3bbc6c5ed4dd1e304163334ab79bf Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 30 Oct 2026 19:48:12 +0000 Subject: [PATCH 3/4] docs(security): include Windows signing in public threat model --- crates/bashkit/docs/threat-model.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index fb5091f8b..3c1f13d91 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -332,6 +332,7 @@ Scripts may attempt to leak sensitive information. | Host env side-channel via clap `Arg::env` (TM-INF-024) | `ls` resolved `TABSIZE`/`TIME_STYLE` from host env | Codegen strips `.env(...)`; builtins read `ctx.env` only | MITIGATED | | Untrusted generated Rust in drift CI (TM-INF-025) | Malicious upstream `uu_app()` runs with a write token | Generator validates the emitted shape; drift workflow splits read/write privilege | FIXED | | Checkout token readable by untrusted CI code (TM-INF-034) | `actions/checkout` leaves `GITHUB_TOKEN` in `.git/config`, where a dependency build script or test in the same job can read it | `persist-credentials: false` on every checkout that does not back an authenticated `git`/`gh` call, and on every job of a pull-request-triggered workflow; every workflow declares a read-only default `permissions` block | FIXED | +| Windows signing authority (TM-INF-035) | Compiler dependencies or native imports gain production code-signing access, or unsigned build outputs reach PyPI | A separate source-validated OIDC job signs native files without compiling or importing them; deployment requires a release-tag-restricted environment and profile-scoped signer role. Embedded signatures and timestamps are verified after RECORD repair; unsigned artifact names never match the publisher's glob. External profile/environment provisioning must complete before enabling the release gate | MITIGATED | | Publish from unverified release refs (TM-INF-027) | Dispatch publish from a branch or unprotected tag | Dispatch requires `refs/heads/main`; publish gated on real `vX.Y.Z` tags | FIXED | | JS `onOutput` errors expose host stack traces (TM-INF-028) | Callback throws, leaking `error.stack` | Propagate `error.message` only; strip absolute/`file://` paths | FIXED | | Raw callback errors leak host internals (TM-INF-030) | Tool callback throws API keys/connection strings/stack traces | `sanitize_errors` defaults on for `ScriptedTool`/`ToolImpl`/`ToolRegistry` across shell/Python/TypeScript | MITIGATED | From a4574b54a3c4c9b49b2be1e3a38c26b93bdf6333 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 30 Oct 2026 20:17:08 +0000 Subject: [PATCH 4/4] chore(python): validate production signing without registry publication --- .github/workflows/publish-python.yml | 91 +-------------- .github/workflows/sign-windows-wheel.yml | 118 ++++++++++++++++++++ .github/workflows/windows-signing-smoke.yml | 88 +++++++++++++++ .github/workflows/windows-signing.yml | 4 + AGENTS.md | 3 + knowledge/operations/release-process.md | 8 ++ knowledge/runtimes/python-package.md | 14 +++ scripts/tests/test_windows_signing.py | 65 ++++++++++- 8 files changed, 302 insertions(+), 89 deletions(-) create mode 100644 .github/workflows/sign-windows-wheel.yml create mode 100644 .github/workflows/windows-signing-smoke.yml diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index e0c47252b..d4ae7560f 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -235,97 +235,18 @@ jobs: - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - run: uvx twine check dist/* - # Credentials live only here, after the release source and unsigned archives - # have been inspected. No compiler, native import, or smoke test runs here. - # THREAT[TM-INF-035]: never give build scripts or the native module signer access. + # Only this shared signer can turn unsigned build outputs into publishable + # Windows wheels. It also checks the immutable tag before accessing Azure. sign-windows: name: Sign Windows wheel needs: [inspect] - runs-on: windows-latest - environment: release-windows-signing + uses: ./.github/workflows/sign-windows-wheel.yml permissions: contents: read id-token: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.12" - - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 - with: - name: pypi_unsigned-windows-x86_64-manylinux - path: unsigned-dist - - - name: Install wheel tooling - run: >- - python -m pip install --only-binary=:all: wheel==0.48.0 - - - name: Unpack and retain unsigned file hashes - id: unpack - run: python scripts/windows_wheel.py prepare --source unsigned-dist --work wheel-work - - - name: Read public signing configuration - id: config - shell: pwsh - run: | - $config = Get-Content scripts/windows-signing.json -Raw | ConvertFrom-Json - foreach ($key in @("tenant-id", "subscription-id", "client-id", "endpoint", "signing-account-name", "certificate-profile-name")) { - $value = $config.PSObject.Properties[$key].Value - if ([string]::IsNullOrWhiteSpace($value) -or $value -match "[\r\n]") { - throw "Invalid signing configuration: $key" - } - $name = $key.Replace("-", "_") - "$name=$value" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 - } - - - name: Authenticate release signer with OIDC - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 - with: - client-id: ${{ steps.config.outputs.client_id }} - tenant-id: ${{ steps.config.outputs.tenant_id }} - subscription-id: ${{ steps.config.outputs.subscription_id }} - - - name: Sign native files and timestamp - uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2 - with: - endpoint: ${{ steps.config.outputs.endpoint }} - signing-account-name: ${{ steps.config.outputs.signing_account_name }} - certificate-profile-name: ${{ steps.config.outputs.certificate_profile_name }} - files-folder: ${{ steps.unpack.outputs.root }} - files-folder-filter: pyd,dll,exe - files-folder-recurse: true - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - - name: Verify every native signature - shell: pwsh - env: - WHEEL_ROOT: ${{ steps.unpack.outputs.root }} - run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT - - - name: Repack with regenerated RECORD and verify archive hashes - run: python scripts/windows_wheel.py repack --work wheel-work --output signed-dist - - - name: Extract the final archive for signature verification - id: final - run: python scripts/windows_wheel.py prepare --source signed-dist --work final-check - - - name: Verify signatures from the final wheel - shell: pwsh - env: - WHEEL_ROOT: ${{ steps.final.outputs.root }} - run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT - - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: pypi_files-windows-x86_64-manylinux - path: signed-dist + with: + unsigned-artifact-name: pypi_unsigned-windows-x86_64-manylinux + signed-artifact-name: pypi_files-windows-x86_64-manylinux # Smoke-test the single cp39-abi3 wheel on every targeted Python version. # With abi3 there is one wheel per platform, so this is the check that it diff --git a/.github/workflows/sign-windows-wheel.yml b/.github/workflows/sign-windows-wheel.yml new file mode 100644 index 000000000..e95698d20 --- /dev/null +++ b/.github/workflows/sign-windows-wheel.yml @@ -0,0 +1,118 @@ +# Shared production signing path. No compiler or native import runs with OIDC. +# THREAT[TM-INF-035]: every caller must use an immutable main-reachable tag. +name: Sign Windows wheel + +on: + workflow_call: + inputs: + unsigned-artifact-name: + type: string + required: true + signed-artifact-name: + type: string + required: true + +permissions: + contents: read + +jobs: + sign: + name: Sign Windows wheel + runs-on: windows-latest + timeout-minutes: 10 + environment: release-windows-signing + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Verify immutable tag source + shell: bash + run: | + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-signing-smoke-[0-9a-f]{12})?$ ]]; then + echo "Signing requires a stable release or signing-smoke tag" + exit 1 + fi + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: ${{ inputs.unsigned-artifact-name }} + path: unsigned-dist + + - name: Install wheel tooling + run: >- + python -m pip install --only-binary=:all: wheel==0.48.0 + + - name: Unpack and retain unsigned file hashes + id: unpack + run: python scripts/windows_wheel.py prepare --source unsigned-dist --work wheel-work + + - name: Read public signing configuration + id: config + shell: pwsh + run: | + $config = Get-Content scripts/windows-signing.json -Raw | ConvertFrom-Json + foreach ($key in @("tenant-id", "subscription-id", "client-id", "endpoint", "signing-account-name", "certificate-profile-name")) { + $value = $config.PSObject.Properties[$key].Value + if ([string]::IsNullOrWhiteSpace($value) -or $value -match "[\r\n]") { + throw "Invalid signing configuration: $key" + } + $name = $key.Replace("-", "_") + "$name=$value" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + + - name: Authenticate release signer with OIDC + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 + with: + client-id: ${{ steps.config.outputs.client_id }} + tenant-id: ${{ steps.config.outputs.tenant_id }} + subscription-id: ${{ steps.config.outputs.subscription_id }} + + - name: Sign native files and timestamp + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2 + with: + endpoint: ${{ steps.config.outputs.endpoint }} + signing-account-name: ${{ steps.config.outputs.signing_account_name }} + certificate-profile-name: ${{ steps.config.outputs.certificate_profile_name }} + files-folder: ${{ steps.unpack.outputs.root }} + files-folder-filter: pyd,dll,exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify every native signature + shell: pwsh + env: + WHEEL_ROOT: ${{ steps.unpack.outputs.root }} + run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT + + - name: Repack with regenerated RECORD and verify archive hashes + run: python scripts/windows_wheel.py repack --work wheel-work --output signed-dist + + - name: Extract the final archive for signature verification + id: final + run: python scripts/windows_wheel.py prepare --source signed-dist --work final-check + + - name: Verify signatures from the final wheel + shell: pwsh + env: + WHEEL_ROOT: ${{ steps.final.outputs.root }} + run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: ${{ inputs.signed-artifact-name }} + path: signed-dist + diff --git a/.github/workflows/windows-signing-smoke.yml b/.github/workflows/windows-signing-smoke.yml new file mode 100644 index 000000000..476a813b8 --- /dev/null +++ b/.github/workflows/windows-signing-smoke.yml @@ -0,0 +1,88 @@ +# Exercise the real signing identity without publishing or rebuilding a registry +# version. The input is the exact reported PyPI wheel, verified by SHA-256. +name: Windows signing smoke + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + prepare: + name: Prepare known published wheel + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Require a signing-smoke tag + shell: bash + run: | + [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+-signing-smoke-[0-9a-f]{12}$ ]] + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.12" + - name: Download and verify the original Windows artifact + run: | + python -m pip download --index-url https://pypi.org/simple --platform win_amd64 --only-binary=:all: --no-deps --no-cache-dir --dest unsigned-dist bashkit==0.20.1 + echo "948f3f73a3e1289caeba7581330732fd145d5b26e5c018d97345c1bd92ce9099 unsigned-dist/bashkit-0.20.1-cp39-abi3-win_amd64.whl" | sha256sum --check --strict + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: pypi_unsigned-signing-smoke + path: unsigned-dist + + sign: + needs: [prepare] + uses: ./.github/workflows/sign-windows-wheel.yml + permissions: + contents: read + id-token: write + with: + unsigned-artifact-name: pypi_unsigned-signing-smoke + signed-artifact-name: windows-signing-validation + + verify: + name: Install signed wheel and test pytest loading + needs: [sign] + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11.9" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: windows-signing-validation + path: dist + - name: Install verification tooling + run: >- + python -m pip install --only-binary=:all: wheel==0.48.0 pytest==9.1.1 + - name: Unpack the exact final wheel + id: unpack + run: python scripts/windows_wheel.py prepare --source dist --work final-wheel + - name: Verify the production signatures and timestamps + shell: pwsh + env: + WHEEL_ROOT: ${{ steps.unpack.outputs.root }} + run: ./scripts/verify_windows_signatures.ps1 -Root $env:WHEEL_ROOT + - name: Install the signed wheel + run: python -m pip install --no-index --find-links dist --no-deps bashkit==0.20.1 + - name: Test native import during pytest plugin loading and execution + shell: pwsh + working-directory: ${{ runner.temp }} + run: | + 'from bashkit import BashTool' | Set-Content bashkit_validation_plugin.py + @' + from bashkit import BashTool + + def test_signed_native_execution(): + tool = BashTool() + assert tool.execute_sync('export SIGNING_TEST=verified').exit_code == 0 + result = tool.execute_sync('echo $SIGNING_TEST') + assert result.exit_code == 0 + assert result.stdout.strip() == 'verified' + '@ | Set-Content test_signed_wheel.py + python -m pytest -p bashkit_validation_plugin test_signed_wheel.py -q diff --git a/.github/workflows/windows-signing.yml b/.github/workflows/windows-signing.yml index b93174181..daef5bd77 100644 --- a/.github/workflows/windows-signing.yml +++ b/.github/workflows/windows-signing.yml @@ -8,6 +8,8 @@ on: paths: - ".github/workflows/windows-signing.yml" - ".github/workflows/publish-python.yml" + - ".github/workflows/sign-windows-wheel.yml" + - ".github/workflows/windows-signing-smoke.yml" - "scripts/windows_wheel.py" - "scripts/windows-signing.json" - "scripts/verify_windows_signatures.ps1" @@ -17,6 +19,8 @@ on: paths: - ".github/workflows/windows-signing.yml" - ".github/workflows/publish-python.yml" + - ".github/workflows/sign-windows-wheel.yml" + - ".github/workflows/windows-signing-smoke.yml" - "scripts/windows_wheel.py" - "scripts/windows-signing.json" - "scripts/verify_windows_signatures.ps1" diff --git a/AGENTS.md b/AGENTS.md index eb39d7c72..ee79a7a63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,9 @@ Windows wheel signing checks: `.github/workflows/windows-signing.yml` runs with pinned wheel tooling on Windows. Packaging tests also run on other hosts; WinVerifyTrust tests require Windows. Production signing prerequisites and the unsigned/signed artifact boundary live in `knowledge/runtimes/python-package.md`. +The manual `windows-signing-smoke.yml` workflow tests the actual Azure signer +through the same reusable job as publication, from a main-reachable test tag, +and emits a validation wheel without publishing. See the release knowledge. ### Pre-PR Checklist diff --git a/knowledge/operations/release-process.md b/knowledge/operations/release-process.md index 8b01ddb05..194d00f7f 100644 --- a/knowledge/operations/release-process.md +++ b/knowledge/operations/release-process.md @@ -316,6 +316,14 @@ the final archive. Unit and Windows integration coverage lives in [Python Package](../runtimes/python-package.md#authenticode-release-path) for provisioning prerequisites and the remaining SAC enforcement check. +Before a new Python release, run `windows-signing-smoke.yml` from a +main-reachable `vX.Y.Z-signing-smoke-<12-hex-source-SHA>` tag. It uses the same +reusable signing job as publication and validates Azure OIDC, actual signing, +timestamps, repaired wheel metadata, pytest plugin loading and native execution. +Its artifact is a signed copy of the hash-checked historical 0.20.1 Windows wheel +for controlled comparison, never a registry release. Retain the run and artifact +digest as evidence; Windows Server success does not prove SAC-On enforcement. + ### publish-js.yml Trigger: Release published (parallel). Builds native NAPI-RS bindings diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index b52114a02..0d5a7acaa 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -129,6 +129,20 @@ The same workflow downloads the exact two reported PyPI wheels, verifies their recorded SHA-256 digests, records native signatures, and imports/executes each under Python 3.11.9 as a Windows Server baseline. Its success is not a SAC result. +`sign-windows-wheel.yml` is shared by the publisher and a manual +`windows-signing-smoke.yml` check. The signer itself verifies that its immutable +source is reachable from main and its ref is either a stable release tag or a +`vX.Y.Z-signing-smoke-<12-hex-source-SHA>` tag; callers cannot skip this gate. +After merging configuration, create such a test tag on the merged main commit +and dispatch the smoke workflow from it. The smoke workflow hashes the exact +published 0.20.1 Windows wheel, signs a copy through the production Azure OIDC +path, verifies signatures and repaired RECORD, then installs it on a separate +credential-free Windows runner. Pytest explicitly loads a plugin importing the +native module and executes a sandbox-state check. The resulting +`windows-signing-validation` artifact is for validation only: no registry upload, +GitHub release, or replacement of the original PyPI wheel occurs. Use its exact +bytes for the remaining SAC-On check before a new production patch release. + ## Public API Full signatures: `crates/bashkit-python/bashkit/_bashkit.pyi`. Runnable diff --git a/scripts/tests/test_windows_signing.py b/scripts/tests/test_windows_signing.py index f317d228e..e0eb23b54 100644 --- a/scripts/tests/test_windows_signing.py +++ b/scripts/tests/test_windows_signing.py @@ -151,6 +151,46 @@ def test_rejects_tampering_unsigned_files_and_empty_directory(self): class SigningWorkflowTests(unittest.TestCase): + @unittest.skipUnless(os.name != "nt" and shutil.which("git") and shutil.which("bash"), + "requires POSIX git fixture") + def test_signer_rejects_branch_refs_and_tags_outside_main(self): + jobs = yaml.safe_load((ROOT / ".github/workflows/sign-windows-wheel.yml").read_text())["jobs"] + guard = next(s["run"] for s in jobs["sign"]["steps"] + if s.get("name") == "Verify immutable tag source") + with tempfile.TemporaryDirectory() as temp: + upstream = Path(temp) / "upstream" + checkout = Path(temp) / "checkout" + + def git(*args, cwd=upstream): + return subprocess.check_output(["git", *args], cwd=cwd, text=True, + stderr=subprocess.DEVNULL).strip() + + subprocess.run(["git", "init", "-b", "main", str(upstream)], check=True, + capture_output=True) + commit = ["-c", "user.name=Signing Fixture", "-c", "user.email=fixture@example.test", + "commit", "--allow-empty", "-m", "fixture"] + git(*commit) + trusted = git("rev-parse", "HEAD") + git("switch", "-c", "unreviewed") + git(*commit) + unreviewed = git("rev-parse", "HEAD") + git("switch", "main") + subprocess.run(["git", "clone", str(upstream), str(checkout)], check=True, + capture_output=True) + cases = [ + ("refs/tags/v1.2.3", trusted, True), + ("refs/tags/v1.2.3-signing-smoke-" + trusted[:12], trusted, True), + ("refs/heads/main", trusted, False), + ("refs/tags/v1.2.3", unreviewed, False), + ] + for ref, sha, allowed in cases: + with self.subTest(ref=ref, source=sha): + git("checkout", "--detach", sha, cwd=checkout) + result = subprocess.run(["bash", "-e", "-c", guard], cwd=checkout, + env={**os.environ, "GITHUB_REF": ref, "GITHUB_SHA": sha}, + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, allowed, result.stdout + result.stderr) + def test_published_comparison_uses_exact_reported_wheels_without_signing_credentials(self): jobs = yaml.safe_load((ROOT / ".github/workflows/windows-signing.yml").read_text())["jobs"] baseline = jobs["published-baseline"] @@ -170,12 +210,14 @@ def test_unsigned_artifacts_cannot_reach_pypi(self): self.assertIn("sign-windows", jobs["test-builds"]["needs"]) signing = jobs["sign-windows"] self.assertIn("inspect", signing["needs"]) - self.assertEqual(signing["environment"], "release-windows-signing") + self.assertEqual(signing["uses"], "./.github/workflows/sign-windows-wheel.yml") + worker = yaml.safe_load((ROOT / ".github/workflows/sign-windows-wheel.yml").read_text())["jobs"]["sign"] + self.assertEqual(worker["environment"], "release-windows-signing") self.assertEqual(signing["permissions"], {"contents": "read", "id-token": "write"}) upload = next(s for s in jobs["build"]["steps"] if s.get("uses", "").startswith("actions/upload-artifact@")) self.assertIn("pypi_unsigned", upload["with"]["name"]) self.assertIn("matrix.os == 'windows'", upload["with"]["name"]) - sign_step = next(s for s in signing["steps"] if s.get("uses", "").startswith("azure/artifact-signing-action@")) + sign_step = next(s for s in worker["steps"] if s.get("uses", "").startswith("azure/artifact-signing-action@")) self.assertEqual(sign_step["with"]["files-folder-filter"], "pyd,dll,exe") self.assertEqual(sign_step["with"]["timestamp-digest"], "SHA256") publish_downloads = [s for s in jobs["publish"]["steps"] @@ -183,13 +225,28 @@ def test_unsigned_artifacts_cannot_reach_pypi(self): self.assertTrue(publish_downloads) for step in publish_downloads: self.assertEqual(step["with"]["pattern"], "pypi_files-*") - signed_upload = next(s for s in signing["steps"] + signed_upload = next(s for s in worker["steps"] if s.get("uses", "").startswith("actions/upload-artifact@")) self.assertEqual(signed_upload["with"]["path"], "signed-dist") - self.assertEqual(signed_upload["with"]["name"], "pypi_files-windows-x86_64-manylinux") + self.assertEqual(signed_upload["with"]["name"], "${{ inputs.signed-artifact-name }}") + self.assertEqual(signing["with"]["signed-artifact-name"], "pypi_files-windows-x86_64-manylinux") + source_guard = next(s["run"] for s in worker["steps"] if s.get("name") == "Verify immutable tag source") + self.assertIn('git merge-base --is-ancestor "$GITHUB_SHA" origin/main', source_guard) + self.assertIn("refs/tags/v", source_guard) for name in ("build", "build-sdist", "build-emscripten", "test-builds"): self.assertNotEqual(jobs[name].get("permissions", {}).get("id-token"), "write") + def test_production_signing_smoke_cannot_publish(self): + workflow = yaml.safe_load((ROOT / ".github/workflows/windows-signing-smoke.yml").read_text()) + jobs = workflow["jobs"] + events = workflow.get("on", workflow.get(True)) + self.assertEqual(set(events), {"workflow_dispatch"}) + self.assertEqual(jobs["sign"]["uses"], "./.github/workflows/sign-windows-wheel.yml") + self.assertEqual(jobs["sign"]["with"]["signed-artifact-name"], "windows-signing-validation") + self.assertNotIn("publish", jobs) + self.assertNotIn("environment", jobs["verify"]) + self.assertNotEqual(jobs["verify"].get("permissions", {}).get("id-token"), "write") + if __name__ == "__main__": unittest.main()