carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev 309e10b2d1bd6feb402bcef68bd467213ab6efdd parent 2941492
3 files changed, +230 −66
  • internal/publicrelease/feed.go +130 −0
  • internal/publicrelease/feed_test.go +87 −0
  • update.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))
-}