Skip to content

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:

  1. 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 under testdata/sigstore/ cover bundle parsing; the rotation playbook is documented in the README there.

  2. Dual-run matrix (verify_test.go) — every policy-surface test runs against both StubVerifier and SigstoreVerifier so behavior drift between the two surfaces is visible immediately.

  3. Live (e2e/live/verify_live_test.go, build tag e2e_live) — drives the actual samuel binary 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 install success 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-unsigned is 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.md so users understand the lifecycle.
  • Offline verify mode: should samuel install --offline work 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.