Skip to content

feat: add c1i upgrade self-updater (Sigstore-verified) from the C1 distribution center - #120

Open
leet-c1 wants to merge 3 commits into
mainfrom
feat/upgrade-command
Open

feat: add c1i upgrade self-updater (Sigstore-verified) from the C1 distribution center#120
leet-c1 wants to merge 3 commits into
mainfrom
feat/upgrade-command

Conversation

@leet-c1

@leet-c1 leet-c1 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What

Adds c1i upgrade (alias update) — a self-updater that pulls releases from the C1 distribution center (dist.conductorone.com), with install-method awareness and cryptographic verification.

c1i upgrade            # upgrade to the latest stable release (asks first)
c1i upgrade --check    # report whether a newer release is available; change nothing
c1i upgrade --channel latest -y   # take the newest release without prompting

How it verifies (two layers)

  1. Sigstore signature over the release manifest (keyless / Fulcio). The manifest's detached .sig/.cert are checked against a pinned identity — the ConductorOne reusable release workflow (SAN URI …/release.yaml@refs/tags/v4) and the GitHub Actions OIDC issuer. Chain validation is anchored at the certificate's NotBefore (Fulcio certs are ~10-min short-lived), and the embedded SCT is mandatory. Local checks (identity, signature) run before any network round-trip.
  2. SHA-256 over the downloaded archive, taken from the now-trusted manifest, checked before the binary is extracted.

Only after both pass is the running binary replaced, via an atomic same-dir stage → Syncchmodrename (POSIX permits renaming over a running executable).

Install-method awareness

Only a standalone downloaded binary is replaced in place. Homebrew, go install, container image, and Windows installs are detected and print the correct upgrade command instead of self-replacing.

Hardening (shared transport)

  • WithMaxResponseBytes bounds response bodies (metadata 8 MiB, artifact 200 MiB) so a hostile endpoint can't OOM the process. Additive; existing callers unchanged (unbounded by default).
  • Redirect handling now refuses an https → http scheme downgrade (credentials over cleartext); http → https still allowed.
  • Every fetched URL (index / manifest / sig / cert / asset href) is pinned to https + the dist host before the request goes out.

Known residual (documented, not a hard boundary)

index.json (channel targets + yank status) is not signed — only per-release manifests are. A compromised distribution origin could therefore steer a client to a different but authentic, ConductorOne-signed release (an older one, down to the current version — no further — or one marked yanked), but never to an unsigned or third-party binary. The monotonicity gate (cmp < 0) plus the signature bound the blast radius; the README and an in-code comment call this out. Closing it fully needs a signed index (a dist-side change).

Testing

  • Unit + httptest end-to-end coverage across internal/selfupdate, internal/transport, and cmd, including a live test that verifies the real published v0.7.0 manifest signature against the pinned identity, and negative tests (bad base64, non-PEM cert, tampered bytes, wrong identity).
  • Live end-to-end against real dist.conductorone.com: a genuine self-replace v0.5.2 → v0.7.0 where the on-disk binary's SHA-256 matched the published archive byte-for-byte; plus the Homebrew / go install refusal branches, already-latest / ahead-of-channel messages, and bad-channel (exit 2).
  • Full gate green: build, vet, test (incl. -shuffle), golangci-lint (0), gosec (0), gitleaks (clean), govulncheck (clean — required a sigstore-go v1.2.1 + grpc v1.82.1 bump to clear two advisories).

Reviewed independently (Sigstore trust chain, download/apply, URL pinning, control flow, transport hardening) — no blocking findings.

🤖 Generated with Claude Code

leet-c1 and others added 3 commits September 4, 2026 17:43
`c1i upgrade` (alias `update`) checks for and installs a newer release.

Discovery uses dist.conductorone.com's public release interface (the index.json
channels + per-version manifest.json documented in baton-admin's dist-release
RFC): the `stable` channel by default, `latest`/`preview` via --channel. No C1
CLI consumes this yet, so the small stable subset of the schema is hand-rolled
in internal/selfupdate rather than importing the canonical protobuf from
github-workflows.

