carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package publicrelease reads the vendor's public signed release feed: the
2// latest tag, the signed checksum list, and a verified asset. Two callers
3// share it — `carlos update` on a workstation and the console's `carlos
4// system update` promote — so the verification chain is written once.
5package publicrelease
6
7import (
8 "context"
9 "crypto/ed25519"
10 "crypto/sha256"
11 "encoding/base64"
12 "encoding/hex"
13 "errors"
14 "fmt"
15 "io"
16 "net/http"
17 "regexp"
18 "strings"
19 "time"
20)
21
22type Feed struct {
23 LatestURL string
24 DownloadBase string // tag-pinned: <DownloadBase>/<tag>/<file>; never latest/download
25 PubKey string // base64 ed25519 public key over the raw SHA256SUMS.txt bytes
26 Client *http.Client
27}
28
29// Default is the vendor feed. The strings are duplicated in update.go's
30// package vars so update_test.go can repoint them; keep the two in step.
31func Default() Feed {
32 return Feed{
33 LatestURL: "https://apt.carlosframework.com/latest.txt",
34 DownloadBase: "https://github.com/carlosframework/releases/releases/download",
35 PubKey: "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=",
36 }
37}
38
39var TagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
40
41func AssetName(platform string) string { return "carlos-" + platform }
42
43func (f Feed) client() *http.Client {
44 if f.Client != nil {
45 return f.Client
46 }
47 return &http.Client{Timeout: 5 * time.Minute}
48}
49
50func (f Feed) get(ctx context.Context, url string, limit int64) ([]byte, error) {
51 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
52 if err != nil {
53 return nil, err
54 }
55 res, err := f.client().Do(req)
56 if err != nil {
57 return nil, err
58 }
59 defer res.Body.Close()
60 if res.StatusCode != http.StatusOK {
61 return nil, fmt.Errorf("%s: status %d", url, res.StatusCode)
62 }
63 return io.ReadAll(io.LimitReader(res.Body, limit))
64}
65
66// Latest is the newest release tag, and only a tag.
67func (f Feed) Latest(ctx context.Context) (string, error) {
68 b, err := f.get(ctx, f.LatestURL, 128)
69 if err != nil {
70 return "", err
71 }
72 tag := strings.TrimSpace(string(b))
73 if !TagRe.MatchString(tag) {
74 return "", fmt.Errorf("%s: %q is not a release tag", f.LatestURL, tag)
75 }
76 return tag, nil
77}
78
79// Sums fetches SHA256SUMS.txt and its .sig for tag, verifies the signature
80// with PubKey, and returns asset → sha256 hex.
81func (f Feed) Sums(ctx context.Context, tag string) (map[string]string, error) {
82 base := f.DownloadBase + "/" + tag + "/"
83 sums, err := f.get(ctx, base+"SHA256SUMS.txt", 1<<20)
84 if err != nil {
85 return nil, err
86 }
87 sig, err := f.get(ctx, base+"SHA256SUMS.txt.sig", 4096)
88 if err != nil {
89 return nil, fmt.Errorf("%v (a release without a signed checksum list predates self-update)", err)
90 }
91 pub, err := base64.StdEncoding.DecodeString(f.PubKey)
92 if err != nil || len(pub) != ed25519.PublicKeySize {
93 return nil, errors.New("release feed public key is invalid")
94 }
95 rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
96 if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) {
97 return nil, errors.New("SHA256SUMS.txt signature does not verify — refusing the release")
98 }
99 out := map[string]string{}
100 for _, line := range strings.Split(string(sums), "\n") {
101 fields := strings.Fields(line)
102 if len(fields) == 2 {
103 out[fields[1]] = fields[0]
104 }
105 }
106 return out, nil
107}
108
109// Asset fetches one named asset for tag and refuses it unless its sha256
110// matches the signed checksum list.
111func (f Feed) Asset(ctx context.Context, tag, asset string, limit int64) ([]byte, string, error) {
112 sums, err := f.Sums(ctx, tag)
113 if err != nil {
114 return nil, "", err
115 }
116 want, ok := sums[asset]
117 if !ok {
118 return nil, "", fmt.Errorf("no %s in the release's checksum list", asset)
119 }
120 data, err := f.get(ctx, f.DownloadBase+"/"+tag+"/"+asset, limit)
121 if err != nil {
122 return nil, "", err
123 }
124 s := sha256.Sum256(data)
125 got := hex.EncodeToString(s[:])
126 if got != want {
127 return nil, "", fmt.Errorf("%s does not match its signed checksum — refusing it", asset)
128 }
129 return data, got, nil
130}
131