// 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: //; 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 }