publicrelease: the vendor feed's verification chain, written once
carlos update and the console's carlos system update share it: latest tag, signed SHA256SUMS, per-asset sha.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
3 files changed,
+230
−66
internal/publicrelease/feed.go+130 −0internal/publicrelease/feed_test.go+87 −0update.go+13 −66
diff --git a/internal/publicrelease/feed.go b/internal/publicrelease/feed.go| new file mode 100644 |
| index 000000000..65f1e5270 |
| --- /dev/null |
| +++ b/internal/publicrelease/feed.go |
| @@ -0,0 +1,130 @@ |
| +// Package publicrelease reads the vendor's public signed release feed: the |
| +// latest tag, the signed checksum list, and a verified asset. Two callers |
| +// share it — `carlos update` on a workstation and the console's `carlos |
| +// system update` promote — so the verification chain is written once. |
| +package publicrelease |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| + "time" |
| +) |
| + |
| +type Feed struct { |
| + LatestURL string |
| + DownloadBase string // tag-pinned: <DownloadBase>/<tag>/<file>; never latest/download |
| + PubKey string // base64 ed25519 public key over the raw SHA256SUMS.txt bytes |
| + Client *http.Client |
| +} |
| + |
| +// Default is the vendor feed. The strings are duplicated in update.go's |
| +// package vars so update_test.go can repoint them; keep the two in step. |
| +func Default() Feed { |
| + return Feed{ |
| + LatestURL: "https://apt.carlosframework.com/latest.txt", |
| + DownloadBase: "https://github.com/carlosframework/releases/releases/download", |
| + PubKey: "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=", |
| + } |
| +} |
| + |
| +var TagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) |
| + |
| +func AssetName(platform string) string { return "carlos-" + platform } |
| + |
| +func (f Feed) client() *http.Client { |
| + if f.Client != nil { |
| + return f.Client |
| + } |
| + return &http.Client{Timeout: 5 * time.Minute} |
| +} |
| + |
| +func (f Feed) get(ctx context.Context, url string, limit int64) ([]byte, error) { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + res, err := f.client().Do(req) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + return nil, fmt.Errorf("%s: status %d", url, res.StatusCode) |
| + } |
| + return io.ReadAll(io.LimitReader(res.Body, limit)) |
| +} |
| + |
| +// Latest is the newest release tag, and only a tag. |
| +func (f Feed) Latest(ctx context.Context) (string, error) { |
| + b, err := f.get(ctx, f.LatestURL, 128) |
| + if err != nil { |
| + return "", err |
| + } |
| + tag := strings.TrimSpace(string(b)) |
| + if !TagRe.MatchString(tag) { |
| + return "", fmt.Errorf("%s: %q is not a release tag", f.LatestURL, tag) |
| + } |
| + return tag, nil |
| +} |
| + |
| +// Sums fetches SHA256SUMS.txt and its .sig for tag, verifies the signature |
| +// with PubKey, and returns asset → sha256 hex. |
| +func (f Feed) Sums(ctx context.Context, tag string) (map[string]string, error) { |
| + base := f.DownloadBase + "/" + tag + "/" |
| + sums, err := f.get(ctx, base+"SHA256SUMS.txt", 1<<20) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sig, err := f.get(ctx, base+"SHA256SUMS.txt.sig", 4096) |
| + if err != nil { |
| + return nil, fmt.Errorf("%v (a release without a signed checksum list predates self-update)", err) |
| + } |
| + pub, err := base64.StdEncoding.DecodeString(f.PubKey) |
| + if err != nil || len(pub) != ed25519.PublicKeySize { |
| + return nil, errors.New("release feed public key is invalid") |
| + } |
| + rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig))) |
| + if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) { |
| + return nil, errors.New("SHA256SUMS.txt signature does not verify — refusing the release") |
| + } |
| + out := map[string]string{} |
| + for _, line := range strings.Split(string(sums), "\n") { |
| + fields := strings.Fields(line) |
| + if len(fields) == 2 { |
| + out[fields[1]] = fields[0] |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// Asset fetches one named asset for tag and refuses it unless its sha256 |
| +// matches the signed checksum list. |
| +func (f Feed) Asset(ctx context.Context, tag, asset string, limit int64) ([]byte, string, error) { |
| + sums, err := f.Sums(ctx, tag) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + want, ok := sums[asset] |
| + if !ok { |
| + return nil, "", fmt.Errorf("no %s in the release's checksum list", asset) |
| + } |
| + data, err := f.get(ctx, f.DownloadBase+"/"+tag+"/"+asset, limit) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + s := sha256.Sum256(data) |
| + got := hex.EncodeToString(s[:]) |
| + if got != want { |
| + return nil, "", fmt.Errorf("%s does not match its signed checksum — refusing it", asset) |
| + } |
| + return data, got, nil |
| +} |
diff --git a/internal/publicrelease/feed_test.go b/internal/publicrelease/feed_test.go| new file mode 100644 |
| index 000000000..d77de5fed |
| --- /dev/null |
| +++ b/internal/publicrelease/feed_test.go |
| @@ -0,0 +1,87 @@ |
| +package publicrelease |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func testFeed(t *testing.T, sign bool, assets map[string][]byte) (Feed, string) { |
| + t.Helper() |
| + pub, priv, _ := ed25519.GenerateKey(nil) |
| + var sums strings.Builder |
| + for name, body := range assets { |
| + s := sha256.Sum256(body) |
| + fmt.Fprintf(&sums, "%s %s\n", hex.EncodeToString(s[:]), name) |
| + } |
| + sig := ed25519.Sign(priv, []byte(sums.String())) |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "v0.18.0") }) |
| + mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums.String()) }) |
| + mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt.sig", func(w http.ResponseWriter, r *http.Request) { |
| + if sign { |
| + fmt.Fprintln(w, base64.StdEncoding.EncodeToString(sig)) |
| + } else { |
| + fmt.Fprintln(w, base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize))) |
| + } |
| + }) |
| + for name, body := range assets { |
| + body := body |
| + mux.HandleFunc("/dl/v0.18.0/"+name, func(w http.ResponseWriter, r *http.Request) { w.Write(body) }) |
| + } |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + return Feed{LatestURL: ts.URL + "/latest.txt", DownloadBase: ts.URL + "/dl", PubKey: base64.StdEncoding.EncodeToString(pub)}, ts.URL |
| +} |
| + |
| +func TestFeedLatestAndVerifiedAsset(t *testing.T) { |
| + body := []byte("ELF arm64") |
| + f, _ := testFeed(t, true, map[string][]byte{"carlos-linux-arm64": body, "carlos-linux-amd64": []byte("ELF amd64")}) |
| + ctx := context.Background() |
| + tag, err := f.Latest(ctx) |
| + if err != nil || tag != "v0.18.0" { |
| + t.Fatalf("latest = %q %v", tag, err) |
| + } |
| + data, sha, err := f.Asset(ctx, tag, AssetName("linux-arm64"), 1<<20) |
| + if err != nil || string(data) != string(body) { |
| + t.Fatalf("asset: %v", err) |
| + } |
| + s := sha256.Sum256(body) |
| + if sha != hex.EncodeToString(s[:]) { |
| + t.Fatalf("sha = %s", sha) |
| + } |
| + if _, _, err := f.Asset(ctx, tag, "carlos-windows-amd64.zip", 1<<20); err == nil { |
| + t.Fatal("an asset absent from the sums was accepted") |
| + } |
| +} |
| + |
| +func TestFeedRefusesABadSignatureAndATamperedAsset(t *testing.T) { |
| + body := []byte("ELF") |
| + f, _ := testFeed(t, false, map[string][]byte{"carlos-linux-arm64": body}) |
| + if _, err := f.Sums(context.Background(), "v0.18.0"); err == nil || !strings.Contains(err.Error(), "signature") { |
| + t.Fatalf("bad signature: %v", err) |
| + } |
| + // Signed sums, but the served asset differs from the sum. |
| + f2, _ := testFeed(t, true, map[string][]byte{"carlos-linux-arm64": body}) |
| + // Simulate tampering by asking for a limit smaller than the asset: the |
| + // truncated download cannot match its sum. |
| + if _, _, err := f2.Asset(context.Background(), "v0.18.0", "carlos-linux-arm64", 1); err == nil { |
| + t.Fatal("a truncated asset matched its sum") |
| + } |
| +} |
| + |
| +func TestFeedLatestRequiresATag(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "main") })) |
| + defer ts.Close() |
| + f := Feed{LatestURL: ts.URL, DownloadBase: ts.URL, PubKey: ""} |
| + if _, err := f.Latest(context.Background()); err == nil { |
| + t.Fatal("accepted a non-tag latest") |
| + } |
| +} |
diff --git a/update.go b/update.go| index 07a93ad5c..8730335fa 100644 |
| --- a/update.go |
| +++ b/update.go |
| @@ -7,15 +7,11 @@ |
| package main |
| import ( |
| - "crypto/ed25519" |
| - "crypto/sha256" |
| - "encoding/base64" |
| - "encoding/hex" |
| + "context" |
| "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| - "io" |
| "log" |
| "net/http" |
| "os" |
| @@ -26,6 +22,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/version" |
| ) |
| @@ -94,28 +91,20 @@ func updateCachePath() (string, error) { |
| return filepath.Join(home, ".carlos", "update-check.json"), nil |
| } |
| +// updateFeed builds the publicrelease.Feed from this package's vars, at |
| +// call time — never cached — so update_test.go's repointing of the vars |
| +// (a fresh httptest server per test) always reaches the feed it expects. |
| +func updateFeed() publicrelease.Feed { |
| + return publicrelease.Feed{LatestURL: updateLatestURL, DownloadBase: updateDownloadBase, PubKey: cliUpdatePubKey} |
| +} |
| + |
| // fetchLatest asks the apt site which release is current. One small GET |
| // with its own short deadline — the caller is done with its real work, and |
| // a slow feed must cost nobody anything. |
| func fetchLatest(timeout time.Duration) (string, error) { |
| - client := &http.Client{Timeout: timeout} |
| - res, err := client.Get(updateLatestURL) |
| - if err != nil { |
| - return "", err |
| - } |
| - defer res.Body.Close() |
| - if res.StatusCode != http.StatusOK { |
| - return "", fmt.Errorf("latest.txt: status %d", res.StatusCode) |
| - } |
| - raw, err := io.ReadAll(io.LimitReader(res.Body, 128)) |
| - if err != nil { |
| - return "", err |
| - } |
| - tag := strings.TrimSpace(string(raw)) |
| - if !releaseBuild(tag) { |
| - return "", fmt.Errorf("latest.txt: %q is not a release tag", tag) |
| - } |
| - return tag, nil |
| + f := updateFeed() |
| + f.Client = &http.Client{Timeout: timeout} |
| + return f.Latest(context.Background()) |
| } |
| // maybeUpdateHint prints the one-line "a newer carlos exists" nudge after |
| @@ -294,39 +283,10 @@ func windowsAsset() string { |
| // writes, so no failure can leave half a binary answering `carlos`. |
| func selfReplace(exe, latest string) error { |
| asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH) |
| - sums, err := fetchUpdateFile(latest, "SHA256SUMS.txt", 1<<20) |
| - if err != nil { |
| - return err |
| - } |
| - sig, err := fetchUpdateFile(latest, "SHA256SUMS.txt.sig", 4096) |
| - if err != nil { |
| - return fmt.Errorf("%v (a release without a signed checksum list predates carlos update)", err) |
| - } |
| - pub, err := base64.StdEncoding.DecodeString(cliUpdatePubKey) |
| - if err != nil || len(pub) != ed25519.PublicKeySize { |
| - return errors.New("embedded update key is invalid — this binary cannot self-update") |
| - } |
| - rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig))) |
| - if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) { |
| - return errors.New("SHA256SUMS.txt signature does not verify — refusing the download") |
| - } |
| - wantSum := "" |
| - for _, line := range strings.Split(string(sums), "\n") { |
| - fields := strings.Fields(line) |
| - if len(fields) == 2 && fields[1] == asset { |
| - wantSum = fields[0] |
| - } |
| - } |
| - if wantSum == "" { |
| - return fmt.Errorf("no %s in the release's checksum list", asset) |
| - } |
| - binData, err := fetchUpdateFile(latest, asset, 256<<20) |
| + binData, _, err := updateFeed().Asset(context.Background(), latest, asset, 256<<20) |
| if err != nil { |
| return err |
| } |
| - if got := hex.EncodeToString(func() []byte { s := sha256.Sum256(binData); return s[:] }()); got != wantSum { |
| - return fmt.Errorf("%s does not match its signed checksum — refusing to install it", asset) |
| - } |
| tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*") |
| if err != nil { |
| return err |
| @@ -354,16 +314,3 @@ func selfReplace(exe, latest string) error { |
| } |
| return os.Rename(tmp.Name(), exe) |
| } |
| - |
| -func fetchUpdateFile(tag, name string, limit int64) ([]byte, error) { |
| - client := &http.Client{Timeout: 5 * time.Minute} |
| - res, err := client.Get(updateDownloadBase + "/" + tag + "/" + name) |
| - if err != nil { |
| - return nil, err |
| - } |
| - defer res.Body.Close() |
| - if res.StatusCode != http.StatusOK { |
| - return nil, fmt.Errorf("%s: status %d", name, res.StatusCode) |
| - } |
| - return io.ReadAll(io.LimitReader(res.Body, limit)) |
| -} |