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 runtime token (ACTIONS_RUNTIME_TOKEN) vouches for: the
32// job, its cache scope and its other scopes. The token is shared by the cache
33// and the results service of the job's channel.
34type Claims struct {
35 JobID string `json:"j"`
36 Scope
37 // Expiry is a Unix time in seconds.
38 Expiry int64 `json:"x"`
39 // ExpiresAt and IssuedAt are the JWT's exp and iat; exp is Expiry.
40 ExpiresAt int64 `json:"exp"`
41 IssuedAt int64 `json:"iat"`
42 // Scopes is the scp claim: space-separated scopes, as GitHub's runtime
43 // token has them. @actions/artifact decodes the token (without checking
44 // it) and takes the run and job ids from its Actions.Results scope
45 // (toolkit packages/artifact/src/internal/shared/util.ts:21-75).
46 Scopes string `json:"scp"`
47}
48
49// genericReadScope is the first scope of GitHub's runtime tokens.
50const genericReadScope = "Actions.GenericRead:00000000-0000-0000-0000-000000000000"
51
52// errForbidden is every authentication failure: the caller learns nothing about
53// why a token was refused.
54var errForbidden = errors.New("cache: forbidden")
55
56// signer makes and checks the HMACs of the service: job tokens
57// (ACTIONS_RUNTIME_TOKEN) and the signed archive URLs. The key is random, made
58// at start and never written to disk: a restart invalidates every token, which
59// is right since it also ends every job.
60type signer struct{ key [32]byte }
61
62func newSigner() (*signer, error) {
63 s := &signer{}
64 if _, err := rand.Read(s.key[:]); err != nil {
65 return nil, fmt.Errorf("cache: random key: %w", err)
66 }
67 return s, nil
68}
69
70// mac separates the uses of the key by a prefix, so that a signature of one kind
71// can never pass for another.
72func (s *signer) mac(domain string, parts ...[]byte) []byte {
73 h := hmac.New(sha256.New, s.key[:])
74 h.Write([]byte(domain))
75 for _, p := range parts {
76 h.Write([]byte{0})
77 h.Write(p)
78 }
79 return h.Sum(nil)
80}
81
82var b64 = base64.RawURLEncoding
83
84// maxTokenLen bounds what verify parses.
85const maxTokenLen = 16 << 10
86
87// jwtHeader is the header of every runtime token, as base64url.
88var jwtHeader = b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
89
90// jwtKey is the HS256 key of the tokens: derived from the signer's key under
91// the "token" domain, so that no other signature of the signer (archive or
92// blob URLs) can ever pass for a token, and the other way round.
93func (s *signer) jwtKey() []byte { return s.mac("token") }
94
95// token is a three-segment JWT (header, claims, HS256 signature), which is what
96// @actions/artifact requires of ACTIONS_RUNTIME_TOKEN; @actions/cache sends it
97// as an opaque bearer token.
98func (s *signer) token(c Claims) string {
99 payload, _ := json.Marshal(c) // plain strings and ints: cannot fail
100 signed := jwtHeader + "." + b64.EncodeToString(payload)
101 h := hmac.New(sha256.New, s.jwtKey())
102 h.Write([]byte(signed))
103 return signed + "." + b64.EncodeToString(h.Sum(nil))
104}
105
106// verifyAny returns the claims of a token that this runner made and that has
107// not expired, whatever its scopes. The comparison is constant time.
108func (s *signer) verifyAny(tok string, now time.Time) (Claims, error) {
109 var c Claims
110 if len(tok) > maxTokenLen {
111 return c, errForbidden
112 }
113 head, rest, ok1 := strings.Cut(tok, ".")
114 p, m, ok2 := strings.Cut(rest, ".")
115 if !ok1 || !ok2 || head != jwtHeader {
116 return c, errForbidden
117 }
118 sum, err := b64.DecodeString(m)
119 if err != nil {
120 return c, errForbidden
121 }
122 h := hmac.New(sha256.New, s.jwtKey())
123 h.Write([]byte(head + "." + p))
124 if !hmac.Equal(sum, h.Sum(nil)) {
125 return c, errForbidden
126 }
127 payload, err := b64.DecodeString(p)
128 if err != nil || json.Unmarshal(payload, &c) != nil || c.JobID == "" || now.Unix() >= c.Expiry {
129 return Claims{}, errForbidden
130 }
131 return c, nil
132}
133
134// verify is verifyAny for the cache: the token must carry a cache scope (a
135// token made for the results service only has none).
136func (s *signer) verify(tok string, now time.Time) (Claims, error) {
137 c, err := s.verifyAny(tok, now)
138 if err != nil || c.Repository == "" {
139 return Claims{}, errForbidden
140 }
141 return c, nil
142}
143
144// archiveSig signs the download URL of one entry. The archive is fetched by a
145// client without the bearer token (downloadUtils.ts:168 uses a bare HttpClient),
146// so the URL is the credential; it is bound to the entry, its repository, the
147// job it was issued to and an expiry. The job is what keeps a URL that left its
148// sandbox useless on the channel of any other.
149func (s *signer) archiveSig(id int64, repo, job string, exp int64) string {
150 return b64.EncodeToString(s.mac("archive", []byte(strconv.FormatInt(id, 10)), []byte(repo), []byte(job), []byte(strconv.FormatInt(exp, 10))))
151}
152
153func (s *signer) archiveOK(id int64, repo, job string, exp int64, sig string, now time.Time) bool {
154 got, err := b64.DecodeString(sig)
155 if err != nil || now.Unix() >= exp {
156 return false
157 }
158 want, _ := b64.DecodeString(s.archiveSig(id, repo, job, exp))
159 return hmac.Equal(got, want)
160}