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. With cfg.Sealer nil
78// it returns a disabled service: no ID tokens are issued. 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 &OIDC{}, 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 if !o.Enabled() {
184 if !p.HasOwnerRights() {
185 return ports.OIDCRotation{}, domain.ErrForbidden
186 }
187 return ports.OIDCRotation{}, oidcDisabledError{}
188 }
189
190 switch {
191 case p.IsAnonymous():
192 return ports.OIDCRotation{}, domain.ErrUnauthenticated
193 case !p.HasOwnerRights():
194 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)
195 }
196 o.mu.Lock()
197 defer o.mu.Unlock()
198 now := o.cfg.Clock.Now().UTC()
199 fresh, err := o.newKey(now)
200 if err != nil {
201 return ports.OIDCRotation{}, err
202 }
203 out := ports.OIDCRotation{Issuer: o.issuer, Kid: fresh.stored.ID}
204 put := []domain.OIDCKey{fresh.stored}
205 var drop []string
206 keys := make([]oidcKey, 0, len(o.keys)+1)
207 for _, k := range o.keys {
208 if k.stored.RetiredAt.IsZero() {
209 k.stored.RetiredAt = now
210 put = append(put, k.stored)
211 out.RetiredKid, out.RetiredUntil = k.stored.ID, now.Add(domain.OIDCKeyRetention)
212 }
213 if !k.stored.Published(now) {
214 drop = append(drop, k.stored.ID)
215 continue
216 }
217 keys = append(keys, k)
218 }
219 if err := o.cfg.Store.SaveOIDCKeys(ctx, put, drop); err != nil {
220 return ports.OIDCRotation{}, err
221 }
222 o.keys = append(keys, fresh)
223 log := zerowrap.FromCtx(ctx)
224 log.Info().Str("kid", fresh.stored.ID).Str("retired_kid", out.RetiredKid).Str("by", p.Label()).Msg("oidc key rotated")
225 return out, nil
226}
227
228// jwk is a public key of the JWKS (RFC 7517).
229type jwk struct {
230 Kty string `json:"kty"`
231 Alg string `json:"alg"`
232 Use string `json:"use"`
233 Kid string `json:"kid"`
234 N string `json:"n"`
235 E string `json:"e"`
236}
237
238func publicJWK(kid string, pub *rsa.PublicKey) jwk {
239 return jwk{Kty: "RSA", Alg: "RS256", Use: "sig", Kid: kid, N: b64(pub.N.Bytes()), E: b64(big.NewInt(int64(pub.E)).Bytes())}
240}
241
242// JWKS returns the JSON of the published keys.
243func (o *OIDC) JWKS() ([]byte, error) {
244 if !o.Enabled() {
245 return nil, domain.ErrNotFound
246 }
247 now := o.cfg.Clock.Now().UTC()
248 o.mu.RLock()
249 defer o.mu.RUnlock()
250 keys := make([]jwk, 0, len(o.keys))
251 for _, k := range o.keys {
252 if k.stored.Published(now) {
253 keys = append(keys, publicJWK(k.stored.ID, &k.priv.PublicKey))
254 }
255 }
256 return json.Marshal(map[string][]jwk{"keys": keys})
257}
258
259// oidcClaims are the claims a job's token may hold, as listed by the discovery
260// document (GitHub's, without those of features ermite lacks).
261var oidcClaims = []string{
262 "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner",
263 "repository_owner_id", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow", "workflow_ref",
264 "workflow_sha", "head_ref", "base_ref", "event_name", "ref_type", "ref_protected", "environment",
265 "job_workflow_ref", "job_workflow_sha", "repository_visibility", "runner_environment", "check_run_id",
266}
267
268// Discovery returns the JSON of the OpenID Provider configuration, in the
269// shape of GitHub's.
270func (o *OIDC) Discovery() ([]byte, error) {
271 if !o.Enabled() {
272 return nil, domain.ErrNotFound
273 }
274 return json.Marshal(ordered{
275 {"issuer", o.issuer},
276 {"jwks_uri", o.issuer + "/.well-known/jwks"},
277 {"subject_types_supported", []string{"public", "pairwise"}},
278 {"response_types_supported", []string{"id_token"}},
279 {"claims_supported", oidcClaims},
280 {"id_token_signing_alg_values_supported", []string{"RS256"}},
281 {"scopes_supported", []string{"openid"}},
282 })
283}
284
285// sign returns the compact JWS of claims, signed RS256 by the signing key.
286func (o *OIDC) sign(claims ordered) (string, error) {
287 o.mu.RLock()
288 k, ok := o.signing()
289 o.mu.RUnlock()
290 if !ok {
291 return "", errors.New("no oidc signing key")
292 }
293 header, err := json.Marshal(ordered{{"typ", "JWT"}, {"alg", "RS256"}, {"kid", k.stored.ID}})
294 if err != nil {
295 return "", err
296 }
297 payload, err := json.Marshal(claims)
298 if err != nil {
299 return "", err
300 }
301 input := b64(header) + "." + b64(payload)
302 sum := sha256.Sum256([]byte(input))
303 sig, err := rsa.SignPKCS1v15(rand.Reader, k.priv, crypto.SHA256, sum[:])
304 if err != nil {
305 return "", fmt.Errorf("sign the id token: %w", err)
306 }
307 return input + "." + b64(sig), nil
308}
309
310// thumbprint is the RFC 7638 thumbprint of an RSA public key: base64url of the
311// SHA-256 of {"e":…,"kty":"RSA","n":…} (members in lexical order, no spaces).
312func thumbprint(pub *rsa.PublicKey) string {
313 e := b64(big.NewInt(int64(pub.E)).Bytes())
314 sum := sha256.Sum256([]byte(`{"e":"` + e + `","kty":"RSA","n":"` + b64(pub.N.Bytes()) + `"}`))
315 return b64(sum[:])
316}
317
318func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
319
320// Enabled reports whether the provider issues ID tokens. A nil provider is disabled.
321func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil }
322
323// oidcDisabledError preserves the public disabled-provider message and precondition code.
324type oidcDisabledError struct{}
325
326func (oidcDisabledError) Error() string {
327 return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"
328}
329func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed }