diff --git a/go/internal/selfupdate/apply.go b/go/internal/selfupdate/apply.go index 860a9a1..22e09ee 100644 --- a/go/internal/selfupdate/apply.go +++ b/go/internal/selfupdate/apply.go @@ -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 { @@ -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 } diff --git a/go/internal/selfupdate/cosign.go b/go/internal/selfupdate/cosign.go index 3092092..4115d67 100644 --- a/go/internal/selfupdate/cosign.go +++ b/go/internal/selfupdate/cosign.go @@ -36,26 +36,36 @@ 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 @@ -63,8 +73,7 @@ func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, warnf func( // 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) @@ -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 } diff --git a/go/internal/selfupdate/cosign_test.go b/go/internal/selfupdate/cosign_test.go index 8a066c0..a903be9 100644 --- a/go/internal/selfupdate/cosign_test.go +++ b/go/internal/selfupdate/cosign_test.go @@ -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") @@ -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) } }