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