diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 87e1533..b40d3d1 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,3 +4,13 @@ paths: # GitHub.com documents job.workflow_sha, but actionlint 1.7.12 predates it. # https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#job-context - 'property "workflow_sha" is not defined in object type' + .github/workflows/android-finalize.yml: + ignore: + # Documented GitHub.com features newer than actionlint 1.7.12. + - 'property "workflow_(ref|sha)" is not defined in object type' + - 'specifying action "\$/android-signer" in invalid format' + .github/workflows/android-publish.yml: + ignore: + # Documented GitHub.com features newer than actionlint 1.7.12. + - 'property "workflow_(ref|sha)" is not defined in object type' + - 'specifying action "\$/android-signer" in invalid format' diff --git a/.github/workflows/android-attest.yml b/.github/workflows/android-attest.yml new file mode 100644 index 0000000..4320ac9 --- /dev/null +++ b/.github/workflows/android-attest.yml @@ -0,0 +1,74 @@ +name: Trusted Android artifact provenance +on: + workflow_call: + inputs: + profile: + description: Profile used in this run's immutable artifact names + required: true + type: string +permissions: + contents: read +jobs: + attest-unsigned: + name: Attest unsigned ${{ inputs.profile }} + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + id-token: write + attestations: write + steps: + - name: Validate artifact profile + env: + PROFILE: ${{ inputs.profile }} + run: | + python3 - <<'PYTHON' + import os, re + if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", os.environ["PROFILE"]): + raise SystemExit("Invalid artifact profile") + PYTHON + - name: Download this attempt's immutable inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsigned-${{ inputs.profile }}-${{ github.run_attempt }} + path: unsigned/ + - name: Download this attempt's release support files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-support-${{ inputs.profile }}-${{ github.run_attempt }} + path: support/ + - name: Validate artifact handoff + run: | + python3 - <<'PYTHON' + from pathlib import Path + for directory, expected in ( + ("unsigned", {"app.apk", "app.aab"}), + ("support", {"mapping.txt", "native-debug-symbols.zip", "build-info.json"}), + ): + entries = list(Path(directory).rglob("*")) + files = {str(p.relative_to(directory)) for p in entries if p.is_file()} + if any(p.is_symlink() for p in entries) or files != expected: + raise SystemExit("Unexpected artifact handoff: " + directory) + PYTHON + - name: Attest original APK, AAB, and release support files + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + unsigned/app.apk + unsigned/app.aab + support/mapping.txt + support/native-debug-symbols.zip + support/build-info.json + create-storage-record: false + - name: Preserve provenance bundle + env: + BUNDLE: ${{ steps.provenance.outputs.bundle-path }} + FLAVOR: ${{ inputs.profile }} + run: cp "$BUNDLE" "provenance-${FLAVOR}.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: provenance-${{ inputs.profile }}-${{ github.run_attempt }} + path: provenance-${{ inputs.profile }}.json + if-no-files-found: error diff --git a/.github/workflows/android-finalize.yml b/.github/workflows/android-finalize.yml new file mode 100644 index 0000000..aec7536 --- /dev/null +++ b/.github/workflows/android-finalize.yml @@ -0,0 +1,100 @@ +name: Verify and attest signed Android release +on: + workflow_call: + inputs: + source_run: + type: number + required: true + source_attempt: + type: number + required: true + outputs: + artifact_id: + description: Immutable verified publishing handoff + value: ${{ jobs.attest.outputs.artifact_id }} + manifest_sha256: + description: SHA-256 of the attested release manifest + value: ${{ jobs.attest.outputs.manifest_sha256 }} +permissions: + contents: read +concurrency: + group: signer-finalize-${{ inputs.source_run }}-${{ inputs.source_attempt }} + cancel-in-progress: false +jobs: + attest: + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + runs-on: ubuntu-latest + timeout-minutes: 60 + outputs: + artifact_id: ${{ steps.handoff.outputs.artifact-id }} + manifest_sha256: ${{ steps.seal.outputs.manifest_sha256 }} + permissions: + contents: write + actions: read + id-token: write + attestations: write + steps: + - name: Load approved organization policy + uses: $/android-signer + with: + command: policy + out: ${{ runner.temp }}/signer-policy.json + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: zulu + java-version: '25' + - name: Verify both signing transactions + uses: $/android-signer + env: + GH_TOKEN: ${{ github.token }} + SIGNER_JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + SIGNER_JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + with: + command: prepare + source-run: ${{ inputs.source_run }} + source-attempt: ${{ inputs.source_attempt }} + policy: ${{ runner.temp }}/signer-policy.json + out: ${{ runner.temp }}/verified-release + tools: ${{ runner.temp }}/reference-tools + - name: Attest final signed files + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + ${{ runner.temp }}/verified-release/oss/app-signed.apk + ${{ runner.temp }}/verified-release/oss/app-signed.aab + ${{ runner.temp }}/verified-release/google/app-signed.apk + ${{ runner.temp }}/verified-release/google/app-signed.aab + ${{ runner.temp }}/verified-release/publish/* + predicate-type: https://slsa.dev/provenance/v1 + predicate-path: ${{ runner.temp }}/verified-release/predicate.json + create-storage-record: false + - name: Prepare verified draft + id: seal + uses: $/android-signer + env: + GH_TOKEN: ${{ github.token }} + SIGNER_JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + SIGNER_JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + with: + command: seal + policy: ${{ runner.temp }}/signer-policy.json + out: ${{ runner.temp }}/verified-release + bundle: ${{ steps.provenance.outputs.bundle-path }} + - name: Preserve immutable publishing handoff + id: handoff + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verified-publishing-${{ inputs.source_run }}-${{ inputs.source_attempt }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/verified-release/publish/ + if-no-files-found: error + - name: Preserve verification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-release-verification-${{ inputs.source_run }}-${{ inputs.source_attempt }} + path: | + ${{ runner.temp }}/verified-release/verification.json + ${{ runner.temp }}/verified-release/predicate.json + ${{ runner.temp }}/verified-release/final.sigstore.json + if-no-files-found: warn diff --git a/.github/workflows/android-publish.yml b/.github/workflows/android-publish.yml new file mode 100644 index 0000000..c968172 --- /dev/null +++ b/.github/workflows/android-publish.yml @@ -0,0 +1,116 @@ +name: Publish verified Android release +on: + workflow_call: + inputs: + artifact_id: + type: string + required: true + manifest_sha256: + type: string + required: true +permissions: + contents: read +jobs: + github: + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: publish-github-${{ github.repository_id }}-${{ inputs.manifest_sha256 }} + cancel-in-progress: false + permissions: + contents: write + actions: read + steps: + - name: Load approved organization policy + uses: $/android-signer + with: + command: policy + out: ${{ runner.temp }}/signer-policy.json + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact_id }} + path: ${{ runner.temp }}/publish + merge-multiple: true + - name: Verify and publish GitHub release + uses: $/android-signer + env: + GH_TOKEN: ${{ github.token }} + SIGNER_JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + SIGNER_JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + with: + command: github + policy: ${{ runner.temp }}/signer-policy.json + out: ${{ runner.temp }}/publish + manifest-sha256: ${{ inputs.manifest_sha256 }} + report: ${{ runner.temp }}/github-publication.json + - name: Preserve publication result + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: github-publication-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/github-publication.json + if-no-files-found: error + play: + if: ${{ vars.PLAY_ALPHA_ENABLED == 'true' && github.repository == 'connectbot/connectbot' && github.repository_id == '123235' && github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + environment: play-alpha + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: publish-play-org-connectbot-alpha + cancel-in-progress: false + permissions: + contents: read + actions: read + id-token: write + steps: + - name: Load approved organization policy + uses: $/android-signer + with: + command: policy + out: ${{ runner.temp }}/signer-policy.json + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact_id }} + path: ${{ runner.temp }}/publish + merge-multiple: true + - name: Verify publishing handoff before Google authentication + uses: $/android-signer + env: + GH_TOKEN: ${{ github.token }} + SIGNER_JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + SIGNER_JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + with: + command: verify + policy: ${{ runner.temp }}/signer-policy.json + out: ${{ runner.temp }}/publish + manifest-sha256: ${{ inputs.manifest_sha256 }} + - name: Obtain short-lived Play credentials + id: google + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + project_id: api-4664730880327298161-873945 + workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GOOGLE_PLAY_SERVICE_ACCOUNT }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/androidpublisher + create_credentials_file: false + export_environment_variables: false + - name: Publish verified Google bundle to alpha + uses: $/android-signer + env: + GH_TOKEN: ${{ github.token }} + SIGNER_JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + SIGNER_JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + GOOGLE_PLAY_ACCESS_TOKEN: ${{ steps.google.outputs.access_token }} + with: + command: play + policy: ${{ runner.temp }}/signer-policy.json + out: ${{ runner.temp }}/publish + manifest-sha256: ${{ inputs.manifest_sha256 }} + report: ${{ runner.temp }}/play-publication.json + - name: Preserve publication result + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: play-publication-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/play-publication.json + if-no-files-found: error diff --git a/.github/workflows/android-signer-ci.yml b/.github/workflows/android-signer-ci.yml index b9154d6..10ee1bb 100644 --- a/.github/workflows/android-signer-ci.yml +++ b/.github/workflows/android-signer-ci.yml @@ -1,19 +1,14 @@ name: Android signer tests on: push: - branches: [main] - paths: - - 'android-signer/**' - - '.github/workflows/android-signer-ci.yml' + branches: [main, android-signer-v1] pull_request: - branches: [main] - paths: - - 'android-signer/**' - - '.github/workflows/android-signer-ci.yml' + branches: [main, android-signer-v1] permissions: contents: read jobs: test: + name: Android signer contract tests runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -26,4 +21,4 @@ jobs: - name: Verify action contracts and adversarial publishing cases run: python3 -m unittest discover -s android-signer/tests -v - name: Validate workflow - run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/android-signer-ci.yml + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/android-signer-ci.yml .github/workflows/android-attest.yml .github/workflows/android-finalize.yml .github/workflows/android-publish.yml diff --git a/android-signer/README.md b/android-signer/README.md index 1a5832c..d327028 100644 --- a/android-signer/README.md +++ b/android-signer/README.md @@ -5,49 +5,93 @@ signer. Private Android keys stay on the signing appliance. This action runs Python standard-library code and pinned Android reference tools; it does not build Android applications or hold their signing keys. -Use `connectbot/actions/android-signer@FULL_COMMIT_SHA`. Keep the thin build, -attestation, finalization, and publishing workflows in the consuming repository, -with separate hosted jobs and minimal permissions. Keep its public signing -policy there too. Moving this composite action does not change reusable workflow -identities or Google OIDC conditions. - -```yaml -- uses: connectbot/actions/android-signer@FULL_COMMIT_SHA - env: - GH_TOKEN: ${{ github.token }} - with: - command: prepare - source-run: ${{ inputs.source_run }} - source-attempt: ${{ inputs.source_attempt }} - policy: .github/signer-policy.json - out: ${{ runner.temp }}/verified-release - tools: ${{ runner.temp }}/reference-tools -``` +Use the trusted reusable workflows below for attestation, finalization, and +publication. Their policy loader always reads the centrally governed +`connectbot/.github` repository; callers cannot replace the policy source. +The composite action provides metadata and verification operations to these +workflows. Application build jobs use only its `build-info` operation. Operations: | Command | Required inputs besides `command` and `out` | | --- | --- | | `build-info` | `profile`, `version-code-file` | -| `prepare` | `source-run`, `source-attempt`, `tools`; optional `policy` | -| `seal` | `bundle`; optional `policy` | -| `verify` | `manifest-sha256`; optional `policy` | -| `github` | `manifest-sha256`, `report`; optional `policy` | -| `play` | `manifest-sha256`, `report`; optional `policy` | +| `prepare` | `source-run`, `source-attempt`, `tools`, `policy` | +| `seal` | `bundle`, `policy` | +| `verify` | `manifest-sha256`, `policy` | +| `github` | `manifest-sha256`, `report`, `policy` | +| `play` | `manifest-sha256`, `report`, `policy` | -`policy` defaults to `.github/signer-policy.json`. `seal` emits +The `policy` operation writes the central policy snapshot. All verification and +publication operations require its explicit path; there is no local policy +fallback. `seal` emits `manifest_sha256`. Callers provide Java 25 for `prepare`, GitHub CLI and `GH_TOKEN` for network operations, and a short-lived `GOOGLE_PLAY_ACCESS_TOKEN` for `play`. Verify the handoff with `verify` before requesting Google credentials. Play publishing is restricted by the consumer workflow to its alpha environment; the helper additionally enforces package `org.connectbot` and track `alpha`. -The signing appliance pins the exact caller/build workflow contents. The build -workflow's literal full action commit binds the action's Git tree, including -Python helpers, Java verifier, and tool digests. Updating the action revision in -that workflow requires review and a new appliance policy revision. Never replace -the action reference with a branch/tag or fetch helper scripts from mutable refs. - Run `python3 -m unittest discover -s android-signer/tests -v` from the actions repository. Tests mock credentials and APIs and cover receipt/provenance tampering, Android payload preservation, publishing recovery, and argument injection. + + +## Trusted reusable workflows + +Keep application builds in the consuming repository: Java/SDK setup, Gradle tasks, +flavor matrices, output paths, and release metadata belong beside the application. +Build jobs must have only `contents: read` permission and no signing secrets or +provenance credentials. Use fresh hosted runners and disable shared build caches. +The `build-info` composite command can create release metadata alongside the build. + +After the build succeeds, call +`connectbot/actions/.github/workflows/android-attest.yml@android-signer-v1` once +per profile: + +```yaml +attest-unsigned: + needs: release + if: ${{ github.event_name == 'push' }} + strategy: + matrix: + flavor: [oss, google] + uses: connectbot/actions/.github/workflows/android-attest.yml@android-signer-v1 + permissions: + contents: read + actions: read + id-token: write + attestations: write + with: + profile: ${{ matrix.flavor }} +``` + +The build must upload `unsigned-PROFILE-ATTEMPT` containing `app.apk` and `app.aab`, +and `release-support-PROFILE-ATTEMPT` containing `mapping.txt`, +`native-debug-symbols.zip`, and `build-info.json`, with flat paths. `ATTEMPT` is +`${{ github.run_attempt }}`. This is a stable handoff contract, independent of +Gradle task names, project layout, Java versions, and original output filenames. +A new profile additionally needs independently provisioned signing policy/keys. + +The shared attestor accepts only a profile label. It derives repository, run and +attempt from GitHub, downloads that run's immutable artifacts, checks their file +sets, and computes the subject digests on a fresh hosted runner. It accepts no +caller commands, artifact IDs, source overrides, or signing digests and never +checks out or executes application code. Provenance authenticates the source run +and output bytes; it does not certify the application's build logic as safe. + +The finalizer and publishers are `android-finalize.yml` and `android-publish.yml` +at the same protected branch. They load the public policy from immutable +repository ID 1215286979 (`connectbot/.github`), protected `main`, at +`.github/android-signer/REPOSITORY_ID.json`. The source repository cannot supply a +replacement policy. The resolved policy commit/digest and builder revision are +recorded in final provenance. Google credentials are requested only after handoff +verification. Keep the policy repository and `android-signer-v1` protected. + +The VM authorizes the builder repository ID, workflow +path and protected branch. Caller paths still identify eligible CI entry points, +as a path allowlist. Key identities, source refs, package names, +rotation constraints, run/attempt and artifact digests remain enforced. Routine +caller edits and reviewed updates on the trusted branch do not change VM policy. +Only trusted reusable-workflow authorization is supported. Version 3 policy +requires explicit builder/finalizer identities and contains no authorization mode, +caller-file digests, or workflow/helper content pins. diff --git a/android-signer/action.yml b/android-signer/action.yml index 3f7876b..39d7712 100644 --- a/android-signer/action.yml +++ b/android-signer/action.yml @@ -3,14 +3,13 @@ description: Prepare build metadata or verify and publish attested Hermit signin author: ConnectBot inputs: command: - description: 'Operation: build-info, prepare, seal, verify, github, or play.' + description: 'Operation: policy, build-info, prepare, seal, verify, github, or play.' required: true out: - description: Output directory, or verified publishing handoff for verify/github/play. + description: Output directory or handoff; output file path for policy. required: true policy: - description: Independently approved public signer policy in the consumer checkout. - default: .github/signer-policy.json + description: Explicit central policy snapshot produced by the policy command. profile: description: Android flavor for build-info (oss or google). version-code-file: diff --git a/android-signer/scripts/run_action.py b/android-signer/scripts/run_action.py index 249e7e1..65e841f 100644 --- a/android-signer/scripts/run_action.py +++ b/android-signer/scripts/run_action.py @@ -7,6 +7,7 @@ import sys OPTIONS = { + "policy": (), "build-info": ("profile", "version-code-file"), "prepare": ("policy", "source-run", "source-attempt", "tools"), "seal": ("policy", "bundle"), diff --git a/android-signer/scripts/signer-publish.py b/android-signer/scripts/signer-publish.py index 2ca0983..d05cff4 100644 --- a/android-signer/scripts/signer-publish.py +++ b/android-signer/scripts/signer-publish.py @@ -24,6 +24,40 @@ SUPPORT = {"mapping.txt", "native-debug-symbols.zip", "build-info.json"} STABLE = r"v[0-9]+\.[0-9]+\.[0-9]+" TAG = r"(?:git-|v)[A-Za-z0-9_.+\-]{1,150}" +POLICY_REPOSITORY = "connectbot/.github" +POLICY_REPOSITORY_ID = 1215286979 + + +def policy_api(path): + # Central policy is public. Source-repository tokens never leave their scope. + request = urllib.request.Request("https://api.github.com/repos/" + POLICY_REPOSITORY + path, + headers={"Accept": "application/vnd.github+json", "User-Agent": "connectbot-android-signer"}) + with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response: + data = response.read(1024 * 1024 + 1) + require(len(data) <= 1024 * 1024, "central policy response too large") + return load(data) + + +def fetch_policy(out): + repository, identifier = os.environ["GITHUB_REPOSITORY"], os.environ["GITHUB_REPOSITORY_ID"] + require(re.fullmatch(r"[1-9][0-9]*", identifier), "invalid platform repository ID") + require(policy_api("")["id"] == POLICY_REPOSITORY_ID, "central policy repository identity differs") + branch = policy_api("/branches/main") + require(branch["protected"] is True and r.valid_hash(branch["commit"]["sha"], 40), "central policy branch is unprotected") + commit = branch["commit"]["sha"] + path = ".github/android-signer/" + identifier + ".json" + file = policy_api("/contents/" + path + "?ref=" + commit) + require(file.get("type") == "file" and file.get("encoding") == "base64" and file.get("path") == path, "central policy file missing") + import base64 + data = base64.b64decode(file["content"].replace("\n", ""), validate=True) + policy = load(data) + r.validate_policy(policy) + require(policy["repository"] == repository and policy["repository_id"] == int(identifier), "central policy is for another repository") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(data) + out.with_suffix(".source.json").write_bytes(json_bytes({"repository": POLICY_REPOSITORY, + "repository_id": POLICY_REPOSITORY_ID, "ref": "refs/heads/main", "commit": commit, + "path": path, "sha256": digest(data)})) def source_identity(): @@ -128,6 +162,11 @@ def package_release(out, policy_path): if stable: add_asset(root, assets, "version-info.txt", f"VERSION_NAME={info['tag'][1:]}\nVERSION_CODE={info['version_code']}\n".encode()) predicate = load((out / "predicate.json").read_bytes()) + origin = load(policy_path.with_suffix(".source.json").read_bytes()) + require(origin["repository"] == POLICY_REPOSITORY and origin["repository_id"] == POLICY_REPOSITORY_ID + and origin["sha256"] == digest(policy_path.read_bytes()) and r.valid_hash(origin["commit"], 40), "central policy snapshot differs") + dependencies.append({"uri": f"git+https://github.com/{POLICY_REPOSITORY}@refs/heads/main#{origin['path']}", + "digest": {"gitCommit": origin["commit"], "sha256": origin["sha256"]}}) predicate["buildDefinition"]["resolvedDependencies"].extend(dependencies) (out / "predicate.json").write_bytes(json_bytes(predicate)) for name, data in (("predicate.json", json_bytes(predicate)), ("verification.json", json_bytes(verification)), @@ -151,11 +190,13 @@ def verify_handoff(root, expected_hash, policy_path): r.validate_finalizer_context(meta) require(meta["id"] == policy["repository_id"] and meta["visibility"] == policy["visibility"], "publisher repository identity differs") require(os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted" and os.environ["GITHUB_REPOSITORY"] == policy["repository"], "publisher context differs") + finalizer = policy["finalizer"] verified = load(r.run("gh", "attestation", "verify", path, "--bundle", root / "final.sigstore.json", "--repo", policy["repository"], - "--signer-workflow", f"{policy['repository']}/.github/workflows/release-attest.yml", "--source-digest", os.environ["GITHUB_SHA"], - "--signer-digest", os.environ["GITHUB_SHA"], "--source-ref", os.environ["GITHUB_REF"], "--deny-self-hosted-runners", "--format", "json")) + "--signer-workflow", f"{finalizer['repository']}/{finalizer['workflow']}", "--source-digest", os.environ["GITHUB_SHA"], + "--source-ref", os.environ["GITHUB_REF"], "--deny-self-hosted-runners", "--format", "json")) require(len(verified) == 1, "ambiguous final provenance") result = verified[0]["verificationResult"] + signer_uri, signer_commit = r.verify_finalizer_certificate(policy, result["signature"]["certificate"]) manifest = load(path.read_bytes()) require(set(manifest) == {"version", "repository", "repository_id", "source", "policy_hash", "release", "version_codes", "finalizer", "assets"} and manifest["version"] == 1, "unsupported release manifest") @@ -167,6 +208,9 @@ def verify_handoff(root, expected_hash, policy_path): and type(producer["attempt"]) is int and 0 < producer["attempt"] <= current["attempt"], "handoff identity differs") statement = result["statement"] predicate = statement["predicate"] + require(predicate["runDetails"]["builder"]["id"] == signer_uri + and predicate["buildDefinition"]["internalParameters"]["finalizerCommit"] == signer_commit, + "final predicate builder differs from platform certificate") invocation = f"https://github.com/{policy['repository']}/actions/runs/{producer['run_id']}/attempts/{producer['attempt']}" require(statement["predicateType"] == r.SLSA and predicate["buildDefinition"]["buildType"] == r.BUILD_TYPE and result["signature"]["certificate"]["runInvocationURI"] == invocation @@ -243,9 +287,13 @@ def verify_existing_publication(root, manifest, existing): path = Path(directory) (path / "release-manifest.json").write_bytes(old_bytes) (path / "final.sigstore.json").write_bytes(r.download_asset(manifest["repository"], by_name["final.sigstore.json"])) + policy = load((root / "signer-policy.json").read_bytes()) + finalizer = policy["finalizer"] verified = load(r.run("gh", "attestation", "verify", path / "release-manifest.json", "--bundle", path / "final.sigstore.json", "--repo", manifest["repository"], - "--signer-workflow", f"{manifest['repository']}/.github/workflows/release-attest.yml", "--source-digest", old["finalizer"]["commit"], - "--signer-digest", old["finalizer"]["commit"], "--source-ref", old["finalizer"]["ref"], "--deny-self-hosted-runners", "--format", "json")) + "--signer-workflow", f"{finalizer['repository']}/{finalizer['workflow']}", "--source-digest", old["finalizer"]["commit"], + "--source-ref", old["finalizer"]["ref"], "--deny-self-hosted-runners", "--format", "json")) + require(len(verified) == 1, "ambiguous published provenance") + r.verify_finalizer_certificate(policy, verified[0]["verificationResult"]["signature"]["certificate"]) require(len(verified) == 1, "ambiguous published attestation") result = verified[0]["verificationResult"] require(result["signature"]["certificate"]["runInvocationURI"] == f"https://github.com/{manifest['repository']}/actions/runs/{old['finalizer']['run_id']}/attempts/{old['finalizer']['attempt']}" @@ -438,9 +486,9 @@ def play_release(client, root, manifest): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("build-info", "prepare", "seal", "verify", "github", "play")) + parser.add_argument("command", choices=("policy", "build-info", "prepare", "seal", "verify", "github", "play")) parser.add_argument("--out", type=Path, required=True) - parser.add_argument("--policy", type=Path, default=Path(".github/signer-policy.json")) + parser.add_argument("--policy", type=Path) parser.add_argument("--source-run", type=int) parser.add_argument("--source-attempt", type=int) parser.add_argument("--tools", type=Path) @@ -450,8 +498,11 @@ def main(): parser.add_argument("--bundle", type=Path) parser.add_argument("--report", type=Path) args = parser.parse_args() + require(args.command in ("policy", "build-info") or args.policy, "explicit central policy snapshot required") result = None - if args.command == "build-info": + if args.command == "policy": + fetch_policy(args.out) + elif args.command == "build-info": require(args.profile and args.version_code_file, "profile/version code file required") build_info(args.out, args.profile, args.version_code_file) elif args.command == "prepare": diff --git a/android-signer/scripts/signer-release.py b/android-signer/scripts/signer-release.py index 0e38ccb..a596e5d 100644 --- a/android-signer/scripts/signer-release.py +++ b/android-signer/scripts/signer-release.py @@ -75,21 +75,51 @@ def allowed_ref(patterns, value): and "*" not in p[:-1] and value.startswith(p[:-1])) for p in patterns) -def pin_file(repository, repository_id, path, commit, pins): - require(valid_hash(commit, 40), "invalid pinned commit") - require(api(f"repos/{repository}")["id"] == repository_id, "pinned repository ID differs") - require(re.fullmatch(r"[A-Za-z0-9_.\-/]+", path) and all(p not in ("", ".", "..") for p in path.split("/")), "unsafe pinned path") - value = api(f"repos/{repository}/contents/{path}?ref={commit}") - require(value.get("type") == "file" and value.get("encoding") == "base64" and value.get("path") == path, "pinned file missing") - require(digest(base64.b64decode(value["content"].replace("\n", ""), validate=True)) in pins, "unapproved workflow/helper contents") +def verify_workflow_identity(builder): + validate_workflow_identity(builder) + require(len(builder["refs"]) == 1 and builder["refs"][0].startswith("refs/heads/"), "one trusted builder branch required") + meta = api(f"repos/{builder['repository']}") + require(meta["id"] == builder["repository_id"] and meta["visibility"] == "public", "trusted workflow repository differs") + branch = builder["refs"][0][len("refs/heads/"):] + require(re.fullmatch(r"[A-Za-z0-9_.\-/]+", branch) and all(part not in ("", ".", "..") for part in branch.split("/")), "unsafe builder branch") + require(api(f"repos/{builder['repository']}/branches/{branch}")["protected"] is True, "trusted workflow branch is unprotected") + + +def validate_workflow_identity(builder): + require(set(builder) == {"repository", "repository_id", "workflow", "refs"}, "unsupported workflow policy fields") + require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", builder["repository"]), "invalid trusted repository") + require(type(builder["repository_id"]) is int and builder["repository_id"] > 0, "invalid trusted repository ID") + require(re.fullmatch(r"\.github/workflows/[A-Za-z0-9_-]+\.ya?ml", builder["workflow"]), "invalid trusted workflow") + require(isinstance(builder["refs"], list) and len(builder["refs"]) == 1, "one trusted branch required") + branch = builder["refs"][0].removeprefix("refs/heads/") + require(builder["refs"][0].startswith("refs/heads/") and re.fullmatch(r"[A-Za-z0-9_.\-/]+", branch) + and all(part not in ("", ".", "..") for part in branch.split("/")), "invalid trusted branch") + + +def verify_finalizer_certificate(policy, certificate): + finalizer = policy["finalizer"] + verify_workflow_identity(finalizer) + expected = f"https://github.com/{finalizer['repository']}/{finalizer['workflow']}@{finalizer['refs'][0]}" + require(certificate["buildSignerURI"] == expected and valid_hash(certificate["buildSignerDigest"], 40), "final attestation builder differs") + return expected, certificate["buildSignerDigest"] def validate_policy(policy): - require(policy.get("version") == 2 and policy.get("visibility") == "public", "version 2 public release policy required") + require(policy.get("version") == 3 and policy.get("visibility") == "public", "version 3 public release policy required") + require(all(k in policy for k in ("builder", "finalizer", "callers")), "required policy identities missing") require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", policy["repository"]), "invalid repository") require(isinstance(policy["repository_id"], int) and policy["repository_id"] > 0, "invalid repository ID") require(valid_hash(policy["policy_hash"]) and valid_hash(policy["signer_revision"], 40), "deployment policy/revision not provisioned") require(policy["revision"] > 0 and policy["profiles"] and policy["receipt_keys"], "incomplete signing policy") + require(isinstance(policy["callers"], list) and policy["callers"] + and len(set(policy["callers"])) == len(policy["callers"]) + and all(re.fullmatch(r"\.github/workflows/[A-Za-z0-9_-]+\.ya?ml", path) for path in policy["callers"]), "invalid caller allowlist") + builder, finalizer = policy["builder"], policy["finalizer"] + for workflow in (builder, finalizer): + validate_workflow_identity(workflow) + require(workflow["repository"] != policy["repository"] and workflow["repository_id"] != policy["repository_id"], "trusted workflows must be separate from source") + require(all(finalizer[k] == builder[k] for k in ("repository", "repository_id", "refs")) + and finalizer["workflow"] != builder["workflow"], "finalizer must be separate in the same trusted repository and branch") for name, profile in policy["profiles"].items(): require(re.fullmatch(r"[a-z0-9-]{1,32}", name), "invalid profile") require(all(valid_hash(v) for v in profile["keys"].values() if isinstance(v, str)), "invalid key pin") @@ -173,10 +203,7 @@ def verify_provenance(path, bundle, policy, source_run, summary): require(predicate["runDetails"]["builder"]["id"] == summary["builder"] and predicate["runDetails"]["metadata"]["invocationId"] == invocation, "predicate invocation differs") require(any(d.get("uri") == f"git+{uri}@{workflow['ref']}" and d.get("digest", {}).get("gitCommit") == source_run["head_sha"] for d in definition["resolvedDependencies"]), "source dependency differs") require(any(s.get("digest", {}).get("sha256") == digest(path.read_bytes()) for s in statement["subject"]), "upstream subject differs") - pin_file(policy["repository"], policy["repository_id"], workflow["path"], source_run["head_sha"], policy["callers"][workflow["path"]]) - pin_file(builder["repository"], builder["repository_id"], builder["workflow"], summary["builder_commit"], builder["sha256"]) - for name, pins in builder["helpers"].items(): - pin_file(builder["repository"], builder["repository_id"], name, summary["builder_commit"], pins) + verify_workflow_identity(builder) def unpack(data): @@ -365,10 +392,18 @@ def final_predicate(policy, source_run_id, source_attempt, metadata, summaries, finalizer = os.environ["GITHUB_WORKFLOW_REF"] require(finalizer == f"{policy['repository']}/.github/workflows/signed-release.yml@{os.environ['GITHUB_REF']}", "unexpected finalizer caller") repository_uri = f"https://github.com/{policy['repository']}" + trusted = policy["finalizer"] + verify_workflow_identity(trusted) + expected = f"{trusted['repository']}/{trusted['workflow']}@{trusted['refs'][0]}" + require(os.environ.get("SIGNER_JOB_WORKFLOW_REF") == expected, "untrusted finalizer job identity") + signer_commit = os.environ.get("SIGNER_JOB_WORKFLOW_SHA", "") + require(valid_hash(signer_commit, 40), "missing platform finalizer revision") + signer_uri = "https://github.com/" + expected + dependencies = [{"uri": f"git+https://github.com/{trusted['repository']}@{trusted['refs'][0]}", "digest": {"gitCommit": signer_commit}}, *dependencies] # GitHub's attestation API requires its workflow source as the first entry. dependencies = [{"uri": f"git+{repository_uri}@{os.environ['GITHUB_REF']}", "digest": {"gitCommit": os.environ["GITHUB_SHA"]}}, *dependencies] github = {"event_name": "workflow_dispatch", "repository_id": str(metadata["id"]), "repository_owner_id": str(metadata["owner"]["id"]), "runner_environment": "github-hosted"} - return {"buildDefinition": {"buildType": BUILD_TYPE, "externalParameters": {"workflow": {"repository": repository_uri, "ref": os.environ["GITHUB_REF"], "path": ".github/workflows/signed-release.yml"}, "inputs": inputs}, "internalParameters": {"github": github, "finalizerWorkflow": finalizer, "finalizerCommit": os.environ["GITHUB_SHA"], "signingTransactions": summaries}, "resolvedDependencies": dependencies}, "runDetails": {"builder": {"id": f"{repository_uri}/.github/workflows/release-attest.yml@{os.environ['GITHUB_REF']}"}, "metadata": {"invocationId": f"{repository_uri}/actions/runs/{os.environ['GITHUB_RUN_ID']}/attempts/{os.environ['GITHUB_RUN_ATTEMPT']}"}}} + return {"buildDefinition": {"buildType": BUILD_TYPE, "externalParameters": {"workflow": {"repository": repository_uri, "ref": os.environ["GITHUB_REF"], "path": ".github/workflows/signed-release.yml"}, "inputs": inputs}, "internalParameters": {"github": github, "finalizerWorkflow": signer_uri, "finalizerCommit": signer_commit, "signingTransactions": summaries}, "resolvedDependencies": dependencies}, "runDetails": {"builder": {"id": signer_uri}, "metadata": {"invocationId": f"{repository_uri}/actions/runs/{os.environ['GITHUB_RUN_ID']}/attempts/{os.environ['GITHUB_RUN_ATTEMPT']}"}}} def prepare(policy_path, source_run_id, source_attempt, out, tool_dir): @@ -398,6 +433,8 @@ def prepare(policy_path, source_run_id, source_attempt, out, tool_dir): verify_provenance(original, bundle, policy, source_run, receipt["provenance"][kind]) dependencies.append({"uri": f"urn:android-signer:unsigned:{profile}:{kind}", "digest": {"sha256": digest(data)}}) dependencies.append({"uri": f"urn:android-signer:unsigned-provenance:{profile}:{kind}", "digest": {"sha256": digest(files[bundle.name])}}) + summary = receipt["provenance"][kind] + dependencies.append({"uri": "git+" + summary["builder"], "digest": {"gitCommit": summary["builder_commit"]}}) preserve_payload(inputs["apk"], files["app-signed.apk"], {"META-INF/code_transparency_signed.jwt"}) preserve_payload(inputs["aab"], files["app-signed.aab"], {"BUNDLE-METADATA/com.android.tools.build.bundletool/code_transparency_signed.jwt", "META-INF/MANIFEST.MF", "META-INF/SIGNER.SF", "META-INF/SIGNER.RSA"}) android_verify(root, files, receipt, tool_dir) @@ -456,14 +493,14 @@ def publish_draft(out): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=("prepare", "draft")) - parser.add_argument("--policy", type=Path, default=Path(".github/signer-policy.json")) + parser.add_argument("--policy", type=Path) parser.add_argument("--source-run", type=int) parser.add_argument("--source-attempt", type=int) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--tools", type=Path) args = parser.parse_args() if args.command == "prepare": - require(args.source_run and args.source_attempt and args.tools, "source run/attempt/tools required") + require(args.policy and args.source_run and args.source_attempt and args.tools, "policy/source run/attempt/tools required") prepare(args.policy, args.source_run, args.source_attempt, args.out, args.tools.resolve()) else: publish_draft(args.out) diff --git a/android-signer/tests/test_publishing.py b/android-signer/tests/test_publishing.py index 4c2a79c..4e29899 100644 --- a/android-signer/tests/test_publishing.py +++ b/android-signer/tests/test_publishing.py @@ -18,6 +18,11 @@ SPEC.loader.exec_module(p) +def identity(workflow="android-attest.yml"): + return {"repository": "connectbot/actions", "repository_id": 1369420724, + "workflow": ".github/workflows/" + workflow, "refs": ["refs/heads/android-signer-v1"]} + + class HandoffTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -25,12 +30,17 @@ def setUp(self): self.root = Path(self.tmp.name) cert = b"public test certificate" keys = {k: "a" * 64 for k in ("original", "classical", "upload", "transparency")} - self.policy = {"version": 2, "repository": "example/app", "repository_id": 42, "visibility": "public", "revision": 1, + self.policy = {"version": 3, "repository": "example/app", "repository_id": 42, "visibility": "public", "revision": 1, "policy_hash": "b" * 64, "signer_revision": "c" * 40, "refs": ["refs/heads/main", "refs/tags/v*"], + "callers": [".github/workflows/ci.yml"], "builder": identity(), "finalizer": identity("android-finalize.yml"), "receipt_keys": {p.digest(cert): base64.b64encode(cert).decode()}, "profiles": {name: {"keys": {**keys, "hybrid": None}, "lineage_sha256": "d" * 64, "rotation_min_sdk": 33} for name in ("oss", "google")}} self.policy_path = self.root / "policy.json" self.policy_path.write_bytes(p.json_bytes(self.policy)) + self.policy_path.with_suffix(".source.json").write_bytes(p.json_bytes({ + "repository": p.POLICY_REPOSITORY, "repository_id": p.POLICY_REPOSITORY_ID, + "commit": "1" * 40, "path": ".github/android-signer/42.json", + "sha256": p.digest(self.policy_path.read_bytes())})) self.handoff = self.root / "handoff" self.handoff.mkdir() self.event = self.root / "event.json" @@ -45,8 +55,11 @@ def setUp(self): self.predicate = {"buildDefinition": {"buildType": p.r.BUILD_TYPE, "externalParameters": {"workflow": { "repository": "https://github.com/example/app", "ref": "refs/heads/main", "path": ".github/workflows/signed-release.yml"}, "inputs": {"source_run": "10", "source_attempt": "2"}}}} + signer = "https://github.com/connectbot/actions/.github/workflows/android-finalize.yml@refs/heads/android-signer-v1" + self.predicate["runDetails"] = {"builder": {"id": signer}} + self.predicate["buildDefinition"]["internalParameters"] = {"finalizerCommit": "1" * 40} self.result = {"verificationResult": {"statement": {"predicateType": p.r.SLSA, "predicate": self.predicate, "subject": []}, - "signature": {"certificate": {"runInvocationURI": "https://github.com/example/app/actions/runs/99/attempts/1", "sourceRepositoryIdentifier": "42"}}}} + "signature": {"certificate": {"runInvocationURI": "https://github.com/example/app/actions/runs/99/attempts/1", "sourceRepositoryIdentifier": "42", "buildSignerURI": signer, "buildSignerDigest": "1" * 40}}}} self.populate() def populate(self): @@ -76,10 +89,11 @@ def refresh(self): def verify(self, env=None): with mock.patch.dict(os.environ, self.env if env is None else env, clear=True), \ mock.patch.object(p.r, "api", return_value={"id": 42, "visibility": "public", "default_branch": "main"}), \ + mock.patch.object(p.r, "verify_workflow_identity"), \ mock.patch.object(p.r, "run", return_value=p.json_bytes([self.result])) as run: result = p.verify_handoff(self.handoff, self.sha, self.policy_path) args = run.call_args.args - self.assertIn("--signer-digest", args) + self.assertNotIn("--signer-digest", args) self.assertIn("--source-digest", args) self.assertIn("--deny-self-hosted-runners", args) return result @@ -288,7 +302,8 @@ def test_published_evidence_is_retained_on_full_finalizer_rerun(self): (self.handoff / "predicate.json").write_bytes(changed) self.manifest["assets"]["predicate.json"] = {"sha256": p.digest(changed), "bytes": len(changed)} (self.handoff / "release-manifest.json").write_bytes(p.json_bytes(self.manifest)) - with mock.patch.object(p.r, "download_asset", side_effect=lambda repo, asset: files[asset["name"]]), \ + with mock.patch.object(p.r, "verify_workflow_identity"), \ + mock.patch.object(p.r, "download_asset", side_effect=lambda repo, asset: files[asset["name"]]), \ mock.patch.object(p.r, "run", return_value=p.json_bytes([self.result])): p.verify_existing_publication(self.handoff, self.manifest, existing) self.manifest["assets"]["version_code.txt"]["sha256"] = "0" * 64 diff --git a/android-signer/tests/test_release_workflow.py b/android-signer/tests/test_release_workflow.py index eb926be..4c5dfa8 100644 --- a/android-signer/tests/test_release_workflow.py +++ b/android-signer/tests/test_release_workflow.py @@ -10,6 +10,8 @@ import tarfile import unittest from unittest import mock + +from test_publishing import identity import zipfile ROOT = Path(__file__).resolve().parents[1] @@ -25,13 +27,15 @@ def test_final_predicate_binds_control_plane_inputs_and_workflow(self): inputs = {"source_run": "42", "source_attempt": "2"} event.write_text(release.json.dumps({"inputs": inputs})) environment = {"GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REF": "refs/heads/main", "GITHUB_EVENT_PATH": str(event), "RUNNER_ENVIRONMENT": "github-hosted", "GITHUB_WORKFLOW_REF": f"{policy['repository']}/.github/workflows/signed-release.yml@refs/heads/main", "GITHUB_SHA": "c" * 40, "GITHUB_RUN_ID": "99", "GITHUB_RUN_ATTEMPT": "1"} - with mock.patch.dict(release.os.environ, environment, clear=True): + environment.update(SIGNER_JOB_WORKFLOW_REF="connectbot/actions/.github/workflows/android-finalize.yml@refs/heads/android-signer-v1", SIGNER_JOB_WORKFLOW_SHA="d" * 40) + with mock.patch.dict(release.os.environ, environment, clear=True), mock.patch.object(release, "verify_workflow_identity"): dependency = {"uri": "urn:test:unsigned", "digest": {"sha256": "d" * 64}} predicate = release.final_predicate(policy, 42, 2, {"id": policy["repository_id"], "owner": {"id": 7}, "default_branch": "main"}, {}, [dependency]) self.assertEqual(predicate["buildDefinition"]["externalParameters"]["inputs"], inputs) self.assertEqual(predicate["buildDefinition"]["externalParameters"]["workflow"]["path"], ".github/workflows/signed-release.yml") self.assertEqual(predicate["buildDefinition"]["resolvedDependencies"][0]["digest"], {"gitCommit": "c" * 40}) - self.assertEqual(predicate["buildDefinition"]["resolvedDependencies"][1], dependency) + self.assertEqual(predicate["buildDefinition"]["resolvedDependencies"][1]["digest"], {"gitCommit": "d" * 40}) + self.assertEqual(predicate["buildDefinition"]["resolvedDependencies"][2], dependency) event.write_text(release.json.dumps({"inputs": {**inputs, "source_run": "43"}})) with self.assertRaises(ValueError): release.final_predicate(policy, 42, 2, {"id": policy["repository_id"], "owner": {"id": 7}, "default_branch": "main"}, {}, []) @@ -89,7 +93,7 @@ def tearDownClass(cls): cls.tmp.cleanup() def policy(self): - return {"version": 2, "visibility": "public", "repository": "example/app", "repository_id": 42, + return {"version": 3, "callers": [".github/workflows/ci.yml"], "builder": identity(), "finalizer": identity("android-finalize.yml"), "visibility": "public", "repository": "example/app", "repository_id": 42, "policy_hash": "a" * 64, "signer_revision": "b" * 40, "revision": 3, "receipt_keys": {self.fingerprint: base64.b64encode(self.der).decode()}, "profiles": {"oss": {"package": "org.example", "keys": {"original": "c" * 64, "classical": "d" * 64, "upload": "e" * 64, "transparency": "f" * 64, "hybrid": None}, "lineage_sha256": "1" * 64, "rotation_min_sdk": 33, "hybrid_min_sdk": None}}} diff --git a/android-signer/tests/test_trusted_policy.py b/android-signer/tests/test_trusted_policy.py new file mode 100644 index 0000000..d536735 --- /dev/null +++ b/android-signer/tests/test_trusted_policy.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright 2026 Kenny Root +import base64 +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import test_publishing as fixtures +p = fixtures.p + + +identity = fixtures.identity + +class PolicyTests(unittest.TestCase): + setUp = fixtures.HandoffTests.setUp + populate = fixtures.HandoffTests.populate + refresh = fixtures.HandoffTests.refresh + def responses(self, policy=None): + data = p.json_bytes(policy or self.policy) + return [{"id": p.POLICY_REPOSITORY_ID}, {"protected": True, "commit": {"sha": "a" * 40}}, + {"path": ".github/android-signer/42.json", "type": "file", "encoding": "base64", "content": base64.b64encode(data).decode()}] + + def fetch(self, responses): + out = self.root / "loaded-policy.json" + with mock.patch.dict(os.environ, {**self.env, "GITHUB_REPOSITORY_ID": "42"}, clear=True), \ + mock.patch.object(p, "policy_api", side_effect=responses) as api: + p.fetch_policy(out) + return out, api + + def test_central_policy_uses_platform_repository_id_and_immutable_commit(self): + out, api = self.fetch(self.responses()) + self.assertEqual(p.load(out.read_bytes()), self.policy) + self.assertEqual(api.call_args.args, ("/contents/.github/android-signer/42.json?ref=" + "a" * 40,)) + source = p.load(out.with_suffix(".source.json").read_bytes()) + self.assertEqual(source["commit"], "a" * 40) + self.assertEqual(source["sha256"], p.digest(out.read_bytes())) + + def test_changed_policy_repository_or_unprotected_branch_is_rejected(self): + for index, replacement in [(0, {"id": 99}), (1, {"protected": False, "commit": {"sha": "a" * 40}})]: + responses = self.responses() + responses[index] = replacement + with self.assertRaises(ValueError): + self.fetch(responses) + self.assertFalse((self.root / "loaded-policy.json").exists()) + + def test_another_source_repository_policy_is_rejected(self): + policy = {**self.policy, "repository_id": 43} + with self.assertRaisesRegex(ValueError, "another repository"): + self.fetch(self.responses(policy)) + + def test_trusted_builder_identity_requires_repository_id_and_branch_protection(self): + for responses in ([{"id": 99, "visibility": "public"}], + [{"id": 1369420724, "visibility": "public"}, {"protected": False}]): + with mock.patch.object(p.r, "api", side_effect=responses), self.assertRaises(ValueError): + p.r.verify_workflow_identity(identity()) + + def test_removed_authorization_modes_and_missing_finalizer_are_rejected(self): + for field, value in (("authorization", "content-pins"), ("authorization", "workflow-identity"), + ("sha256", []), ("helpers", {})): + policy = {**self.policy, "builder": {**self.policy["builder"], field: value}} + with self.assertRaisesRegex(ValueError, "unsupported workflow policy"): + p.r.validate_policy(policy) + old = {**self.policy, "version": 2} + with self.assertRaisesRegex(ValueError, "version 3"): + p.r.validate_policy(old) + missing = {k: v for k, v in self.policy.items() if k != "finalizer"} + with self.assertRaisesRegex(ValueError, "required policy identities"): + p.r.validate_policy(missing) + with self.assertRaises(ValueError): + p.r.validate_policy({**self.policy, "callers": {".github/workflows/ci.yml": []}}) + + def test_shared_finalizer_certificate_and_predicate_must_agree(self): + trusted = identity("android-finalize.yml") + self.policy["finalizer"] = trusted + self.policy_path.write_bytes(p.json_bytes(self.policy)) + signer = "https://github.com/connectbot/actions/.github/workflows/android-finalize.yml@refs/heads/android-signer-v1" + certificate = self.result["verificationResult"]["signature"]["certificate"] + certificate.update(buildSignerURI=signer, buildSignerDigest="1" * 40) + self.predicate.update(runDetails={"builder": {"id": signer}}) + self.predicate["buildDefinition"]["internalParameters"] = {"finalizerCommit": "1" * 40} + self.populate() + with mock.patch.dict(os.environ, self.env, clear=True), \ + mock.patch.object(p.r, "api", return_value={"id": 42, "visibility": "public", "default_branch": "main"}), \ + mock.patch.object(p.r, "verify_workflow_identity"), \ + mock.patch.object(p.r, "run", return_value=p.json_bytes([self.result])) as run: + self.assertEqual(p.verify_handoff(self.handoff, self.sha, self.policy_path), self.manifest) + self.assertNotIn("--signer-digest", run.call_args.args) + certificate["buildSignerURI"] = signer.replace("android-finalize.yml", "attacker.yml") + run.return_value = p.json_bytes([self.result]) + with self.assertRaisesRegex(ValueError, "builder differs"): + p.verify_handoff(self.handoff, self.sha, self.policy_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/android-signer/tests/test_workflows.py b/android-signer/tests/test_workflows.py new file mode 100644 index 0000000..1b08d4a --- /dev/null +++ b/android-signer/tests/test_workflows.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Copyright 2026 Kenny Root +from pathlib import Path +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class WorkflowTests(unittest.TestCase): + def test_publishers_verify_before_google_authentication_and_only_target_alpha(self): + workflow = (ROOT / ".github/workflows/android-publish.yml").read_text() + self.assertLess(workflow.index("command: verify"), workflow.index("google-github-actions/auth@")) + self.assertIn("project_id: api-4664730880327298161-873945", workflow) + self.assertIn("github.repository_id == '123235'", workflow) + self.assertIn("environment: play-alpha", workflow) + self.assertNotIn("credentials_json", workflow) + self.assertNotIn("attestations: write", workflow) + self.assertNotIn("id-token: write", workflow.split(" play:")[0]) + self.assertNotIn("actions/checkout@", workflow) + + def attestor(self): + return (ROOT / ".github/workflows/android-attest.yml").read_text() + + def python_step(self, name): + step = self.attestor().split(" - name: " + name + "\n", 1)[1] + code = step.split("<<'PYTHON'\n", 1)[1].split(" PYTHON\n", 1)[0] + return textwrap.dedent(code) + + def test_attestor_only_accepts_profile_and_uses_platform_run_identity(self): + workflow = self.attestor() + interface, job = workflow.split("jobs:\n", 1) + self.assertEqual(interface.count(" type: string"), 1) + self.assertIn(" profile:", interface) + for forbidden in ("actions/checkout@", "gradlew", "setup-java@", "setup-android@", + "setup-gradle@", "secrets:", "run-id:", "repository:", + "artifact-ids:", "pattern:", "merge-multiple:", "continue-on-error:"): + self.assertNotIn(forbidden, workflow) + self.assertIn("runs-on: ubuntu-latest", job) + self.assertIn("github.event_name == 'push'", job) + for artifact in ("unsigned", "release-support", "provenance"): + self.assertIn("name: " + artifact + "-${{ inputs.profile }}-${{ github.run_attempt }}", job) + self.assertIn("support/build-info.json", job) + self.assertLess(job.index("Validate artifact handoff"), job.index("uses: actions/attest@")) + self.assertFalse((ROOT / ".github/workflows/android-build.yml").exists()) + + def test_profile_rejects_paths_globs_and_shell_expressions(self): + code = self.python_step("Validate artifact profile") + for profile in ("oss", "google", "another-profile", "../oss", "*", "oss/extra", + "$(touch injected)", "oss\nextra", "", "a" * 33): + with self.subTest(profile=profile): + result = subprocess.run([sys.executable, "-c", code], + env={**os.environ, "PROFILE": profile}, capture_output=True) + self.assertEqual(result.returncode == 0, profile in ("oss", "google", "another-profile")) + + def test_handoff_rejects_missing_extra_and_symlinked_files(self): + code = self.python_step("Validate artifact handoff") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for directory, names in (("unsigned", ("app.apk", "app.aab")), + ("support", ("mapping.txt", "native-debug-symbols.zip", "build-info.json"))): + (root / directory).mkdir() + for name in names: + (root / directory / name).write_bytes(b"untrusted build output") + def verify(): + return subprocess.run([sys.executable, "-c", code], cwd=root, capture_output=True).returncode + self.assertEqual(verify(), 0) + artifact = root / "unsigned/app.apk" + artifact.unlink() + self.assertNotEqual(verify(), 0) + artifact.write_bytes(b"apk") + extra = root / "support/execute.py" + extra.write_text("raise SystemExit('must never run')") + self.assertNotEqual(verify(), 0) + extra.unlink() + artifact.unlink() + artifact.symlink_to(root / "unsigned/app.aab") + self.assertNotEqual(verify(), 0) + + +if __name__ == "__main__": + unittest.main()