Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324// Package oidc issues the per-run ID tokens CI runs exchange with GCP workload// identity federation, and serves the metadata that lets a relying party trust// them.package oidc
import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/asn1" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "math/big" "net/http" "net/url" "os" "strings" "time"
"github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/spindle/models")
// the only two endpoints an issuer servesconst ( ConfigurationPath = "/.well-known/openid-configuration" JWKSPath = "/jwks")
const minimumTokenLifetime = 15 * time.Minute
func tokenLifetime(workflowTimeBudget time.Duration) time.Duration { return max(minimumTokenLifetime, models.WorkflowTimeBudget(workflowTimeBudget))}
// the documents only change when the process restarts with a new key, but a// relying party should not cache them past a rotationconst cacheMaxAge = "public, max-age=300"
// the longest claim values the issuer will signconst ( maxShaLength = 128 maxRefLength = 512)
type Options struct { Issuer string // the iss claim; the endpoints are served from this origin KeyPath string // PEM file holding an EC P-256 private key KeyID string // defaults to the RFC 7638 thumbprint of the key}
// ErrUnrecordableRun marks an identity the server cannot stand behind, as// opposed to a key or signing failure. Callers withhold the token; they do not// fail the run.var ErrUnrecordableRun = errors.New("run identity cannot be signed")
// Run is the server-recorded identity of one CI run. Every claim is projected// from the run's own record, never from the workflow definition it executes.type Run struct { Repository string // Ref is empty when the server did not resolve it: a manual or pull request // ref never left the client, so it is not signed Ref string SHA string Actor string}
// validate rejects a run identity that could sign a claim the server cannot// stand behind. A ref carries no colon, so subject joins stay unambiguous.func (run Run) validate() error { if _, err := syntax.ParseDID(run.Repository); err != nil { return fmt.Errorf("%w: repository %q is not a DID: %w", ErrUnrecordableRun, run.Repository, err) } if run.SHA == "" || len(run.SHA) > maxShaLength { return fmt.Errorf("%w: sha %q is not a commit", ErrUnrecordableRun, run.SHA) } if run.Ref != "" && (!strings.HasPrefix(run.Ref, "refs/") || strings.Contains(run.Ref, ":") || len(run.Ref) > maxRefLength) { return fmt.Errorf("%w: ref %q is not a git refname", ErrUnrecordableRun, run.Ref) } if run.Actor != "" { if _, err := syntax.ParseDID(run.Actor); err != nil { return fmt.Errorf("%w: actor %q is not a DID: %w", ErrUnrecordableRun, run.Actor, err) } } return nil}
type Issuer struct { issuer string keyID string key *ecdsa.PrivateKey jwks []byte document []byte}
// New reads the signing key and precomputes the discovery document and JWK set.func New(opts Options) (*Issuer, error) { issuer := strings.TrimRight(opts.Issuer, "/") parsed, err := url.Parse(issuer) switch { case err != nil || parsed.Host == "" || parsed.Scheme != "https": return nil, fmt.Errorf("OIDC issuer %q is not an absolute https URL", opts.Issuer) case parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "": return nil, fmt.Errorf("OIDC issuer %q must carry no path, query, or fragment: the endpoints are served from its origin", opts.Issuer) }
key, err := loadPrivateKey(opts.KeyPath) if err != nil { return nil, err }
public := publicJWK(key) keyID := opts.KeyID if keyID == "" { keyID = thumbprint(public) } public.KeyID = keyID
i := &Issuer{issuer: issuer, keyID: keyID, key: key} // a list, so a key rotation can publish a second key alongside this one i.jwks, err = json.Marshal(keySet{Keys: []jsonWebKey{public}}) if err != nil { return nil, fmt.Errorf("encoding JWK set: %w", err) } i.document, err = json.Marshal(configuration{ Issuer: issuer, JWKSURI: issuer + JWKSPath, IDTokenSigningAlgValuesSupported: []string{"ES256"}, }) if err != nil { return nil, fmt.Errorf("encoding discovery document: %w", err) } return i, nil}
// KeyID is the kid a minted token carries.func (i *Issuer) KeyID() string { return i.keyID }
// Mint returns one ES256 ID token for run, addressed to the given audience.// its lifetime covers workflowTimeBudget with a 15-minute floor, and it// refuses to sign a run identity the server could not have recorded.func (i *Issuer) Mint(run Run, workflowTimeBudget time.Duration, audience string) (string, error) { if err := run.validate(); err != nil { return "", err } if audience == "" { return "", errors.New("minting an id token without an audience") } for _, r := range audience { if r < 0x21 || r > 0x7e { // signed verbatim, defense in depth even if callers validate return "", fmt.Errorf("id token audience %q is not printable ASCII", audience) } }
nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { return "", fmt.Errorf("generating token nonce: %w", err) }
now := time.Now() issuedAt := now.Unix() payload, err := json.Marshal(claims{ Issuer: i.issuer, Audience: audience, Subject: run.Repository + ":" + run.Ref, IssuedAt: issuedAt, NotBefore: issuedAt, ExpiresAt: now.Add(tokenLifetime(workflowTimeBudget)).Unix(), JTI: hex.EncodeToString(nonce), Repository: run.Repository, Ref: run.Ref, Actor: run.Actor, SHA: run.SHA, }) if err != nil { return "", fmt.Errorf("encoding ID token claims: %w", err) } head, err := json.Marshal(tokenHeader{Algorithm: "ES256", KeyID: i.keyID, Type: "JWT"}) if err != nil { return "", fmt.Errorf("encoding ID token header: %w", err) }
signingInput := encode(head) + "." + encode(payload) digest := sha256.Sum256([]byte(signingInput)) der, err := ecdsa.SignASN1(rand.Reader, i.key, digest[:]) if err != nil { return "", fmt.Errorf("signing ID token: %w", err) } signature, err := joseSignature(der) if err != nil { return "", err } return signingInput + "." + encode(signature), nil}
func (i *Issuer) ServeConfiguration(w http.ResponseWriter, _ *http.Request) { writeJSON(w, i.document)}
func (i *Issuer) ServeJWKS(w http.ResponseWriter, _ *http.Request) { writeJSON(w, i.jwks)}
type claims struct { Issuer string `json:"iss"` Audience string `json:"aud"` Subject string `json:"sub"` IssuedAt int64 `json:"iat"` NotBefore int64 `json:"nbf"` ExpiresAt int64 `json:"exp"` JTI string `json:"jti"` Repository string `json:"repository"` Ref string `json:"ref"` Actor string `json:"actor"` SHA string `json:"sha"`}
type tokenHeader struct { Algorithm string `json:"alg"` KeyID string `json:"kid"` Type string `json:"typ"`}
type configuration struct { Issuer string `json:"issuer"` JWKSURI string `json:"jwks_uri"` IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`}
type keySet struct { Keys []jsonWebKey `json:"keys"`}
type jsonWebKey struct { KeyType string `json:"kty"` Curve string `json:"crv"` X string `json:"x"` Y string `json:"y"` Use string `json:"use"` Alg string `json:"alg"` KeyID string `json:"kid"`}
func publicJWK(key *ecdsa.PrivateKey) jsonWebKey { return jsonWebKey{ KeyType: "EC", Curve: key.Curve.Params().Name, X: coordinate(key.PublicKey.X), Y: coordinate(key.PublicKey.Y), Use: "sig", Alg: "ES256", }}
// coordinates are fixed width, as RFC 7518 requires for a curve of known sizefunc coordinate(v *big.Int) string { return encode(v.FillBytes(make([]byte, 32)))}
// thumbprint is the RFC 7638 JWK thumbprint: SHA-256 over the required members,// in lexicographic order, with no whitespace.func thumbprint(key jsonWebKey) string { canonical := fmt.Sprintf(`{"crv":%q,"kty":%q,"x":%q,"y":%q}`, key.Curve, key.KeyType, key.X, key.Y) sum := sha256.Sum256([]byte(canonical)) return encode(sum[:])}
// JWS carries ECDSA signatures as fixed-width r||s, not as the ASN.1 the// standard library speaks.func joseSignature(der []byte) ([]byte, error) { var sig struct{ R, S *big.Int } if _, err := asn1.Unmarshal(der, &sig); err != nil { return nil, fmt.Errorf("decoding ECDSA signature: %w", err) } out := make([]byte, 64) sig.R.FillBytes(out[:32]) sig.S.FillBytes(out[32:]) return out, nil}
func loadPrivateKey(path string) (*ecdsa.PrivateKey, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading OIDC signing key: %w", err) } block, _ := pem.Decode(data) if block == nil { return nil, fmt.Errorf("OIDC signing key %s holds no PEM block", path) }
key, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { parsed, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) if pkcs8Err != nil { return nil, fmt.Errorf("parsing OIDC signing key %s: %w", path, err) } var ok bool if key, ok = parsed.(*ecdsa.PrivateKey); !ok { return nil, fmt.Errorf("OIDC signing key %s is not an EC key", path) } } if key.Curve != elliptic.P256() { return nil, fmt.Errorf("OIDC signing key %s is on %s, want P-256", path, key.Curve.Params().Name) } return key, nil}
func encode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b)}
func writeJSON(w http.ResponseWriter, body []byte) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", cacheMaxAge) _, _ = w.Write(body)}