v0.3.0 8f98fd3

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 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}

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