Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions .github/workflows/signing-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: Signing E2E

# End-to-end validation of the `synth compile --sign-output` Phase 5 path
# against a real `wsc` binary from pulseengine/sigil. This workflow closes the
# "not validated end-to-end" gap explicitly flagged when Phase 5 shipped in
# PR #135 — synth-cli's unit test only pins the argv shape, not the actual
# sign + verify round-trip. The test script lives at `tests/wsc_sign_e2e.sh`.
#
# Trust model: the wsc binary is downloaded from a pinned sigil release,
# its sha256 is verified against a value pinned in this workflow file. The
# sha256 reference value was taken from the sigil v0.9.0 release's
# `wsc-linux-x86_64.sha256` sidecar at publish time. If sigil rotates the
# binary without bumping the version, this workflow fails loudly — that is
# the rigor pattern: pin the contract.
#
# Why release-download and not Bazel:
# - sigil ships ready-to-run binaries with per-asset sha256 sidecars and
# SLSA build provenance. Pinning by version + sha256 is sufficient to
# guarantee a known binary.
# - rules_wasm_component (already in MODULE.bazel) is a WASM-component
# toolchain ruleset, not a `wsc` CLI distribution. Wiring wsc through
# Bazel would require a custom binary-import rule for marginal hermetic
# benefit over a sha256-pinned curl.
# - Sigil's own CI builds wsc from source (cargo build), which is even
# slower than downloading a release binary and offers nothing we can't
# reproduce locally with `cargo install --git ...`.
#
# Untrusted-input safety: this workflow does NOT read PR titles, comment
# bodies, head_ref, or any other user-controlled field into `run:` blocks.
# All values interpolated into shell commands come from workflow `env:`
# keys (pinned in this file) or from `secrets`/`runner.temp` (controlled
# by GitHub). Bound via env: and dereferenced as $VAR per the standard
# workflow-injection mitigation.

on:
push:
tags:
- "v*"
branches: [main]
paths:
# PR triggers below already cover crates/synth-cli/src/sign.rs and the
# release workflow; on push-to-main we run on the same set so a
# branch-protection bypass doesn't skip the e2e.
- "crates/synth-cli/src/sign.rs"
- "crates/synth-cli/src/main.rs"
- "tests/wsc_sign_e2e.sh"
- "tests/integration/add.wat"
- ".github/workflows/signing-e2e.yml"
pull_request:
branches: [main]
paths:
# Run on PRs that touch the synth-side signing contract or the test
# itself. Catches future breaks where someone changes the argv shape
# in sign.rs without updating the wsc pin.
- "crates/synth-cli/src/sign.rs"
- "crates/synth-cli/src/main.rs"
- "tests/wsc_sign_e2e.sh"
- "tests/integration/add.wat"
- ".github/workflows/signing-e2e.yml"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
# Pinned wsc release. Bumped manually in lockstep with the wsc CLI
# contract assumption documented in crates/synth-cli/src/sign.rs. If
# sigil ships a new wsc with a different CLI shape (e.g. dropping
# --keyless in favor of --mode keyless), bumping this here without
# updating sign.rs is intended to make the test fail loudly.
WSC_VERSION: "v0.9.0"
# sha256 of the Linux x86_64 wsc binary at $WSC_VERSION. Source:
# https://github.com/pulseengine/sigil/releases/download/v0.9.0/wsc-linux-x86_64.sha256
WSC_SHA256_LINUX_X86_64: "9054b4b066e2b0a954110851a43266ff0e9ef12b4e1ecc03c333943fd52cecb6"

jobs:
signing-e2e:
name: synth compile --sign-output e2e
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
# Required for `wsc sign --keyless` to obtain an OIDC token from
# GitHub Actions; Fulcio short-lived certs are bound to that token's
# workflow identity. Without `id-token: write` the keyless flow
# cannot produce a Fulcio certificate at all.
contents: read
id-token: write
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable

- name: Cache Cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target/
key: ${{ runner.os }}-cargo-sign-e2e-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-sign-e2e-

- name: Build synth-cli (release)
run: cargo build --release -p synth-cli

- name: Install wabt for wat2wasm
# wat2wasm is used by tests/wsc_sign_e2e.sh case 1 to turn the WAT
# fixture into a real WASM module for the keyless sign+verify
# round-trip. wabt is a small package in the standard Ubuntu repos.
run: sudo apt-get update && sudo apt-get install -y wabt

- name: Download pinned wsc binary
env:
# Bound through env: per the workflow-injection-mitigation pattern.
# Values come from workflow-level env (pinned in this file), not
# from any user-controlled field.
WSC_TAG: ${{ env.WSC_VERSION }}
WSC_SHA: ${{ env.WSC_SHA256_LINUX_X86_64 }}
run: |
set -euo pipefail
ASSET="wsc-linux-x86_64"
URL="https://github.com/pulseengine/sigil/releases/download/${WSC_TAG}/${ASSET}"
echo "::notice::Downloading wsc from $URL"
curl --fail --location --silent --show-error \
-o "$RUNNER_TEMP/wsc" "$URL"

# Verify sha256 against the pinned digest. Use `sha256sum -c` with
# the pinned value rather than parsing the upstream .sha256 sidecar
# (which would also need to be downloaded and trusted, leaving a
# TOCTOU window). This makes the workflow file itself the trust
# anchor — auditable in one place.
echo "${WSC_SHA} $RUNNER_TEMP/wsc" | sha256sum --check --strict
echo "::notice::wsc sha256 matches pinned value"

chmod +x "$RUNNER_TEMP/wsc"
# Make wsc discoverable on PATH for synth-cli's `Command::new("wsc")`.
mkdir -p "$RUNNER_TEMP/wsc-bin"
mv "$RUNNER_TEMP/wsc" "$RUNNER_TEMP/wsc-bin/wsc"
echo "$RUNNER_TEMP/wsc-bin" >> "$GITHUB_PATH"

- name: Show wsc version
run: |
set -euo pipefail
which wsc
wsc --version

- name: Run end-to-end signing test
env:
SYNTH: ${{ github.workspace }}/target/release/synth
# WSC is set from PATH inside the script (via `command -v wsc`), but
# the script also accepts $WSC as an env override; pass it for
# belt-and-suspenders.
WSC: ${{ runner.temp }}/wsc-bin/wsc
run: |
set -euo pipefail
chmod +x ./tests/wsc_sign_e2e.sh
./tests/wsc_sign_e2e.sh

- name: Upload test artefacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: wsc-sign-e2e-artefacts
path: |
/tmp/wsc-sign-e2e.*/**
retention-days: 7
if-no-files-found: ignore
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Internal
- CI: `signing-e2e.yml` workflow added (Phase 5 end-to-end). Runs three
wsc keyless cases against a sha256-pinned wsc v0.9.0. Case 3
(tamper-negative) is `xfail` against current wsc — see
[pulseengine/sigil#135](https://github.com/pulseengine/sigil/issues/135):
`wsc verify --keyless` accepts WASM modules whose signed payload has
been byte-modified after signing. Documented in
`docs/sigil-integration.md`.

## [0.6.0] - 2026-05-24

Supply-chain close-out — synth produces all six evidence layers from
Expand Down
24 changes: 19 additions & 5 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ pattern is the long-term plan (short-lived tokens, no stored secret),
but requires per-crate trusted-publisher registration on crates.io
(11 forms). Tracked as a follow-up; not blocking v0.6.x / v0.7.x.

## Phase 5 — signing synth's *output* ELF binaries ✅ compiler-side implemented
## Phase 5 — signing synth's *output* ELF binaries ✅ compiler-side implemented + CI-validated end-to-end

- **Delivers:** `synth compile --sign-output` invokes sigil's `wsc sign
--keyless --format elf` after writing the ELF, attaching a Sigstore
Expand All @@ -285,12 +285,26 @@ but requires per-crate trusted-publisher registration on crates.io
trust model, verification command, and the wsc-contract assumption.
- **Status:** compiler-side wired (`crates/synth-cli/src/sign.rs`); the
signing subprocess shape, missing-wsc error path, and `--all-exports`
interaction are unit-tested. End-to-end signing + verification against a
live `wsc` binary was deferred to the maintainer (the implementing agent
did not have `wsc` on PATH).
interaction are unit-tested. End-to-end against a real `wsc` binary is
CI-validated via `.github/workflows/signing-e2e.yml` +
`tests/wsc_sign_e2e.sh`:
- The workflow downloads a sha256-pinned `wsc` (currently
`pulseengine/sigil` `v0.9.0`) and runs the e2e script on every PR
that touches `crates/synth-cli/src/sign.rs`, `main.rs`, or the test
itself, plus on every `v*` tag push.
- Three cases are checked: (a) WASM keyless sign+verify round-trip,
(b) synth's actual `--sign-output` invocation against ELF, and
(c) a tamper-negative on the case-(a) signed WASM. Case (b)
currently pins the **known sigil-v0.9.0 ELF-keyless gap** (sigil
rejects keyless ELF with an explicit usage error); the test
asserts the gap and emits a GitHub `::notice::` annotation if
sigil ever lifts it, alerting us to promote the test to a real
"signed ELF" assertion.
- **Pipeline integration is out of scope here** — wiring `--sign-output`
into `release.yml` (and uploading signed firmware as a release asset) is
the natural follow-up alongside Phase 6's `--sbom` plumbing.
blocked on sigil shipping keyless-ELF support. Once the e2e job starts
producing a real signed ELF (case (b) flips to positive), the release
workflow can add a signing step alongside Phase 6's SBOM emission.

## Phase 6 — CycloneDX SBOM auto-emit ✅ implemented

Expand Down
115 changes: 108 additions & 7 deletions docs/sigil-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,71 @@ exits non-zero with the wsc stderr captured in the error message.

### Verification (consumer side)

> **Status note (sigil v0.9.0, May 2026).** `wsc sign --keyless --format
> elf` is currently rejected by sigil with the literal error string
> *"Keyless signing is currently supported only for WASM format. Use
> key-based signing for ELF and MCUboot artifacts."* See
> [`src/cli/main.rs`](https://github.com/pulseengine/sigil/blob/v0.9.0/src/cli/main.rs#L770-L779).
> Until sigil ships keyless ELF support, `synth compile --sign-output`
> exits non-zero with the wsc rejection surfaced verbatim. The CI test
> (`tests/wsc_sign_e2e.sh`, workflow `signing-e2e.yml`) asserts exactly
> that current behaviour AND auto-detects when the situation flips,
> alerting maintainers via a `::notice::` GitHub annotation. The
> command below documents the *intended* verification UX once sigil
> ships ELF keyless; today the round-trip is only achievable for WASM
> modules (see [Verifying a signed WASM module locally](#verifying-a-signed-wasm-module-locally)
> below).

```bash
wsc verify --keyless --format elf \
--cert-identity "https://github.com/pulseengine/synth/.github/workflows/release.yml@refs/tags/v0.6.0" \
--cert-oidc-issuer "https://token.actions.githubusercontent.com" \
-i firmware.elf
```

The exact `--cert-identity` regex depends on how the firmware was signed
The exact `--cert-identity` value depends on how the firmware was signed
(local developer signs with their own GitHub identity; CI signs with the
workflow identity). Producers should publish the expected identity alongside
the firmware. See [sigil's `docs/keyless.md`](https://github.com/pulseengine/sigil/blob/main/docs/keyless.md)
the firmware. Sigil's `wsc verify --keyless` accepts the literal
`--cert-identity` (string match) and `--cert-oidc-issuer` flags; there is
**no** `--cert-identity-regexp` flag in sigil v0.9.0 (see sigil's
[`src/cli/main.rs` lines 247-258](https://github.com/pulseengine/sigil/blob/v0.9.0/src/cli/main.rs#L247-L258)).
See [sigil's `docs/keyless.md`](https://github.com/pulseengine/sigil/blob/main/docs/keyless.md)
for the full verification surface.

### Verifying a signed WASM module locally

This is the path that actually works today. It is what `tests/wsc_sign_e2e.sh`
case 1 exercises in CI on every PR that touches the signing contract.

```bash
# 1. Build a WASM module (synth's WAT fixtures work, or any wasmparser-valid
# module). For a developer-identity signature, install wsc from sigil:
cargo install --git https://github.com/pulseengine/sigil --bin wsc
wat2wasm tests/integration/add.wat -o /tmp/add.wasm

# 2. Sign with your developer GitHub identity (wsc opens an OIDC browser flow
# for keyless signing outside CI).
wsc sign --keyless --format wasm -i /tmp/add.wasm -o /tmp/add.signed.wasm

# 3. Verify. With --cert-identity bound to your GitHub identity:
wsc verify --keyless --format wasm \
--cert-identity 'you@example.com' \
--cert-oidc-issuer 'https://github.com/login/oauth' \
-i /tmp/add.signed.wasm

# Or, in a GitHub Actions workflow with id-token: write, the certificate
# identity is the workflow ref:
wsc verify --keyless --format wasm \
--cert-identity 'https://github.com/pulseengine/synth/.github/workflows/signing-e2e.yml@refs/heads/main' \
--cert-oidc-issuer 'https://token.actions.githubusercontent.com' \
-i /tmp/add.signed.wasm
```

Omitting `--cert-identity` makes verify accept any identity that's anchored
in Rekor — useful for a quick health check but **insufficient for trust**
in production. Always pin the expected identity in any consumer-side script.

## Interaction with `--sbom`

When both flags are supplied:
Expand Down Expand Up @@ -170,16 +222,65 @@ long-lived signing key needs to be managed.

## Status

Implemented in `feat/sigil-elf-signing`. Validated:
Implemented in `feat/sigil-elf-signing` (PR #135). End-to-end validation
landed in `feat/wsc-ci-end-to-end` via the `signing-e2e.yml` workflow.

Validated:

- Unit tests pin the `wsc` argv shape and the missing-wsc error path
(`crates/synth-cli/src/sign.rs::tests`).
- Manual smoke test of `synth compile --demo add --sign-output` (without
wsc installed) confirms exit code 1 with the actionable error.
- End-to-end signing + verification **was not validated by the implementing
agent** — `wsc` was not available in that environment. A maintainer with
`wsc` installed should run the verification command above against an ELF
signed locally to confirm the wsc contract assumption.
- **End-to-end CI validation**: `.github/workflows/signing-e2e.yml`
downloads a sha256-pinned `wsc` from sigil's releases (currently
`v0.9.0`) and runs `tests/wsc_sign_e2e.sh`, which exercises three
load-bearing cases:
1. **WASM keyless sign + verify round-trip** — proves wsc is healthy,
Fulcio + Rekor are reachable, the OIDC token works, and the keyless
code path that sigil currently supports does sign and re-verify a
module end-to-end.
2. **Synth's actual ELF contract** — runs `synth compile --sign-output`
and asserts the current sigil-v0.9.0 behaviour: wsc rejects keyless
ELF with *"Keyless signing is currently supported only for WASM
format"*; synth surfaces that error verbatim; the unsigned ELF
stays untouched on disk; no stale `.signing.tmp` is left behind.
The test also emits a GitHub `::notice::` annotation when sigil
eventually lifts the ELF restriction, alerting maintainers to
update the test (and ship a proper signed ELF in the release
workflow).
3. **Tamper-negative** (currently xfail — sigil#135) — flips a byte
in the case-1 signed WASM and asserts `wsc verify --keyless`
rejects it. **The CI run on commit 7f1a9c9 surfaced that
wsc v0.9.0 accepts the tampered file (exit 0)** — verification
is not detecting modifications inside the signed payload (offset
64 in a 15 218-byte signed module, well before the trailing
signature section). Filed upstream as
[pulseengine/sigil#135](https://github.com/pulseengine/sigil/issues/135).
The test is intentionally `xfail` against current wsc and will
auto-`xpass`-notify when sigil#135 ships a fix; the assertion
should then flip back to a hard rejection check. Until then,
`wsc verify --keyless` should be treated as proving signature-blob
well-formedness, **not** module integrity.

### Known contract gap (sigil v0.9.0)

Keyless ELF signing **does not work end-to-end** today, by sigil's design:
`wsc sign --keyless --format elf` returns an explicit usage error
(sigil's [`src/cli/main.rs:770-779`](https://github.com/pulseengine/sigil/blob/v0.9.0/src/cli/main.rs#L770-L779)).
synth-cli's `sign_elf` invokes exactly that argv shape, so `synth compile
--sign-output` currently always fails when a real `wsc` is on PATH.

This is a planned, tracked situation, not a bug in synth: synth-cli's
implementation is forward-compatible with the keyless-ELF support sigil
intends to add (the argv shape matches sigil's existing `--format elf` +
`--keyless` flag wiring). When sigil ships that, the `signing-e2e.yml`
job will start producing a real signed ELF, the test's case 2 will flip
into a positive assertion, and the release workflow can begin attaching
signed firmware to GitHub Releases.

A maintainer who wants to dogfood the synth-side path today can use the
[WASM verification recipe above](#verifying-a-signed-wasm-module-locally),
which exercises the same Fulcio/Rekor surface synth-cli relies on.

## Out of scope

Expand Down
Loading
Loading