token.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close