v0.26.0 df1db36

oidc.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"crypto"
8	"crypto/rand"
9	"crypto/rsa"
10	"crypto/sha256"
11	"crypto/x509"
12	"encoding/base64"
13	"encoding/json"
14	"errors"
15	"fmt"
16	"math/big"
17	"sync"
18	"time"
19
20	"github.com/bnema/zerowrap"
21
22	"bnema.dev/ermite/internal/domain"
23	"bnema.dev/ermite/internal/ports"
24)
25
26// The OIDC provider of CI jobs (CI-4, D5). The server signs GitHub-shaped ID
27// tokens (RS256) with a key of its own, published with the discovery document
28// and the JWKS under <public URL>/-/oidc, the issuer. The key is generated at
29// the first start, stored sealed with ERMITE_SECRETS_KEY (without a secrets key
30// there is no OIDC at all) and replaced by RotateKey; a replaced key stays in
31// the JWKS for domain.OIDCKeyRetention so that the tokens it signed verify
32// until they expire.
33
34// OIDCPath is where the provider is served under the public URL; the issuer
35// is the public URL followed by it.
36const OIDCPath = "/-/oidc"
37
38// OIDCKeyBits is the size of the RSA signing keys.
39const OIDCKeyBits = 3072
40
41// OIDCConfig holds what the OIDC provider needs. Store, Tokens, Clock and
42// PublicURL are required; Sealer nil disables OIDC.
43type OIDCConfig struct {
44	Store ports.OIDCKeyStore
45	// Sealer seals the private keys (ERMITE_SECRETS_KEY). Leave it unset
46	// rather than assigning a typed nil pointer.
47	Sealer ports.SecretSealer
48	// Tokens finds the request tokens of the jobs.
49	Tokens    ports.CIRepoTokenStore
50	Clock     ports.Clock
51	PublicURL string
52	// NewKey generates a signing key; nil generates an RSA key of OIDCKeyBits.
53	// Tests give prepared keys.
54	NewKey func() (*rsa.PrivateKey, error)
55}
56
57// OIDC is the key management of the provider and the signature of the tokens.
58// It is safe for concurrent use.
59type OIDC struct {
60	cfg    OIDCConfig
61	issuer string
62
63	mu sync.RWMutex
64	// keys are the published keys, oldest first; the last one that is not
65	// retired signs.
66	keys []oidcKey
67}
68
69type oidcKey struct {
70	stored domain.OIDCKey
71	priv   *rsa.PrivateKey
72}
73
74var _ ports.OIDCKeyService = (*OIDC)(nil)
75
76// NewOIDC loads the signing keys, generates the first one when there is none,
77// and forgets the retired keys whose publication is over. It returns nil
78// without error when cfg.Sealer is nil: the server issues no ID tokens. A
79// stored key that does not open with the secrets key is an error: the server
80// must not start (the secrets key check already refuses another key, so this
81// is a damaged record).
82func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
83	log := zerowrap.FromCtx(ctx)
84	if cfg.Sealer == nil {
85		log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86		return nil, nil
87	}
88	if cfg.NewKey == nil {
89		cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
90	}
91	o := &OIDC{cfg: cfg, issuer: cfg.PublicURL + OIDCPath}
92	stored, err := cfg.Store.ListOIDCKeys(ctx)
93	if err != nil {
94		return nil, err
95	}
96	now := cfg.Clock.Now().UTC()
97	var drop []string
98	for _, k := range stored {
99		if !k.Published(now) {
100			drop = append(drop, k.ID)
101			continue
102		}
103		priv, err := o.open(k)
104		if err != nil {
105			return nil, err
106		}
107		o.keys = append(o.keys, oidcKey{stored: k, priv: priv})
108	}
109	var put []domain.OIDCKey
110	if _, ok := o.signing(); !ok {
111		k, err := o.newKey(now)
112		if err != nil {
113			return nil, err
114		}
115		o.keys = append(o.keys, k)
116		put = append(put, k.stored)
117	}
118	if len(put) > 0 || len(drop) > 0 {
119		if err := cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
120			return nil, err
121		}
122	}
123	k, _ := o.signing()
124	log.Info().Str("issuer", o.issuer).Str("kid", k.stored.ID).Msg("oidc provider ready")
125	return o, nil
126}
127
128// Issuer is the iss of the tokens.
129func (o *OIDC) Issuer() string { return o.issuer }
130
131// TokenURL is the token endpoint as a job gets it (Job.id_token_url): it has a
132// query already, so clients append "&audience=".
133func (o *OIDC) TokenURL() string { return o.issuer + "/token?api-version=2.0" }
134
135// open unseals and parses a stored key, and checks that it is the key its id
136// names.
137func (o *OIDC) open(k domain.OIDCKey) (*rsa.PrivateKey, error) {
138	der, err := o.cfg.Sealer.Open(k.Sealed, []byte(domain.OIDCKeyPath(k.ID)))
139	if err != nil {
140		return nil, fmt.Errorf("open the oidc key %s: %w", k.ID, err)
141	}
142	parsed, err := x509.ParsePKCS8PrivateKey(der)
143	if err != nil {
144		return nil, fmt.Errorf("open the oidc key %s: %w", k.ID, err)
145	}
146	priv, ok := parsed.(*rsa.PrivateKey)
147	if !ok || thumbprint(&priv.PublicKey) != k.ID {
148		return nil, fmt.Errorf("open the oidc key %s: not the key of that id", k.ID)
149	}
150	return priv, nil
151}
152
153// newKey generates a key and seals it under its path.
154func (o *OIDC) newKey(now time.Time) (oidcKey, error) {
155	priv, err := o.cfg.NewKey()
156	if err != nil {
157		return oidcKey{}, fmt.Errorf("generate the oidc key: %w", err)
158	}
159	der, err := x509.MarshalPKCS8PrivateKey(priv)
160	if err != nil {
161		return oidcKey{}, fmt.Errorf("generate the oidc key: %w", err)
162	}
163	kid := thumbprint(&priv.PublicKey)
164	sealed := o.cfg.Sealer.Seal(der, []byte(domain.OIDCKeyPath(kid)))
165	clear(der)
166	return oidcKey{stored: domain.OIDCKey{ID: kid, Sealed: sealed, CreatedAt: now}, priv: priv}, nil
167}
168
169// signing returns the key that signs: the newest that is not retired.
170func (o *OIDC) signing() (oidcKey, bool) {
171	for i := len(o.keys) - 1; i >= 0; i-- {
172		if o.keys[i].stored.RetiredAt.IsZero() {
173			return o.keys[i], true
174		}
175	}
176	return oidcKey{}, false
177}
178
179// RotateKey replaces the signing key by a new one. The old one signs no more
180// and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181// publication is over are deleted with it. Only the owner may rotate.
182func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
183	switch {
184	case p.IsAnonymous():
185		return ports.OIDCRotation{}, domain.ErrUnauthenticated
186	case !p.HasOwnerRights():
187		return ports.OIDCRotation{}, fmt.Errorf("%w: rotating the oidc key needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
188	}
189	o.mu.Lock()
190	defer o.mu.Unlock()
191	now := o.cfg.Clock.Now().UTC()
192	fresh, err := o.newKey(now)
193	if err != nil {
194		return ports.OIDCRotation{}, err
195	}
196	out := ports.OIDCRotation{Issuer: o.issuer, Kid: fresh.stored.ID}
197	put := []domain.OIDCKey{fresh.stored}
198	var drop []string
199	keys := make([]oidcKey, 0, len(o.keys)+1)
200	for _, k := range o.keys {
201		if k.stored.RetiredAt.IsZero() {
202			k.stored.RetiredAt = now
203			put = append(put, k.stored)
204			out.RetiredKid, out.RetiredUntil = k.stored.ID, now.Add(domain.OIDCKeyRetention)
205		}
206		if !k.stored.Published(now) {
207			drop = append(drop, k.stored.ID)
208			continue
209		}
210		keys = append(keys, k)
211	}
212	if err := o.cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
213		return ports.OIDCRotation{}, err
214	}
215	o.keys = append(keys, fresh)
216	log := zerowrap.FromCtx(ctx)
217	log.Info().Str("kid", fresh.stored.ID).Str("retired_kid", out.RetiredKid).Str("by", p.Label()).Msg("oidc key rotated")
218	return out, nil
219}
220
221// jwk is a public key of the JWKS (RFC 7517).
222type jwk struct {
223	Kty string `json:"kty"`
224	Alg string `json:"alg"`
225	Use string `json:"use"`
226	Kid string `json:"kid"`
227	N   string `json:"n"`
228	E   string `json:"e"`
229}
230
231func publicJWK(kid string, pub *rsa.PublicKey) jwk {
232	return jwk{Kty: "RSA", Alg: "RS256", Use: "sig", Kid: kid, N: b64(pub.N.Bytes()), E: b64(big.NewInt(int64(pub.E)).Bytes())}
233}
234
235// JWKS returns the JSON of the published keys.
236func (o *OIDC) JWKS() ([]byte, error) {
237	now := o.cfg.Clock.Now().UTC()
238	o.mu.RLock()
239	defer o.mu.RUnlock()
240	keys := make([]jwk, 0, len(o.keys))
241	for _, k := range o.keys {
242		if k.stored.Published(now) {
243			keys = append(keys, publicJWK(k.stored.ID, &k.priv.PublicKey))
244		}
245	}
246	return json.Marshal(map[string][]jwk{"keys": keys})
247}
248
249// oidcClaims are the claims a job's token may hold, as listed by the discovery
250// document (GitHub's, without those of features ermite lacks).
251var oidcClaims = []string{
252	"sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
253	"repository_owner_id", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow", "workflow_ref",
254	"workflow_sha", "head_ref", "base_ref", "event_name", "ref_type", "ref_protected", "environment",
255	"job_workflow_ref", "job_workflow_sha", "repository_visibility", "runner_environment", "check_run_id",
256}
257
258// Discovery returns the JSON of the OpenID Provider configuration, in the
259// shape of GitHub's.
260func (o *OIDC) Discovery() ([]byte, error) {
261	return json.Marshal(ordered{
262		{"issuer", o.issuer},
263		{"jwks_uri", o.issuer + "/.well-known/jwks"},
264		{"subject_types_supported", []string{"public", "pairwise"}},
265		{"response_types_supported", []string{"id_token"}},
266		{"claims_supported", oidcClaims},
267		{"id_token_signing_alg_values_supported", []string{"RS256"}},
268		{"scopes_supported", []string{"openid"}},
269	})
270}
271
272// sign returns the compact JWS of claims, signed RS256 by the signing key.
273func (o *OIDC) sign(claims ordered) (string, error) {
274	o.mu.RLock()
275	k, ok := o.signing()
276	o.mu.RUnlock()
277	if !ok {
278		return "", errors.New("no oidc signing key")
279	}
280	header, err := json.Marshal(ordered{{"typ", "JWT"}, {"alg", "RS256"}, {"kid", k.stored.ID}})
281	if err != nil {
282		return "", err
283	}
284	payload, err := json.Marshal(claims)
285	if err != nil {
286		return "", err
287	}
288	input := b64(header) + "." + b64(payload)
289	sum := sha256.Sum256([]byte(input))
290	sig, err := rsa.SignPKCS1v15(rand.Reader, k.priv, crypto.SHA256, sum[:])
291	if err != nil {
292		return "", fmt.Errorf("sign the id token: %w", err)
293	}
294	return input + "." + b64(sig), nil
295}
296
297// thumbprint is the RFC 7638 thumbprint of an RSA public key: base64url of the
298// SHA-256 of {"e":…,"kty":"RSA","n":…} (members in lexical order, no spaces).
299func thumbprint(pub *rsa.PublicKey) string {
300	e := b64(big.NewInt(int64(pub.E)).Bytes())
301	sum := sha256.Sum256([]byte(`{"e":"` + e + `","kty":"RSA","n":"` + b64(pub.N.Bytes()) + `"}`))
302	return b64(sum[:])
303}
304
305func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }

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 3

Tags 44

↑↓ moveenter openesc close