Skip to content

Add c1i upgrade: Sigstore-verified self-update from the C1 distribution center - #120

Merged
leet-c1 merged 18 commits into
mainfrom
feat/upgrade-command
Oct 6, 2026
Merged

leet-c1 merged 18 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), which installs a newer release from the C1 distribution center (dist.conductorone.com) and verifies it before replacing anything. Targeted for 0.9.0.

c1i upgrade                     # latest stable release (asks first; -y/--yes to skip)
c1i upgrade --check             # JSON: current, latest, channel, update_available, install_method
c1i upgrade --channel latest -y # newest build, no prompt

Only a standalone binary is replaced in place. For Homebrew, go install, and container installs it prints the right upgrade command and exits 0; --check includes it as upgrade_command. Windows and system-package installs are never self-replaced. Development builds (dev, (devel), Go pseudo-versions) refuse to replace themselves.

Verification

  1. Sigstore signature on the release manifest.
    • The certificate must come from the release workflow: an anchored match on ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v<major> and the GitHub Actions OIDC issuer. It must also carry source repo ConductorOne/c1i, source ref refs/tags/v<manifest version>, trigger push, and a GitHub-hosted runner.
    • The certificate chain, embedded SCT, and Rekor transparency-log entry are verified against the Sigstore public-good trust root. That root is fetched over TUF through c1i's shared transport, so --debug and --max-retries apply to it.
    • The manifest's version must equal the target the channel named.
  2. SHA-256 of the downloaded archive, taken from the verified manifest's entry for this OS and architecture.

The binary is replaced atomically: staged in the same directory, fsync, rename. The writability of the install directory is checked before anything is downloaded. Concurrent upgrades are serialized with a flock on the install directory. On NFS-style filesystems that can't lock a directory, the upgrade proceeds unlocked, since the rename is atomic. The installed version is re-checked under the lock.

Exit codes

Dist 404 → 4, 429 → 5, 5xx → 6. Any other dist 4xx, an unreachable dist or TUF host, a refused redirect, or a signature or checksum failure → 8. A local failure (directory not writable, replace failed) → 1, naming the install directory.

Known residual

index.json (channel targets and yank status) is not signed; only per-release manifests are. A compromised distribution origin can withhold upgrades or offer any signed release newer than yours, including a yanked or prerelease one. It can never deliver an unsigned build, or one older than the binary you run. Closing this needs a signed index on the dist side.

Dependencies

  • New direct dependencies: sigstore-go, sigstore/sigstore, protobuf-specs, go-tuf/v2, golang.org/x/mod. All are at their latest release, except grpc, held at v1.83.2 because v1.84.0 is still affected by GO-2026-6443. grpc-gateway stays at v2.30.0 for the same reason, because v2.31.0 requires grpc v1.84.0.
  • govulncheck -show verbose on linux, darwin and windows: 0 called and 0 imported vulnerabilities. One module-level finding, GO-2026-5932 (x/crypto/openpgp), has no fix and isn't imported.
  • The stripped binary grows from 12.9 MB to 23.4 MB, almost all of it sigstore-go's verify/tlog/bundle packages (which pull in grpc). Hand-rolling those checks would save about 6.5 MB; we chose not to reimplement security-critical verification to save space.

The shared transport also gains WithMaxResponseBytes (opt-in), and now refuses an https → http redirect.

Also in this PR

  • Saving the tenant URL to ~/.c1i.yaml no longer overwrites a file it can't read or parse. It leaves the file unchanged and auth login prints a warning. The credential is still stored.
  • A ~/.c1i.yaml holding only ---, ~ or null no longer crashes login. It used to panic on save; it's now treated as empty.
  • SaveToConfigFile now has tests, each confirmed by a mutant.
  • Two code comments no longer use internal shorthand for the API gateway.

Testing

  • Offline verification tests. These use a snapshot of the Sigstore trust root and the real signed v0.7.0 release. Forged certificates, tampered manifests and bundle mismatches are rejected.
  • End-to-end httptest flow with a throwaway Fulcio CA, CT log and Rekor log. Unsigned, tampered, wrong-version, wrong-checksum and yanked releases all exit 8, with the binary byte-identical afterwards.
  • Mutation testing. Each load-bearing check was removed in turn, 33 checks in all: signature call, tlog, SCT, chain, identity fields, version binding, sha, yank, lock re-check, TUF routing, exit-code mapping, writability. A test fails for every one. Two independent rounds of security review re-ran these.
  • Live tests against dist.conductorone.com.
    • A v0.7.0 build replaced itself with v0.8.0, and its SHA matches the published archive.
    • --check returns JSON on all three channels.
    • The symlinked, Homebrew-shaped, and go install-shaped paths, plus dev builds, behave as described above.
    • A read-only directory fails before download (exit 1). Offline exits 8, instantly with --max-retries 0.
    • Two concurrent upgrades leave one valid binary and no stray files.
    • The Sigstore TUF fetch is visible in --debug.
  • Gates at the CI-pinned versions: build, vet, test -count=1 -shuffle=on, golangci-lint v2.14.0 (0 issues), gosec v2.29.0, govulncheck v1.8.0 ×3 GOOS, go mod tidy, and gitleaks all pass.

Not tested live: tampered releases (covered offline), the interactive prompt, macOS, Windows, and container installs.

🤖 Generated with Claude Code

@leet-c1
leet-c1 force-pushed the feat/upgrade-command branch from 512cbaf to a9ee22a Compare September 21, 2026 20:11
leet-c1 and others added 13 commits October 5, 2026 23:07
`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 of the C1 distribution center's release
layout): 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>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump sigstore-go, sigstore, protobuf-specs and go-tuf to their latest
releases, and their transitive modules with them. grpc is held at
v1.83.2: v1.84.0 is still affected by GO-2026-6443, which v1.83.2 fixes.
x/crypto and x/net move to releases that fix GO-2026-6303/6354/6355 and
GO-2026-5942.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and exit codes

- Verification is now tested offline against the real v0.7.0 release and a
  snapshot of the Sigstore public-good trusted root: tampered bytes, a
  forged self-signed certificate, mismatched bundle signature/certificate, a
  corrupted SET, an integrated time past the certificate, and a trust root
  missing Fulcio, CT or Rekor each fail. A cmd-level httptest flow signs
  manifests with a throwaway Fulcio/CT/Rekor and checks that unsigned,
  tampered, wrong-semver and wrong-sha releases leave the binary untouched.
- The certificate may come from any major tag of the release workflow, and
  must carry c1i's tag for the manifest's version, a push trigger and a
  GitHub-hosted runner.
- A dist HTTP status keeps its exit code (404 4, 429 5, 5xx 6); signature,
  checksum, TUF, an unreachable dist and a refused redirect exit 8; a local
  replace or lock failure exits 1 and names the install directory.
- `--check` prints a JSON report; pseudo-version builds don't self-replace;
  the lock no longer leaves a file next to the binary; the TUF trust root is
  fetched through the shared transport; `go env` runs with
  GOTOOLCHAIN=local from a neutral directory; confirm reads the command's
  input. Removes dead code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds an upgrade entry to the agents guide, cuts the README section to the
examples and two paragraphs, and shortens the CHANGELOG entry. The unsigned
index.json residual is restated: an origin can withhold an upgrade or offer
a yanked release, but the rollback check stops it serving an older one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The snapshot trust root's base64 keys contain two runs shaped like C1 object
ids. Allowlist those exact tokens rather than the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ap dist 4xx to 8

- A directory flock that the filesystem can't take (EBADF, ENOLCK,
  EOPNOTSUPP/ENOTSUP, as on NFS) no longer aborts the upgrade; the rename
  is atomic either way.
- The install directory's writability is checked before anything is
  downloaded, and the error names the directory once.
- A dist 401, 403 or other 4xx exits 8: dist needs no auth and takes no
  caller input. 404, 429 and 5xx keep 4, 5 and 6.
- Tests now cover the TUF fetch going through the client's Doer, the yank
  check, and the installed-version re-check under the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ex residual

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rectory once

The up-front writability check made the replace-failure path unreachable in
tests; drive it with a directory where the binary should be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@leet-c1
leet-c1 force-pushed the feat/upgrade-command branch from f9b7864 to 9e527b8 Compare October 5, 2026 23:13
@leet-c1 leet-c1 changed the title feat: add c1i upgrade self-updater (Sigstore-verified) from the C1 distribution center Add c1i upgrade: Sigstore-verified self-update from the C1 distribution center Oct 5, 2026
leet-c1 and others added 5 commits October 6, 2026 02:55
… the downgrade rule

Gives upgrade its own section in the agents guide, lists install_method
values, says which dist failures exit 8, documents --dry-run, and notes that
an https->http redirect is never followed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating the file (mode 0600), replacing url while keeping other keys, and
the current behaviors worth knowing: comments are dropped, and an
unparseable file is replaced rather than reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SaveToConfigFile now returns an error naming the file, and leaves it
untouched, when it can't read or parse it. auth login has already stored
its credential, so it still succeeds and warns that the URL wasn't saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file holding only "---", "~" or "null" unmarshals to a nil map, and
saving the URL then panicked after login had stored its credential. Such a
file is now an empty config. The error for a file that isn't a YAML mapping
now says so. Tests pin empty, whitespace and comment-only files and a file
that can't be read. Also corrects the agents guide on what a source build
prints, and re-wraps two edited paragraphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@leet-c1
leet-c1 merged commit 9f80d48 into main Oct 6, 2026
2 checks passed
@leet-c1
leet-c1 deleted the feat/upgrade-command branch October 6, 2026 14:25
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