Flow: read the channel's target version, compare to the build-info version,
and for a standalone binary download the GOOS-GOARCH asset, verify its
manifest SHA-256, and atomically replace the running executable (temp file in
the same directory, then rename — POSIX allows replacing a running binary).
`--check` reports without changing anything; `--dry-run` previews the exact
download and target; `--yes` skips the prompt (required when stdin isn't a tty).

Install-method aware: a Homebrew (Cellar), `go install` (GOBIN/GOPATH), or
container-image install is NOT self-replaced — upgrade prints the right command
for it. Windows (a running .exe can't be swapped; the channel is an MSI) is
pointed at the download.

Fetches go through internal/transport, so --debug/--max-retries and the c1i
user-agent apply and the file is not a new HTTP-bypass. sha256 only for v1;
the manifest's Sigstore signature/SLSA provenance are a fast-follow. No new
dependencies.

Live-validated against real dist: --check distinguishes stable (v0.6.0) from
latest (v0.7.0), and --dry-run resolves the real v0.7.0 linux-amd64 artifact
whose sha256 matches the published checksum. Unit tests cover version
compare, install detection, index/manifest parsing, the SPA-shell guard,
sha256 verify, tar.gz/zip extraction, atomic replace, and end-to-end apply
including the checksum-mismatch abort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the adversarial-review findings on `c1i upgrade`. Verification is now
two layers: the release manifest's keyless Sigstore signature is checked against
a PINNED identity before it is trusted, and the manifest's per-asset SHA-256
then anchors the downloaded bytes.

Sigstore (internal/selfupdate/verify.go): pins the SAN
(github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4)
and OIDC issuer (token.actions.githubusercontent.com). Verifies, all mandatory:
the cert identity matches the pin, the signature is valid over exactly the raw
manifest bytes, the cert chains to a Fulcio root, and its SCT verifies against
the trust root's CT logs. Local checks run first so a wrong identity / tampered
manifest fails offline. The detached manifest .sig/.cert carry no Rekor entry,
so Rekor is not enforced for the manifest signature and the chain is validated
at the cert's NotBefore (Fulcio certs are ~10 min, so time.Now() would fail
every past release) -- documented in-code; trust rests on pinned identity + SCT
+ SHA-256. A compromised dist can no longer install an attacker binary: it
cannot mint a Fulcio cert for ConductorOne's workflow identity.

Hardening: href/manifest/sig/cert URLs are pinned to https + the dist host
(no fetch-from-arbitrary-host); transport gained WithMaxResponseBytes and the
download/metadata reads are bounded (no pre-verification OOM); the redirect
guard refuses an https->http downgrade; replaceExecutable fsyncs the staged
file and its dir; CompareVersions does proper semver prerelease ordering
(rc.10 > rc.2); fromZip requires a regular file; container detection adds
Podman's /run/.containerenv; manifest.Semver is checked == target; and the
confirm prompt names the exact binary path being replaced.

Deps: sigstore-go v1.2.1, sigstore v1.10.8 (+ transitive) -- pinned past
GO-2026-6162 / GO-2026-6061; govulncheck clean. Tests: offline failure paths
(bad base64, non-PEM, tampered bytes, identity/issuer mismatch) plus a live
integration test verifying the real v0.7.0 manifest against the pinned identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion check

Addresses the adversarial re-review of the Sigstore work. Both red-teams found
the crypto sound (no attacker-binary RCE under full dist compromise) and the
hardening genuine/regression-free. Two residuals closed here:

- The one Medium finding: index.json (channel + yank status) is not signed, only
  the per-release manifest is, so a compromised distribution origin could steer
  a user to a different but authentic ConductorOne-signed release (older-but-not-
  below-current, or yanked) — never to an unsigned/third-party binary. c1i can't
  fully close this without a signed index (a dist-side change); documented in the
  README and at the yank check so `yanked` is understood as best-effort, not a
  hard security boundary.
- A low fail-closed brittleness: manifest.Semver == target was an exact string
  compare (a v0.7.0 vs 0.7.0 skew would falsely reject); now compared as semver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant