1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6 "crypto/hmac"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "strconv"
14 "strings"
15 "time"
16)
17
18// Scope is what a job may do in the cache, as GitHub scopes it: entries belong
19// to a repository and a ref. A job writes to one ref only and reads from a
20// list of refs in priority order (its own ref, then the base branch of a pull
21// request, then the default branch).
22//
23// The scope of a job comes from Job.cache (see internal/host/cache.go, which
24// checks it). An empty WriteRef makes the job read-only; no ReadRefs, write-only.
25type Scope struct {
26 Repository string `json:"r"`
27 WriteRef string `json:"w,omitempty"`
28 ReadRefs []string `json:"rd,omitempty"`
29}
30
31// Claims are what a cache token vouches for.
32type Claims struct {
33 JobID string `json:"j"`
34 Scope
35 // Expiry is a Unix time in seconds.
36 Expiry int64 `json:"x"`
37}
38
39// errForbidden is every authentication failure: the caller learns nothing about
40// why a token was refused.
41var errForbidden = errors.New("cache: forbidden")
42
43// signer makes and checks the HMACs of the service: job tokens
44// (ACTIONS_RUNTIME_TOKEN) and the signed archive URLs. The key is random, made
45// at start and never written to disk: a restart invalidates every token, which
46// is right since it also ends every job.
47type signer struct{ key [32]byte }
48
49func newSigner() (*signer, error) {
50 s := &signer{}
51 if _, err := rand.Read(s.key[:]); err != nil {
52 return nil, fmt.Errorf("cache: random key: %w", err)
53 }
54 return s, nil
55}
56
57// mac separates the uses of the key by a prefix, so that a signature of one kind
58// can never pass for another.
59func (s *signer) mac(domain string, parts ...[]byte) []byte {
60 h := hmac.New(sha256.New, s.key[:])
61 h.Write([]byte(domain))
62 for _, p := range parts {
63 h.Write([]byte{0})
64 h.Write(p)
65 }
66 return h.Sum(nil)
67}
68
69var b64 = base64.RawURLEncoding
70
71// maxTokenLen bounds what verify parses.
72const maxTokenLen = 16 << 10
73
74// token is base64url(JSON claims) "." base64url(HMAC-SHA256 of the JSON bytes).
75func (s *signer) token(c Claims) string {
76 payload, _ := json.Marshal(c) // plain strings and ints: cannot fail
77 return b64.EncodeToString(payload) + "." + b64.EncodeToString(s.mac("token", payload))
78}
79
80// verify returns the claims of a token that this runner made and that has not
81// expired. The comparison is constant time.
82func (s *signer) verify(tok string, now time.Time) (Claims, error) {
83 var c Claims
84 if len(tok) > maxTokenLen {
85 return c, errForbidden
86 }
87 p, m, ok := strings.Cut(tok, ".")
88 if !ok {
89 return c, errForbidden
90 }
91 payload, err1 := b64.DecodeString(p)
92 sum, err2 := b64.DecodeString(m)
93 if err1 != nil || err2 != nil || !hmac.Equal(sum, s.mac("token", payload)) {
94 return c, errForbidden
95 }
96 if err := json.Unmarshal(payload, &c); err != nil || c.Repository == "" || now.Unix() >= c.Expiry {
97 return Claims{}, errForbidden
98 }
99 return c, nil
100}
101
102// archiveSig signs the download URL of one entry. The archive is fetched by a
103// client without the bearer token (downloadUtils.ts:168 uses a bare HttpClient),
104// so the URL is the credential; it is bound to the entry, its repository, the
105// job it was issued to and an expiry. The job is what keeps a URL that left its
106// sandbox useless on the channel of any other.
107func (s *signer) archiveSig(id int64, repo, job string, exp int64) string {
108 return b64.EncodeToString(s.mac("archive", []byte(strconv.FormatInt(id, 10)), []byte(repo), []byte(job), []byte(strconv.FormatInt(exp, 10))))
109}
110
111func (s *signer) archiveOK(id int64, repo, job string, exp int64, sig string, now time.Time) bool {
112 got, err := b64.DecodeString(sig)
113 if err != nil || now.Unix() >= exp {
114 return false
115 }
116 want, _ := b64.DecodeString(s.archiveSig(id, repo, job, exp))
117 return hmac.Equal(got, want)
118}