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
7 changes: 4 additions & 3 deletions go/internal/selfupdate/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import (
//
// Verification order matters: we authenticate checksums.txt (cosign keyless,
// when available — see verifyChecksumsSignature) BEFORE trusting any digest it
// contains, then check the archive against that now-trusted digest. Warnings for
// the cosign-absent / unsigned-release fallback paths go to stderr.
// contains, then check the archive against that now-trusted digest. When cosign
// isn't installed the update stays quiet (the checksum still guards the download);
// set MIR_REQUIRE_COSIGN to demand signature verification.
func (c *Client) Apply(rel *Release, targetPath string) error {
archive, err := c.fetch(rel.AssetURL)
if err != nil {
Expand All @@ -30,7 +31,7 @@ func (c *Client) Apply(rel *Release, targetPath string) error {
return fmt.Errorf("download checksums: %w", err)
}
if err := c.verifyChecksumsSignature(rel, sums, func(msg string) {
fmt.Fprintln(os.Stderr, "warning: "+msg)
fmt.Fprintln(os.Stderr, msg)
}); err != nil {
return err
}
Expand Down
40 changes: 25 additions & 15 deletions go/internal/selfupdate/cosign.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,44 @@ func cosignIdentityRegexp(repo string) string {
// genuinely came from THIS repo's release pipeline. It does NOT attest to the
// source that was built — only to the checksum file's origin.
//
// Degradation policy (mirrors install.sh):
// - cosign not on PATH -> warn once, return nil (checksum-only fallback;
// keep self-update working for users without cosign).
// Degradation policy:
// - cosign not on PATH -> stay SILENT and return nil (checksum-only
// fallback). Most users don't have cosign installed, and the per-file SHA256
// already guards against a corrupted download, so a successful update must not
// look like a failure. Set MIR_REQUIRE_COSIGN to turn this into a hard error.
// - signing assets absent -> if the release predates signing (no .sig/.pem
// URLs at all), warn and fall back. If cosign is present AND the assets are
// expected but unfetchable, that is a hard failure.
// URLs at all), fall back silently (or hard-fail under MIR_REQUIRE_COSIGN). If
// cosign is present AND the assets are expected but unfetchable, hard failure.
// - verification fails -> return error; the caller MUST abort the update.
// - verification passes -> emit a positive one-line confirmation via note.
//
// warnf receives a single human-readable warning line (no trailing newline) for
// the fallback cases; pass a stderr writer in production, nil to silence.
func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, warnf func(string)) error {
warn := func(msg string) {
if warnf != nil {
warnf(msg)
// note receives a single human-readable line (no trailing newline) for the success
// confirmation; pass a stderr writer in production, nil to silence.
func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, note func(string)) error {
emit := func(msg string) {
if note != nil {
note(msg)
}
}
// soft degrades a missing-provenance case: silent by default (don't nag the
// majority without cosign), a hard error when the operator demands verification.
soft := func(reason string) error {
if os.Getenv("MIR_REQUIRE_COSIGN") != "" {
return fmt.Errorf("%s, and MIR_REQUIRE_COSIGN is set", reason)
}
return nil
}

if _, err := exec.LookPath("cosign"); err != nil {
warn("cosign not found; skipping signature check of checksums.txt (install cosign for supply-chain verification) — falling back to checksum-only")
return nil
return soft("cosign is not installed, so the release signature was not verified")
}

// A release cut before signing was introduced carries no .sig/.pem. cosign
// being installed cannot conjure them — fall back rather than hard-fail so
// upgrading FROM an old release still works. (The next signed tag is the
// first one that will actually exercise verification.)
if rel.ChecksumsSigURL == "" || rel.ChecksumsCertURL == "" {
warn("release has no cosign signature for checksums.txt (unsigned/legacy release) — falling back to checksum-only")
return nil
return soft("this release has no cosign signature")
}

sig, err := c.fetch(rel.ChecksumsSigURL)
Expand Down Expand Up @@ -107,5 +116,6 @@ func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, warnf func(
if err := cmd.Run(); err != nil {
return fmt.Errorf("cosign verify-blob failed for checksums.txt (possible tampering): %w", err)
}
emit("✓ verified the release signature (cosign keyless)")
return nil
}
66 changes: 52 additions & 14 deletions go/internal/selfupdate/cosign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,46 @@ func TestCosignIdentityRegexp(t *testing.T) {
}

// TestVerifyChecksumsSignatureNoCosign pins the graceful-degradation contract:
// with cosign absent from PATH, verification must return nil (fall back to
// checksum-only) and emit exactly one warning. We force "cosign not found" by
// pointing PATH at an empty dir for the duration of the test.
// with cosign absent from PATH, verification returns nil (checksum-only fallback)
// and stays SILENT — a successful update must not nag the majority who have no
// cosign. We force "cosign not found" by pointing PATH at an empty dir.
func TestVerifyChecksumsSignatureNoCosign(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // no cosign on this PATH
if _, err := exec.LookPath("cosign"); err == nil {
t.Skip("cosign unexpectedly resolvable on stripped PATH")
}

var warnings []string
var notes []string
c := &Client{Repo: "srcfl/miranda"}
rel := &Release{ChecksumsSigURL: "http://x/sig", ChecksumsCertURL: "http://x/pem"}
if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { warnings = append(warnings, m) }); err != nil {
if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err != nil {
t.Fatalf("expected nil (fallback) when cosign absent, got %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "cosign not found") {
t.Fatalf("expected one 'cosign not found' warning, got %v", warnings)
if len(notes) != 0 {
t.Fatalf("expected NO output when cosign is absent (don't nag), got %v", notes)
}
}

// TestVerifyChecksumsSignatureStrictRequiresCosign: with MIR_REQUIRE_COSIGN set,
// a missing cosign becomes a hard error so an operator can MANDATE provenance.
func TestVerifyChecksumsSignatureStrictRequiresCosign(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Setenv("MIR_REQUIRE_COSIGN", "1")
if _, err := exec.LookPath("cosign"); err == nil {
t.Skip("cosign unexpectedly resolvable on stripped PATH")
}
c := &Client{Repo: "srcfl/miranda"}
rel := &Release{ChecksumsSigURL: "http://x/sig", ChecksumsCertURL: "http://x/pem"}
err := c.verifyChecksumsSignature(rel, []byte("sums"), nil)
if err == nil || !strings.Contains(err.Error(), "MIR_REQUIRE_COSIGN") {
t.Fatalf("expected a hard error under MIR_REQUIRE_COSIGN, got %v", err)
}
}

// TestVerifyChecksumsSignatureUnsignedRelease pins that a release WITHOUT
// signing assets (empty .sig/.pem URLs, e.g. a legacy tag) falls back rather
// than hard-failing — even when cosign IS installed. We fake a cosign on PATH so
// the LookPath check passes; it must never be invoked on this path.
// signing assets (empty .sig/.pem URLs, e.g. a legacy tag) falls back silently
// rather than hard-failing — even when cosign IS installed. We fake a cosign on
// PATH so the LookPath check passes; it must never be invoked on this path.
func TestVerifyChecksumsSignatureUnsignedRelease(t *testing.T) {
dir := t.TempDir()
fakeCosign := filepath.Join(dir, "cosign")
Expand All @@ -69,14 +85,36 @@ func TestVerifyChecksumsSignatureUnsignedRelease(t *testing.T) {
}
t.Setenv("PATH", dir)

var warnings []string
var notes []string
c := &Client{Repo: "srcfl/miranda"}
rel := &Release{} // no ChecksumsSigURL / ChecksumsCertURL
if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { warnings = append(warnings, m) }); err != nil {
if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err != nil {
t.Fatalf("expected nil (fallback) for unsigned release, got %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "no cosign signature") {
t.Fatalf("expected one 'no cosign signature' warning, got %v", warnings)
if len(notes) != 0 {
t.Fatalf("expected NO output for an unsigned release, got %v", notes)
}
}

// TestVerifyChecksumsSignaturePassEmitsNote: when cosign IS present and verifies,
// the update emits a positive one-line confirmation (and returns nil).
func TestVerifyChecksumsSignaturePassEmitsNote(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "cosign"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
srv := newBlobServer(t)
defer srv.Close()

var notes []string
c := &Client{Repo: "srcfl/miranda", HTTP: srv.Client()}
rel := &Release{ChecksumsSigURL: srv.URL + "/sig", ChecksumsCertURL: srv.URL + "/pem"}
if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err != nil {
t.Fatalf("expected nil when cosign verifies, got %v", err)
}
if len(notes) != 1 || !strings.Contains(notes[0], "verified") {
t.Fatalf("expected one positive 'verified' note, got %v", notes)
}
}

Expand Down
Loading