RFD 0009: Plugin signing via Sigstore enforcement (v2.1)¶
Summary¶
v2.0 shipped with verify.StubVerifier as the default Verifier: the policy fields under [security] (identity_patterns, allow_unsigned_for, --allow-unsigned) were enforced, but no cryptographic verification ran. v2.1 swaps in a SigstoreVerifier backed by github.com/sigstore/sigstore-go and flips verify.IsProduction() to true. The user-facing surface is unchanged: same flags, same policy schema, same lockfile format. The new code adds:
internal/plugin/verify/sigstore.go— production verifier- TUF trust-root bootstrap (lazy fetch + 24h on-disk cache)
- Real OIDC identity-pattern matching via sigstore-go's
verify.NewShortCertificateIdentity - Real Rekor transparency-log presence checks
- Honest CLI output that surfaces the signing identity on success and the Rekor log entry URL on failure
The math swap is the entire point. v2.0 doctor said:
⚠ verifier is stubbed in v2.0 — policy is enforced but signatures are not cryptographically validated.
v2.1 doctor says:
⚠ signature verifier: sigstore-go (production)
Motivation¶
v2.0's release notes called out the stub-disclosure pattern as "honest at the doctor level but misleading on individual commands." A user reading samuel install foo saw signature: verified (...) and reasonably believed cryptographic verification had run. It had not. Bridging the trust gap is small in surface area (~600 lines of production code) and high in trust value: we cross the line from claiming signature verification to performing it.
The rc-cycle synthesis (synthesis/v2-rc-cycle-lessons.md) called this out as the load-bearing follow-up to the v2.0 release: the schema and the policy surface have to be stable across the transition so plugins signed against the test registry on v2.0 keep verifying on v2.1 without re-signing. That stability constraint shaped this design.
Design¶
Verifier interface (unchanged)¶
The Verifier interface — VerifyBlob(ctx, path, req) and VerifyImage(ctx, digest, req) — is the same in v2.1 as v2.0. The existing call sites in internal/plugin/service/service.go make no distinction between stub and production; they just call through the interface and act on the returned Result. This is the load-bearing contract that lets both implementations coexist for tests (matrix-run via t.Run("stub", ...) / t.Run("sigstore", ...) in verify_test.go).
Trust root via sigstore TUF¶
The sigstore project publishes a TUF repository at https://tuf-repo-cdn.sigstore.dev that distributes the trusted_root.json containing the public keys for Fulcio (the CA) and Rekor (the transparency log). Bootstrapping trust from this repository means:
- We do not embed long-lived public keys in the samuel binary.
- Sigstore's own rotation cadence (~24h) is the source of truth.
- A binary upgrade triggers a fresh fetch (the cache key embeds the samuel version).
The verifier lazily fetches the trusted root on first verify call. The TUF client (sigstore-go/pkg/tuf) handles the metadata verification and target fetch; we only own the on-disk cache.
~/.samuel/cache/sigstore/trust-root/
└── <samuel-version>/
└── trusted_root.json (24h TTL; mtime is the freshness key)
Three retries with exponential backoff (200ms → 400ms → 800ms) cover transient network failures. After three failures the verifier returns a structured error pointing at the docs URL. SAMUEL_VERIFY_STUB=1 is the supported escape hatch for persistent failures (corporate proxies, air-gapped CI), and SAMUEL_TUF_MIRROR is reserved as a future-PRD hook (not implemented this release).
Identity-pattern → certificate-identity translation¶
samuel's identity_patterns is a glob list (e.g. https://github.com/samuelpkg/**). sigstore-go expects each identity as a CertificateIdentity carrying an OIDC issuer plus a SAN regex. The translation:
glob: https://github.com/samuelpkg/**
↓
SAN regex: ^https://github\.com/samuelpkg/.*$
issuer: https://token.actions.githubusercontent.com
* matches one path segment; ** matches any sequence (including / and @). Real GitHub Actions OIDC SANs are <org>/<repo>/.github/workflows/<file>@refs/tags/<ver> — many segments — so the default policy uses **. PRD 0009 v2.2 release flipped the defaults after live-tier verification revealed * only matched the bare repo URL.
** expands to .* (multi-segment); a trailing * expands to [^/]* (single segment). Exact strings are anchored. The full mapping is in globToRegex with tests in sigstore_test.go::TestGlobToRegex.
The issuer is hard-coded to the GitHub Actions OIDC issuer for v2.1. Alternative issuers (GitLab CI, self-hosted Fulcio, etc.) are out of scope; a richer identity_patterns_v2 schema is a future-PRD problem.
Result-cache key (unchanged)¶
The existing Cache wrapper in verify.go keys decisions on (blob_digest, AllowUnsigned). The sigstore-go integration changes nothing about that contract; toggling --allow-unsigned still re-runs the inner verifier (as fixed by issue #2 during the v2.0 cycle).
Honest CLI output¶
Two visible changes to samuel install:
- Success carries the actual signing identity from the Result:
✓ Installed samuel-test-skill-signed@1.0.0 (skill) (signed by https://github.com/.../release.yml@refs/tags/v1.0.0)
signature: verified (https://github.com/.../release.yml@refs/tags/v1.0.0)
- Failure includes the Rekor log entry URL when the bundle has a tlog entry, so the user can inspect the underlying transparency-log record without scraping bundle JSON by hand.
Test strategy¶
Three tiers, in increasing fidelity:
-
Unit (
internal/plugin/verify/sigstore_test.go) — hermetic, no network. Cover the structured-error path (missing-bundle), the trust-root cache TTL, the glob-to-regex mapping. Recorded fixtures undertestdata/sigstore/cover bundle parsing; the rotation playbook is documented in the README there. -
Dual-run matrix (
verify_test.go) — every policy-surface test runs against bothStubVerifierandSigstoreVerifierso behavior drift between the two surfaces is visible immediately. -
Live (
e2e/live/verify_live_test.go, build tage2e_live) — drives the actualsamuelbinary against the public sigstore-test-registry with three signed fixtures (signed, unsigned, wrong-identity). Network-bound; runs nightly.
Performance budget¶
Asserted by verify_bench_test.go:
| Path | Budget | Source of latency |
|---|---|---|
| Cold (no cache) | ≤ 3s | TUF fetch (~1s) + bundle parse + sigstore math |
| Cold (trust cached) | ≤ 500ms | Bundle parse + sigstore math |
| Warm (result cache hit) | ≤ 50ms | JSON unmarshal of the cached Result |
The benchmarks marked SAMUEL_BENCH_NETWORK=1 are skipped in the default unit-test run so the unit tier stays hermetic.
Alternatives considered¶
Embed long-lived public keys¶
Pinning Fulcio + Rekor pubkeys directly in the samuel binary would remove the TUF dependency at the cost of taking sigstore's rotation cadence on the chin every binary release. The TUF fetch is cheap once cached; the rotation safety more than justifies it. Rejected.
Self-hosted Sigstore (SAMUEL_REKOR_URL / SAMUEL_FULCIO_URL)¶
Some users will want to run their own Fulcio + Rekor on internal infrastructure. We could plumb the URLs as env vars now. Deferred to v2.2 because (a) no current user has asked for it and (b) the sigstore-go API exposes the override knobs cleanly when we need them — adding them later is a non-breaking change.
samuel sign wrapper¶
A samuel sign command that wraps cosign sign-blob could simplify plugin authoring. Deferred: plugin authors today use cosign sign-blob directly in their CI, and adding a wrapper would either duplicate cosign's flag surface or constrain it. Worth revisiting when a plugin-authoring CLI gets prioritized.
Build-tag gated production verifier¶
We considered gating SigstoreVerifier behind a build tag (e.g. samuel_prod) so the default go build would produce a stub binary. Rejected: that re-creates the v2.0 trust-gap problem at the distribution level. Users downloading a release binary should get the production verifier by default; the SAMUEL_VERIFY_STUB=1 env var is the explicit, runtime-visible opt-out.
Migration¶
The migration is a no-op for end users:
- The samuel.toml
[security]schema is unchanged. - The lockfile format is unchanged.
- The CLI flags are unchanged.
- Plugins signed against the test registry on v2.0 verify against the same identity patterns on v2.1 without re-signing — the patterns haven't moved.
What changes:
samuel doctor's advisory line.- The
samuel installsuccess line surfaces the actual signing identity instead of a placeholder. - Unsigned plugins (without a published
signature_bundle) now fail with a structured error unless--allow-unsignedis set; on v2.0 the stub allowed them silently when policy passed.
That third change is intentional and is the one observable behavioral break. The test registry's fixtures (samuel-test-skill-unsigned) cover it explicitly so the failure mode is documented and tested.
Open questions¶
- Trust-root rotation tooling: deferred to a future PRD. Document upstream sigstore's rotation policy in
docs/concepts/signing.mdso users understand the lifecycle. - Offline verify mode: should
samuel install --offlinework for cached-verify hits? Recommend yes; document the staleness window. Out of scope for v2.1. - Multi-issuer identity patterns: e.g. accepting both GitHub Actions OIDC and a self-hosted issuer. Deferred to v2.2.