From 3d0c4800a666a902df84537910da670f53d1ae9b Mon Sep 17 00:00:00 2001 From: Lee Date: Thu, 2 Apr 2026 10:41:30 -0700 Subject: [PATCH 1/3] Initial work on supporting Signature-Key header --- sigkeydraft/fetcher.go | 159 ++++++++++++++++ sigkeydraft/fetcher_test.go | 302 +++++++++++++++++++++++++++++++ sigkeydraft/oidc.go | 221 ++++++++++++++++++++++ sigkeydraft/oidc_test.go | 300 ++++++++++++++++++++++++++++++ sigkeydraft/sigkey.go | 239 ++++++++++++++++++++++++ sigkeydraft/sigkey_test.go | 253 ++++++++++++++++++++++++++ sigkeydraft/signaturekey_test.go | 69 +++++++ sign.go | 107 ++++++++--- verify.go | 4 + 9 files changed, 1624 insertions(+), 30 deletions(-) create mode 100644 sigkeydraft/fetcher.go create mode 100644 sigkeydraft/fetcher_test.go create mode 100644 sigkeydraft/oidc.go create mode 100644 sigkeydraft/oidc_test.go create mode 100644 sigkeydraft/sigkey.go create mode 100644 sigkeydraft/sigkey_test.go create mode 100644 sigkeydraft/signaturekey_test.go diff --git a/sigkeydraft/fetcher.go b/sigkeydraft/fetcher.go new file mode 100644 index 0000000..9ac0425 --- /dev/null +++ b/sigkeydraft/fetcher.go @@ -0,0 +1,159 @@ +package sigkeydraft + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "fmt" + "net/http" + + "github.com/remitly-oss/httpsig-go/key" + "github.com/remitly-oss/httpsig-go/keyutil" + "github.com/remitly-oss/httpsig-go/types" +) + +// SignatureKeyFetcher implements key.KeyFetcher by resolving keys from the +// Signature-Key header using draft-hardt-httpbis-signature-key-02. +// +// Only the jwt scheme is supported. For each verified signature label the +// fetcher validates the JWT via OIDCIssuerVerifier, extracts the public key +// from the cnf.jwk claim, and returns a KeySpec whose Identity field is +// populated from the JWT iss and sub claims. +type SignatureKeyFetcher struct { + label string + issuerVerifier *OIDCIssuerVerifier +} + +// NewSignatureKeyFetcher creates a SignatureKeyFetcher for the given signature +// label. issuerVerifier verifies the JWT and fetches the issuer's JWKS. +func NewSignatureKeyFetcher(label string, issuerVerifier *OIDCIssuerVerifier) *SignatureKeyFetcher { + return &SignatureKeyFetcher{ + label: label, + issuerVerifier: issuerVerifier, + } +} + +// FetchByKeyID implements key.KeyFetcher. The keyID from the signature +// metadata is ignored because the key material is carried in the +// Signature-Key header. +func (f *SignatureKeyFetcher) FetchByKeyID(ctx context.Context, headers http.Header, _ string) (key.KeySpecer, error) { + return f.fetchFromHeader(ctx, headers) +} + +// Fetch implements key.KeyFetcher. +func (f *SignatureKeyFetcher) Fetch(ctx context.Context, headers http.Header, _ types.MetadataProvider) (key.KeySpecer, error) { + return f.fetchFromHeader(ctx, headers) +} + +func (f *SignatureKeyFetcher) fetchFromHeader(ctx context.Context, headers http.Header) (key.KeySpecer, error) { + headerValue := headers.Get(Header) + if headerValue == "" { + return nil, fmt.Errorf("sigkey: Signature-Key header is missing or empty (label %q)", f.label) + } + + entries, err := ParseHeader(headerValue) + if err != nil { + return nil, fmt.Errorf("sigkey: failed to parse Signature-Key header: %w", err) + } + + entry, ok := entries[f.label] + if !ok { + return nil, fmt.Errorf("sigkey: Signature-Key header has no entry for label %q", f.label) + } + + if entry.Scheme != SchemeJWT { + return nil, fmt.Errorf("sigkey: scheme %q for label %q is not supported; only %q is supported", entry.Scheme, f.label, SchemeJWT) + } + + return f.resolveJWT(ctx, entry) +} + +// resolveJWT validates the JWT, extracts cnf.jwk, and builds a KeySpec. +func (f *SignatureKeyFetcher) resolveJWT(ctx context.Context, entry SigKeyHeader) (key.KeySpec, error) { + compactJWT, err := entry.JWT() + if err != nil { + return key.KeySpec{}, fmt.Errorf("sigkey: %w", err) + } + + claims, err := f.issuerVerifier.VerifyJWT(ctx, compactJWT) + if err != nil { + return key.KeySpec{}, fmt.Errorf("sigkey: JWT validation failed: %w", err) + } + + pubKey, algo, err := extractCNFKey(claims) + if err != nil { + return key.KeySpec{}, err + } + + iss, _ := claims["iss"].(string) + sub, _ := claims["sub"].(string) + + return key.KeySpec{ + Algo: algo, + PubKey: pubKey, + Identity: key.KeyIdentity{ + Identity: sub, + Issuer: iss, + IssuerType: key.IssuerIDP, + }, + }, nil +} + +// extractCNFKey extracts the public key and algorithm from the cnf.jwk claim. +func extractCNFKey(claims map[string]any) (pubKey any, algo types.Algorithm, err error) { + cnf, ok := claims["cnf"].(map[string]any) + if !ok { + return nil, "", fmt.Errorf("sigkey: JWT is missing required 'cnf' claim") + } + + jwkRaw, ok := cnf["jwk"] + if !ok { + return nil, "", fmt.Errorf("sigkey: JWT cnf claim is missing required 'jwk' member") + } + + // Re-marshal to JSON so we can use keyutil.ReadJWK for parsing. + jwkJSON, err := json.Marshal(jwkRaw) + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to marshal cnf.jwk to JSON: %w", err) + } + + jwk, err := keyutil.ReadJWK(jwkJSON) + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to parse cnf.jwk: %w", err) + } + + algo, err = algoFromJWK(jwk) + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to determine algorithm from cnf.jwk: %w", err) + } + + pk, err := jwk.PublicKey() + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to extract public key from cnf.jwk: %w", err) + } + + return pk, algo, nil +} + +// algoFromJWK infers the Algorithm from the public key extracted from the JWK. +// The spec requires that 'alg' MUST NOT be present in the JWK, so we derive +// the algorithm from the concrete key type and curve. +func algoFromJWK(j keyutil.JWK) (types.Algorithm, error) { + pub, err := j.PublicKey() + if err != nil { + return "", fmt.Errorf("sigkey: cannot extract public key from JWK: %w", err) + } + switch key := pub.(type) { + case *ecdsa.PublicKey: + switch key.Curve.Params().Name { + case "P-256": + return types.Algo_ECDSA_P256_SHA256, nil + case "P-384": + return types.Algo_ECDSA_P384_SHA384, nil + default: + return "", fmt.Errorf("sigkey: unsupported EC curve %q", key.Curve.Params().Name) + } + default: + return "", fmt.Errorf("sigkey: unsupported JWK key type %T", pub) + } +} diff --git a/sigkeydraft/fetcher_test.go b/sigkeydraft/fetcher_test.go new file mode 100644 index 0000000..8c732a9 --- /dev/null +++ b/sigkeydraft/fetcher_test.go @@ -0,0 +1,302 @@ +package sigkeydraft_test + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/remitly-oss/httpsig-go/key" + sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" + "github.com/remitly-oss/httpsig-go/types" +) + +// sigkeyServer is a test OIDC-like server that serves a JWKS and lets tests +// build signed JWTs against its key. +type sigkeyServer struct { + priv *ecdsa.PrivateKey + server *httptest.Server +} + +func newSigkeyServer(t *testing.T) *sigkeyServer { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + s := &sigkeyServer{priv: priv} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/jwks.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(s.jwksJSON(t)) + })) + t.Cleanup(s.server.Close) + return s +} + +func (s *sigkeyServer) issuer() string { return s.server.URL } + +func (s *sigkeyServer) jwksJSON(t *testing.T) []byte { + t.Helper() + pub := &s.priv.PublicKey + xPadded := padTo(pub.X.Bytes(), 32) + yPadded := padTo(pub.Y.Bytes(), 32) + type jwkJSON struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } + b, err := json.Marshal(struct { + Keys []jwkJSON `json:"keys"` + }{Keys: []jwkJSON{{ + Kty: "EC", Crv: "P-256", + X: base64.RawURLEncoding.EncodeToString(xPadded), + Y: base64.RawURLEncoding.EncodeToString(yPadded), + }}}) + if err != nil { + t.Fatal(err) + } + return b +} + +func (s *sigkeyServer) signJWT(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + signed, err := tok.SignedString(s.priv) + if err != nil { + t.Fatal(err) + } + return signed +} + +func (s *sigkeyServer) standardClaims(sub string) jwt.MapClaims { + return jwt.MapClaims{ + "iss": s.issuer(), + "sub": sub, + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } +} + +func padTo(b []byte, n int) []byte { + padded := make([]byte, n) + copy(padded[n-len(b):], b) + return padded +} + +func ecJWKMap(t *testing.T) (map[string]any, *ecdsa.PrivateKey) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := &priv.PublicKey + return map[string]any{ + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(padTo(pub.X.Bytes(), 32)), + "y": base64.RawURLEncoding.EncodeToString(padTo(pub.Y.Bytes(), 32)), + }, priv +} + +func ed25519JWKMap(t *testing.T) (map[string]any, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return map[string]any{ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pub), + }, priv +} + +func makeSignatureKeyHeader(label, compactJWT string) string { + return label + `=jwt;jwt="` + compactJWT + `"` +} + +func TestSignatureKeyFetcherJWT_EC(t *testing.T) { + srv := newSigkeyServer(t) + jwkMap, signingKey := ecJWKMap(t) + + claims := srv.standardClaims("alice") + claims["cnf"] = map[string]any{"jwk": jwkMap} + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + ks, err := fetcher.FetchByKeyID(context.Background(), headers, "some-key-id") + if err != nil { + t.Fatalf("FetchByKeyID: %v", err) + } + spec, err := ks.KeySpec() + if err != nil { + t.Fatalf("KeySpec: %v", err) + } + + if spec.Algo != types.Algo_ECDSA_P256_SHA256 { + t.Errorf("Algo: got %q, want %q", spec.Algo, types.Algo_ECDSA_P256_SHA256) + } + ecPub, ok := spec.PubKey.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("PubKey type: got %T, want *ecdsa.PublicKey", spec.PubKey) + } + if ecPub.X.Cmp(signingKey.PublicKey.X) != 0 || ecPub.Y.Cmp(signingKey.PublicKey.Y) != 0 { + t.Error("extracted public key does not match expected") + } + if spec.Identity.IssuerType != key.IssuerIDP { + t.Errorf("IssuerType: got %q, want %q", spec.Identity.IssuerType, key.IssuerIDP) + } + if spec.Identity.Issuer != srv.issuer() { + t.Errorf("Issuer: got %q, want %q", spec.Identity.Issuer, srv.issuer()) + } + if spec.Identity.Identity != "alice" { + t.Errorf("Identity (sub): got %q, want %q", spec.Identity.Identity, "alice") + } +} + +func TestSignatureKeyFetcherJWT_Ed25519(t *testing.T) { + t.Skip("Ed25519/OKP JWK parsing not yet supported by keyutil") + srv := newSigkeyServer(t) + jwkMap, signingKey := ed25519JWKMap(t) + + claims := srv.standardClaims("bob") + claims["cnf"] = map[string]any{"jwk": jwkMap} + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + ks, err := fetcher.Fetch(context.Background(), headers, nil) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + spec, err := ks.KeySpec() + if err != nil { + t.Fatalf("KeySpec: %v", err) + } + + if spec.Algo != types.Algo_ED25519 { + t.Errorf("Algo: got %q, want %q", spec.Algo, types.Algo_ED25519) + } + edPub, ok := spec.PubKey.(ed25519.PublicKey) + if !ok { + t.Fatalf("PubKey type: got %T, want ed25519.PublicKey", spec.PubKey) + } + if string(edPub) != string(signingKey.Public().(ed25519.PublicKey)) { + t.Error("extracted Ed25519 public key does not match expected") + } + if spec.Identity.Identity != "bob" { + t.Errorf("Identity (sub): got %q, want %q", spec.Identity.Identity, "bob") + } +} + +func TestSignatureKeyFetcherMissingHeader(t *testing.T) { + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{"https://idp.example.com"})) + headers := http.Header{} + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing Signature-Key header") + } +} + +func TestSignatureKeyFetcherMissingLabel(t *testing.T) { + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{"https://idp.example.com"})) + headers := http.Header{} + headers.Set("Signature-Key", `other=jwt;jwt="tok"`) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing label") + } +} + +func TestSignatureKeyFetcherUnsupportedScheme(t *testing.T) { + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{"https://idp.example.com"})) + headers := http.Header{} + headers.Set("Signature-Key", `sig1=hwk;kty="EC";crv="P-256";x="abc";y="def"`) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for unsupported scheme") + } +} + +func TestSignatureKeyFetcherJWTVerificationError(t *testing.T) { + srv := newSigkeyServer(t) + + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + claims := srv.standardClaims("alice") + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + compactJWT, err := tok.SignedString(otherPriv) + if err != nil { + t.Fatal(err) + } + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + _, err = fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for JWT verification failure") + } +} + +func TestSignatureKeyFetcherMissingCNF(t *testing.T) { + srv := newSigkeyServer(t) + + claims := srv.standardClaims("alice") + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing cnf claim") + } +} + +func TestSignatureKeyFetcherMissingCNFJWK(t *testing.T) { + srv := newSigkeyServer(t) + + claims := srv.standardClaims("alice") + claims["cnf"] = map[string]any{"kid": "some-key"} + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing cnf.jwk") + } +} diff --git a/sigkeydraft/oidc.go b/sigkeydraft/oidc.go new file mode 100644 index 0000000..0a9b1b8 --- /dev/null +++ b/sigkeydraft/oidc.go @@ -0,0 +1,221 @@ +package sigkeydraft + +import ( + "context" + "crypto" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/remitly-oss/httpsig-go/keyutil" +) + +// OIDCIssuerVerifier validates JWTs by fetching the issuer's +// JWKS from {iss}/.well-known/jwks.json, verifying the JWT signature, and +// validating standard claims (exp, iat). +// +// Only issuers in the allowedIssuers list are accepted. An empty list rejects +// all JWTs. +// +// JWKS responses are cached per issuer with a configurable TTL (default 5 +// minutes) to avoid fetching on every request. +type OIDCIssuerVerifier struct { + allowedIssuers map[string]struct{} + httpClient *http.Client + cacheTTL time.Duration + nowFunc func() time.Time + + mu sync.Mutex + cache map[string]jwksEntry // keyed by issuer URL +} + +type jwksEntry struct { + keys []jwksKey + fetchedAt time.Time +} + +// OIDCOption configures an OIDCIssuerVerifier. +type OIDCOption func(*OIDCIssuerVerifier) + +// WithHTTPClient sets the HTTP client used for JWKS fetches. +func WithHTTPClient(c *http.Client) OIDCOption { + return func(v *OIDCIssuerVerifier) { v.httpClient = c } +} + +// WithJWKSCacheTTL sets how long a fetched JWKS is cached before re-fetching. +// Default is 5 minutes. +func WithJWKSCacheTTL(d time.Duration) OIDCOption { + return func(v *OIDCIssuerVerifier) { v.cacheTTL = d } +} + +// NewOIDCIssuerVerifier creates an OIDCIssuerVerifier that accepts JWTs from +// any issuer in allowedIssuers. Pass an empty slice to reject all JWTs. +func NewOIDCIssuerVerifier(allowedIssuers []string, opts ...OIDCOption) *OIDCIssuerVerifier { + allowed := make(map[string]struct{}, len(allowedIssuers)) + for _, iss := range allowedIssuers { + allowed[iss] = struct{}{} + } + v := &OIDCIssuerVerifier{ + allowedIssuers: allowed, + httpClient: &http.Client{Timeout: 10 * time.Second}, + cacheTTL: 5 * time.Minute, + nowFunc: time.Now, + cache: make(map[string]jwksEntry), + } + for _, opt := range opts { + opt(v) + } + return v +} + +// VerifyJWT validates the JWT signature using the issuer's JWKS and returns +// the full claims map on success. +func (v *OIDCIssuerVerifier) VerifyJWT(ctx context.Context, compactJWT string) (map[string]any, error) { + // Parse without verification first to extract iss and kid. + unverified, _, err := jwt.NewParser().ParseUnverified(compactJWT, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT: %w", err) + } + + iss, err := unverified.Claims.GetIssuer() + if err != nil || iss == "" { + return nil, fmt.Errorf("JWT missing required 'iss' claim") + } + + if _, ok := v.allowedIssuers[iss]; !ok { + return nil, fmt.Errorf("JWT issuer %q is not in the allowed issuers list", iss) + } + + keys, err := v.jwksKeys(ctx, iss) + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS for issuer %q: %w", iss, err) + } + + // kid from the JWT header selects which key to try first. + kid, _ := unverified.Header["kid"].(string) + + key, err := selectKey(keys, kid) + if err != nil { + return nil, fmt.Errorf("no suitable key found in JWKS for issuer %q: %w", iss, err) + } + + claims := jwt.MapClaims{} + _, err = jwt.ParseWithClaims(compactJWT, claims, func(_ *jwt.Token) (any, error) { + return key, nil + }, jwt.WithExpirationRequired(), jwt.WithIssuedAt()) + if err != nil { + return nil, fmt.Errorf("JWT verification failed: %w", err) + } + + return map[string]any(claims), nil +} + +// jwksKeys returns cached or freshly fetched public keys for the issuer. +func (v *OIDCIssuerVerifier) jwksKeys(ctx context.Context, issuer string) ([]jwksKey, error) { + v.mu.Lock() + entry, ok := v.cache[issuer] + if ok && v.nowFunc().Sub(entry.fetchedAt) < v.cacheTTL { + v.mu.Unlock() + return entry.keys, nil + } + v.mu.Unlock() + + keys, err := v.fetchJWKS(ctx, issuer) + if err != nil { + return nil, err + } + + v.mu.Lock() + v.cache[issuer] = jwksEntry{keys: keys, fetchedAt: v.nowFunc()} + v.mu.Unlock() + + return keys, nil +} + +// fetchJWKS fetches {issuer}/.well-known/jwks.json and parses all public keys. +func (v *OIDCIssuerVerifier) fetchJWKS(ctx context.Context, issuer string) ([]jwksKey, error) { + url := issuer + "/.well-known/jwks.json" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to build JWKS request: %w", err) + } + + resp, err := v.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("JWKS fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read JWKS response: %w", err) + } + + return parseJWKS(body) +} + +// jwkSet is the JSON structure of a JWKS response. +type jwkSet struct { + Keys []json.RawMessage `json:"keys"` +} + +type jwksKey struct { + kid string + pub crypto.PublicKey +} + +// parseJWKS parses a JWKS JSON body and returns all usable public keys with +// their kid values. +func parseJWKS(body []byte) ([]jwksKey, error) { + var set jwkSet + if err := json.Unmarshal(body, &set); err != nil { + return nil, fmt.Errorf("failed to parse JWKS JSON: %w", err) + } + if len(set.Keys) == 0 { + return nil, fmt.Errorf("JWKS contains no keys") + } + + var keys []jwksKey + for _, raw := range set.Keys { + jwk, err := keyutil.ReadJWK(raw) + if err != nil { + // Skip unsupported key types rather than failing entirely. + continue + } + pub, err := jwk.PublicKey() + if err != nil { + continue + } + keys = append(keys, jwksKey{kid: jwk.KeyID, pub: pub}) + } + + if len(keys) == 0 { + return nil, fmt.Errorf("JWKS contained no usable public keys") + } + return keys, nil +} + +// selectKey picks the key matching kid. If kid is empty or no key matches, +// the first key is returned (single-key JWKS are common). +func selectKey(keys []jwksKey, kid string) (crypto.PublicKey, error) { + if len(keys) == 0 { + return nil, fmt.Errorf("empty key set") + } + if kid != "" { + for _, k := range keys { + if k.kid == kid { + return k.pub, nil + } + } + return nil, fmt.Errorf("no key with kid %q found in JWKS", kid) + } + return keys[0].pub, nil +} diff --git a/sigkeydraft/oidc_test.go b/sigkeydraft/oidc_test.go new file mode 100644 index 0000000..c4e4bae --- /dev/null +++ b/sigkeydraft/oidc_test.go @@ -0,0 +1,300 @@ +package sigkeydraft + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// buildJWKSResponse builds a minimal JWKS JSON body from an ECDSA public key. +func buildJWKSResponse(t *testing.T, pub *ecdsa.PublicKey, kid string) []byte { + t.Helper() + xBytes := pub.X.Bytes() + yBytes := pub.Y.Bytes() + xPadded := make([]byte, 32) + yPadded := make([]byte, 32) + copy(xPadded[32-len(xBytes):], xBytes) + copy(yPadded[32-len(yBytes):], yBytes) + + type jwkJSON struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + Kid string `json:"kid,omitempty"` + } + set := struct { + Keys []jwkJSON `json:"keys"` + }{ + Keys: []jwkJSON{{ + Kty: "EC", + Crv: "P-256", + X: base64.RawURLEncoding.EncodeToString(xPadded), + Y: base64.RawURLEncoding.EncodeToString(yPadded), + Kid: kid, + }}, + } + b, err := json.Marshal(set) + if err != nil { + t.Fatal(err) + } + return b +} + +// buildSignedJWT builds a compact JWT signed with the given ECDSA private key. +func buildSignedJWT(t *testing.T, priv *ecdsa.PrivateKey, issuer, subject, kid string, extra map[string]any) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": issuer, + "sub": subject, + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + for k, v := range extra { + claims[k] = v + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + if kid != "" { + tok.Header["kid"] = kid + } + signed, err := tok.SignedString(priv) + if err != nil { + t.Fatal(err) + } + return signed +} + +func TestOIDCIssuerVerifier_Valid(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "key1") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/jwks.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "key1", nil) + + v := NewOIDCIssuerVerifier([]string{issuer}) + claims, err := v.VerifyJWT(context.Background(), compactJWT) + if err != nil { + t.Fatalf("VerifyJWT: %v", err) + } + if claims["sub"] != "alice" { + t.Errorf("sub: got %v, want %q", claims["sub"], "alice") + } + if claims["iss"] != issuer { + t.Errorf("iss: got %v, want %q", claims["iss"], issuer) + } +} + +func TestOIDCIssuerVerifier_DisallowedIssuer(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + compactJWT := buildSignedJWT(t, priv, "https://untrusted.example.com", "alice", "", nil) + + v := NewOIDCIssuerVerifier([]string{"https://trusted.example.com"}) + _, err = v.VerifyJWT(context.Background(), compactJWT) + if err == nil { + t.Fatal("expected error for disallowed issuer") + } +} + +func TestOIDCIssuerVerifier_ExpiredJWT(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + claims := jwt.MapClaims{ + "iss": issuer, + "sub": "alice", + "iat": time.Now().Add(-10 * time.Minute).Unix(), + "exp": time.Now().Add(-5 * time.Minute).Unix(), // already expired + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + compactJWT, err := tok.SignedString(priv) + if err != nil { + t.Fatal(err) + } + + v := NewOIDCIssuerVerifier([]string{issuer}) + _, err = v.VerifyJWT(context.Background(), compactJWT) + if err == nil { + t.Fatal("expected error for expired JWT") + } +} + +func TestOIDCIssuerVerifier_WrongKey(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + // JWKS has otherPriv's public key, but JWT is signed with priv. + jwksBody := buildJWKSResponse(t, &otherPriv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "", nil) + + v := NewOIDCIssuerVerifier([]string{issuer}) + _, err = v.VerifyJWT(context.Background(), compactJWT) + if err == nil { + t.Fatal("expected error for wrong signing key") + } +} + +func TestOIDCIssuerVerifier_JWKSCached(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + fetchCount := 0 + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount++ + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + v := NewOIDCIssuerVerifier([]string{issuer}, WithJWKSCacheTTL(time.Minute)) + + for i := 0; i < 3; i++ { + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "", nil) + if _, err := v.VerifyJWT(context.Background(), compactJWT); err != nil { + t.Fatalf("VerifyJWT call %d: %v", i, err) + } + } + + if fetchCount != 1 { + t.Errorf("JWKS fetched %d times, want 1 (should be cached)", fetchCount) + } +} + +func TestOIDCIssuerVerifier_JWKSCacheExpiry(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + fetchCount := 0 + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount++ + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + + now := time.Now() + v := NewOIDCIssuerVerifier([]string{issuer}, WithJWKSCacheTTL(time.Minute)) + v.nowFunc = func() time.Time { return now } + + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "", nil) + if _, err := v.VerifyJWT(context.Background(), compactJWT); err != nil { + t.Fatalf("first VerifyJWT: %v", err) + } + + // Advance time past TTL. + v.nowFunc = func() time.Time { return now.Add(2 * time.Minute) } + + compactJWT = buildSignedJWT(t, priv, issuer, "alice", "", nil) + if _, err := v.VerifyJWT(context.Background(), compactJWT); err != nil { + t.Fatalf("second VerifyJWT: %v", err) + } + + if fetchCount != 2 { + t.Errorf("JWKS fetched %d times, want 2 (cache should have expired)", fetchCount) + } +} + +func TestOIDCIssuerVerifier_KidSelection(t *testing.T) { + priv1, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + priv2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + // JWKS with two keys; JWT uses kid "key2". + xBytes1 := priv1.PublicKey.X.Bytes() + yBytes1 := priv1.PublicKey.Y.Bytes() + xPadded1 := make([]byte, 32) + yPadded1 := make([]byte, 32) + copy(xPadded1[32-len(xBytes1):], xBytes1) + copy(yPadded1[32-len(yBytes1):], yBytes1) + + xBytes2 := priv2.PublicKey.X.Bytes() + yBytes2 := priv2.PublicKey.Y.Bytes() + xPadded2 := make([]byte, 32) + yPadded2 := make([]byte, 32) + copy(xPadded2[32-len(xBytes2):], xBytes2) + copy(yPadded2[32-len(yBytes2):], yBytes2) + + type jwkJSON struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + Kid string `json:"kid"` + } + set := struct { + Keys []jwkJSON `json:"keys"` + }{Keys: []jwkJSON{ + {Kty: "EC", Crv: "P-256", X: base64.RawURLEncoding.EncodeToString(xPadded1), Y: base64.RawURLEncoding.EncodeToString(yPadded1), Kid: "key1"}, + {Kty: "EC", Crv: "P-256", X: base64.RawURLEncoding.EncodeToString(xPadded2), Y: base64.RawURLEncoding.EncodeToString(yPadded2), Kid: "key2"}, + }} + jwksBody, _ := json.Marshal(set) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + // Sign with priv2, kid="key2" — verifier must select key2 not key1. + compactJWT := buildSignedJWT(t, priv2, issuer, "bob", "key2", nil) + + v := NewOIDCIssuerVerifier([]string{issuer}) + claims, err := v.VerifyJWT(context.Background(), compactJWT) + if err != nil { + t.Fatalf("VerifyJWT: %v", err) + } + if claims["sub"] != "bob" { + t.Errorf("sub: got %v, want %q", claims["sub"], "bob") + } +} diff --git a/sigkeydraft/sigkey.go b/sigkeydraft/sigkey.go new file mode 100644 index 0000000..db721af --- /dev/null +++ b/sigkeydraft/sigkey.go @@ -0,0 +1,239 @@ +// Package sigkey parses the Signature-Key HTTP header defined in +// draft-hardt-httpbis-signature-key. It has no dependency on the parent +// httpsig package and can be used standalone. +// +// This package does not perform any validation of keys or certificates and is only responsible for parsing the Signature-Key header. +package sigkeydraft + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + sfv "github.com/dunglas/httpsfv" +) + +type Scheme string + +const ( + // Header is the canonical name of the Signature-Key HTTP header. + Header = "Signature-Key" + + SchemeHWK Scheme = "hwk" + SchemeJWKSURI Scheme = "jwks_uri" + SchemeX509 Scheme = "x509" + SchemeJWT Scheme = "jwt" +) + +type ParametersHWK struct { + Kty string `sfv:"kty,omitempty"` + Crv string `sfv:"crv,omitempty"` + X string `sfv:"x,omitempty"` + Y string `sfv:"y,omitempty"` + // RSA + N string `sfv:"n,omitempty"` + E string `sfv:"e,omitempty"` +} + +type ParametersJWT struct { + JWT string `sfv:"jwt"` +} + +type ParametersJWKSURI struct { + JWKSURI string `sfv:"jwks_uri"` + KID string `sfv:"kid,omitempty"` + WellKnown string `sfv:"well-known,omitempty"` +} + +type ParametersX509 struct { + X5U string `sfv:"x5u"` + X5T string `sfv:"x5t,omitempty"` +} + +// SigKeyHeader is one parsed entry from the Signature-Key header dictionary. +// Each entry corresponds to a single signature label. +type SigKeyHeader struct { + // Label is the signature label (dictionary key), e.g. "sig1". + Label string + // Scheme is the key-transport scheme token, e.g. "jwt". + Scheme Scheme + // params holds the raw SFV parameters for scheme-specific values. + params *sfv.Params +} + +// NewSigKey constructs a SigKeyHeader from a label, scheme, and one of the +// Parameters structs. The resulting value can be serialized into a +// Signature-Key header value via DeriveHeader. +func NewSigKey(label string, scheme Scheme, params any) (SigKeyHeader, error) { + item := sfv.NewItem(sfv.Token(scheme)) + if err := structToSFVParams(params, item.Params); err != nil { + return SigKeyHeader{}, fmt.Errorf("sigkey: %w", err) + } + return SigKeyHeader{ + Label: label, + Scheme: scheme, + params: item.Params, + }, nil +} + +// ParseHeader parses a Signature-Key header value as an SFV Dictionary and +// returns a map from signature label to SigKey. Returns an error if the value +// is empty, not a valid SFV dictionary, or any entry has a malformed scheme. +func ParseHeader(headerValue string) (map[string]SigKeyHeader, error) { + dict, err := sfv.UnmarshalDictionary([]string{headerValue}) + if err != nil { + return nil, fmt.Errorf("sigkey: failed to parse Signature-Key header as SFV dictionary: %w", err) + } + + names := dict.Names() + if len(names) == 0 { + return nil, fmt.Errorf("sigkey: Signature-Key header is empty") + } + + entries := make(map[string]SigKeyHeader, len(names)) + for _, label := range names { + member, _ := dict.Get(label) + item, ok := member.(sfv.Item) + if !ok { + return nil, fmt.Errorf("sigkey: entry %q must be an SFV Item, got %T", label, member) + } + + var scheme Scheme + switch v := item.Value.(type) { + case sfv.Token: + scheme = Scheme(v) + case string: + scheme = Scheme(v) + default: + return nil, fmt.Errorf("sigkey: scheme for entry %q must be a token or string, got %T", label, item.Value) + } + + entries[label] = SigKeyHeader{ + Label: label, + Scheme: scheme, + params: item.Params, + } + } + return entries, nil +} + +// Param returns the raw SFV parameter value for the given name, along with +// whether it was present. This provides access to scheme-specific parameters +// (e.g. "jwt") without the caller needing to know the SFV types. +func (e SigKeyHeader) Param(name string) (any, bool) { + return e.params.Get(name) +} + +// StringParam returns the named parameter as a string. Returns an error if +// the parameter is absent or is not a string value. +func (e SigKeyHeader) StringParam(name string) (string, error) { + v, ok := e.params.Get(name) + if !ok { + return "", fmt.Errorf("sigkey: entry %q has no parameter %q", e.Label, name) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("sigkey: entry %q parameter %q has unexpected type %T", e.Label, name, v) + } + return s, nil +} + +// JWT returns the unvalidated JWT string from the entry's parameters. +// Convenience wrapper for StringParam(SchemeJWT). +func (e SigKeyHeader) JWT() (string, error) { + s, err := e.StringParam(string(SchemeJWT)) + if err != nil { + return "", fmt.Errorf("sigkey: entry %q is missing required 'jwt' parameter: %w", e.Label, err) + } + return s, nil +} + +// JWKSURI returns the JWKS URI from the entry's parameters. +// Convenience wrapper for StringParam(SchemeHWKSURI). +func (e SigKeyHeader) JWKSURI() (string, error) { + s, err := e.StringParam(string(SchemeJWKSURI)) + if err != nil { + return "", fmt.Errorf("sigkey: entry %q is missing required 'hwks_uri' parameter: %w", e.Label, err) + } + return s, nil +} + +func (skh SigKeyHeader) SetHeader(h http.Header) error { + // h.Set(Header, skh.DeriveHeader(h.Get(Header)) + updated, err := skh.DeriveHeader(h.Get(Header)) + if err != nil { + return err + } + h.Set(Header, updated) + return nil +} + +// DeriveHeader returns the value to set for a Signature-Key header, adding or +// replacing the entry for label. existing is the current header value (empty +// string if the header is not yet set). scheme is the key-transport token +// (e.g. SchemeJWT). params must be a pointer to or value of one of the +// Parameters structs (ParametersJWT, ParametersHWK, ParametersJWKSURI, +// ParametersX509). Fields are mapped to SFV parameters using the "sfv" struct +// tag; fields tagged with "omitempty" are skipped when zero. +func (skh SigKeyHeader) DeriveHeader(existingHeader string) (string, error) { + var dict *sfv.Dictionary + if existingHeader != "" { + var err error + dict, err = sfv.UnmarshalDictionary([]string{existingHeader}) + if err != nil { + return "", fmt.Errorf("sigkey: failed to parse existing Signature-Key header: %w", err) + } + } else { + dict = sfv.NewDictionary() + } + + item := sfv.NewItem(sfv.Token(skh.Scheme)) + for _, name := range skh.params.Names() { + v, _ := skh.params.Get(name) + item.Params.Add(name, v) + } + dict.Add(skh.Label, item) + + value, err := sfv.Marshal(dict) + if err != nil { + return "", fmt.Errorf("sigkey: failed to marshal Signature-Key header: %w", err) + } + return value, nil +} + +// structToSFVParams populates p from the exported fields of v using "sfv" +// struct tags. The tag format is `sfv:"name"` or `sfv:"name,omitempty"`. +// Only string fields are supported; other types return an error. +func structToSFVParams(v any, p *sfv.Params) error { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return fmt.Errorf("params must be a struct, got %T", v) + } + rt := rv.Type() + for i := range rt.NumField() { + field := rt.Field(i) + tag := field.Tag.Get("sfv") + if tag == "" || tag == "-" { + continue + } + name, opts, _ := strings.Cut(tag, ",") + omitempty := opts == "omitempty" + + fv := rv.Field(i) + if omitempty && fv.IsZero() { + continue + } + + switch fv.Kind() { + case reflect.String: + p.Add(name, fv.String()) + default: + return fmt.Errorf("unsupported field type %s for param %q", fv.Type(), name) + } + } + return nil +} diff --git a/sigkeydraft/sigkey_test.go b/sigkeydraft/sigkey_test.go new file mode 100644 index 0000000..e9ebf6b --- /dev/null +++ b/sigkeydraft/sigkey_test.go @@ -0,0 +1,253 @@ +package sigkeydraft_test + +import ( + "testing" + + sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" +) + +func TestParseHeader(t *testing.T) { + tests := []struct { + name string + header string + wantErr bool + wantLabels []string + wantSchemes map[string]sigkey.Scheme + }{ + { + name: "single jwt entry", + header: `sig1=jwt;jwt="eyJ.payload.sig"`, + wantLabels: []string{"sig1"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT}, + }, + { + name: "multiple entries", + header: `sig1=jwt;jwt="tok1", sig2=jwt;jwt="tok2"`, + wantLabels: []string{"sig1", "sig2"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT, "sig2": sigkey.SchemeJWT}, + }, + { + name: "jwks_uri entry", + header: `sig1=jwks_uri;jwks_uri="https://device.example.com/.well-known/jwks.json"`, + wantLabels: []string{"sig1"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWKSURI}, + }, + { + name: "mixed jwt and jwks_uri", + header: `sig1=jwt;jwt="eyJ.payload.sig", sig2=jwks_uri;jwks_uri="https://device.example.com/.well-known/jwks.json"`, + wantLabels: []string{"sig1", "sig2"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT, "sig2": sigkey.SchemeJWKSURI}, + }, + { + name: "empty header", + header: "", + wantErr: true, + }, + { + name: "invalid sfv", + header: "!!!", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + entries, err := sigkey.ParseHeader(tc.header) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, label := range tc.wantLabels { + e, ok := entries[label] + if !ok { + t.Errorf("missing entry for label %q", label) + continue + } + if wantScheme, ok := tc.wantSchemes[label]; ok { + if e.Scheme != wantScheme { + t.Errorf("entry %q scheme: got %q, want %q", label, e.Scheme, wantScheme) + } + } + } + }) + } +} + +func TestSigKeyJWT(t *testing.T) { + header := `sig1=jwt;jwt="eyJ.payload.sig"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e := entries["sig1"] + got, err := e.JWT() + if err != nil { + t.Fatalf("JWT(): %v", err) + } + if got != "eyJ.payload.sig" { + t.Errorf("JWT() = %q, want %q", got, "eyJ.payload.sig") + } +} + +func TestSigKeyJWT_Missing(t *testing.T) { + // A valid entry with no jwt param + header := `sig1=jwt` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + _, err = entries["sig1"].JWT() + if err == nil { + t.Fatal("expected error for missing jwt param, got nil") + } +} + +func TestSigKeyJWKSURI(t *testing.T) { + const wantURI = "https://device.example.com/.well-known/jwks.json" + header := `sig1=jwks_uri;jwks_uri="` + wantURI + `"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + got, err := entries["sig1"].JWKSURI() + if err != nil { + t.Fatalf("JWKSURI(): %v", err) + } + if got != wantURI { + t.Errorf("JWKSURI() = %q, want %q", got, wantURI) + } +} + +func TestSigKeyJWKSURI_Missing(t *testing.T) { + header := `sig1=jwks_uri` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + _, err = entries["sig1"].JWKSURI() + if err == nil { + t.Fatal("expected error for missing jwks_uri param, got nil") + } +} + +func TestSigKeyStringParam(t *testing.T) { + header := `sig1=jwt;jwt="mytoken"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + got, err := entries["sig1"].StringParam("jwt") + if err != nil { + t.Fatalf("StringParam: %v", err) + } + if got != "mytoken" { + t.Errorf("StringParam() = %q, want %q", got, "mytoken") + } +} + +func TestDeriveHeader_JWT(t *testing.T) { + sk, err := sigkey.NewSigKey("sig1", sigkey.SchemeJWT, sigkey.ParametersJWT{JWT: "eyJ.payload.sig"}) + if err != nil { + t.Fatalf("NewSigKey: %v", err) + } + value, err := sk.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader: %v", err) + } + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e, ok := entries["sig1"] + if !ok { + t.Fatal("missing entry for sig1") + } + if e.Scheme != sigkey.SchemeJWT { + t.Errorf("scheme: got %q, want %q", e.Scheme, sigkey.SchemeJWT) + } + got, err := e.JWT() + if err != nil { + t.Fatalf("JWT(): %v", err) + } + if got != "eyJ.payload.sig" { + t.Errorf("JWT() = %q, want %q", got, "eyJ.payload.sig") + } +} + +func TestDeriveHeader_AppendToExisting(t *testing.T) { + sk1, err := sigkey.NewSigKey("sig1", sigkey.SchemeJWT, sigkey.ParametersJWT{JWT: "token1"}) + if err != nil { + t.Fatalf("NewSigKey sig1: %v", err) + } + value, err := sk1.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader sig1: %v", err) + } + + const wantURI = "https://example.com/.well-known/jwks.json" + sk2, err := sigkey.NewSigKey("sig2", sigkey.SchemeJWKSURI, sigkey.ParametersJWKSURI{JWKSURI: wantURI}) + if err != nil { + t.Fatalf("NewSigKey sig2: %v", err) + } + value, err = sk2.DeriveHeader(value) + if err != nil { + t.Fatalf("DeriveHeader sig2: %v", err) + } + + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if tok, _ := entries["sig1"].JWT(); tok != "token1" { + t.Errorf("sig1 jwt = %q, want %q", tok, "token1") + } + if uri, _ := entries["sig2"].JWKSURI(); uri != wantURI { + t.Errorf("sig2 jwks_uri = %q, want %q", uri, wantURI) + } +} + +func TestDeriveHeader_HWK_OmitsEmptyFields(t *testing.T) { + sk, err := sigkey.NewSigKey("sig1", sigkey.SchemeHWK, sigkey.ParametersHWK{ + Kty: "EC", + Crv: "P-256", + X: "someXvalue", + Y: "someYvalue", + }) + if err != nil { + t.Fatalf("NewSigKey: %v", err) + } + value, err := sk.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader: %v", err) + } + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e := entries["sig1"] + for _, name := range []string{"kty", "crv", "x", "y"} { + if _, ok := e.Param(name); !ok { + t.Errorf("missing param %q", name) + } + } + // RSA fields should be absent + for _, name := range []string{"n", "e"} { + if _, ok := e.Param(name); ok { + t.Errorf("param %q should be absent (omitempty)", name) + } + } +} + +func TestNewSigKey_NonStructError(t *testing.T) { + if _, err := sigkey.NewSigKey("sig1", sigkey.SchemeJWT, "not-a-struct"); err == nil { + t.Fatal("expected error for non-struct params") + } +} diff --git a/sigkeydraft/signaturekey_test.go b/sigkeydraft/signaturekey_test.go new file mode 100644 index 0000000..577160a --- /dev/null +++ b/sigkeydraft/signaturekey_test.go @@ -0,0 +1,69 @@ +package sigkeydraft_test + +import ( + "net/http" + "testing" + + httpsig "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/key" + sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" +) + +// TestSignatureKeyRoundTrip signs a request then verifies it using +// SignatureKeyFetcher, exercising the full sign→verify pipeline. +func TestSignatureKeyRoundTrip(t *testing.T) { + srv := newSigkeyServer(t) + + jwkMap, signingPriv := ecJWKMap(t) + + claims := srv.standardClaims("alice") + claims["cnf"] = map[string]any{"jwk": jwkMap} + compactJWT := srv.signJWT(t, claims) + + req, err := http.NewRequest("GET", "https://example.com/path", nil) + if err != nil { + t.Fatal(err) + } + + profile := httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.Fields("@method", "@target-uri"), + Metadata: []httpsig.Metadata{httpsig.MetaCreated}, + Label: "sig1", + } + if err := httpsig.Sign(req, profile, httpsig.SigningKey{Key: signingPriv}); err != nil { + t.Fatalf("Sign: %v", err) + } + req.Header.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + vp := httpsig.VerifyProfile{ + SignatureLabel: "sig1", + RequiredFields: httpsig.Fields("@method", "@target-uri"), + RequiredMetadata: []httpsig.Metadata{httpsig.MetaCreated}, + AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_ECDSA_P256_SHA256}, + DisableTimeEnforcement: true, + } + + result, err := httpsig.Verify(req, fetcher, vp) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !result.Verified { + t.Fatal("expected Verified=true") + } + + spec, err := result.KeySpecer.KeySpec() + if err != nil { + t.Fatalf("KeySpec: %v", err) + } + if spec.Identity.Issuer != srv.issuer() { + t.Errorf("Issuer: got %q, want %q", spec.Identity.Issuer, srv.issuer()) + } + if spec.Identity.Identity != "alice" { + t.Errorf("Identity: got %q, want %q", spec.Identity.Identity, "alice") + } + if spec.Identity.IssuerType != key.IssuerIDP { + t.Errorf("IssuerType: got %q, want %q", spec.Identity.IssuerType, key.IssuerIDP) + } +} diff --git a/sign.go b/sign.go index a55e0ba..ac363bd 100644 --- a/sign.go +++ b/sign.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "sync" "time" "unicode" @@ -88,16 +89,36 @@ type SigningKey struct { Secret []byte // Secret to use for symmetric algorithms Opts SigningKeyOpts // Options for advanced signing use cases like TPMs. // Meta fields - MetaKeyID string // 'keyid' - Only used if 'keyid' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. Metadata. - MetaTag string // 'tag'. Only used if 'tag' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. + MetaKeyID string // 'keyid' - Only used if 'keyid' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. Metadata. + MetaTag string // 'tag'. Only used if 'tag' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. + Expiration time.Time // Optional expiration time. If expired when Sign is called and a SigningKeyGenerator is available a new key is generated. +} + +func (skey *SigningKey) Expired() bool { + if skey.Expiration.IsZero() { + return false + } + + return time.Now().After(skey.Expiration) } type SigningKeyOpts struct { Signer crypto.Signer // crypto.Signer interface for TPMs and other custom use cases. ASN1ForECDSA bool // Set to true to indicate the crypto.Signer returns ASN.1 formatted signatures for ECDSA algorithms. False (default) indicates ECDSA signatures are concatenated R and S parameters as per the HTTP Signatures spec. + + // PresignHeaders is called before signing to allow header manipulation if not nil. + // It is intended for setting information about the signing key in the request. + PresignHeaders func(http.Header) error } + +type SigningKeyGenerator interface { + GenerateKey(SigningProfile) (SigningKey, error) +} + type Signer struct { profile SigningProfile + keygen SigningKeyGenerator + mu sync.Mutex skey SigningKey } @@ -115,6 +136,24 @@ func NewSigner(profile SigningProfile, skey SigningKey) (*Signer, error) { return s, nil } +func NewSignerWithKeyGenerator(profile SigningProfile, keygen SigningKeyGenerator) (*Signer, error) { + opts := profile.withDefaults() + skey, err := keygen.GenerateKey(profile) + if err != nil { + return nil, fmt.Errorf("Failed to generate signing key: %w", err) + } + err = profile.validate(skey) + if err != nil { + return nil, err + } + s := &Signer{ + profile: opts, + keygen: keygen, + skey: skey, + } + return s, nil +} + func Sign(req *http.Request, params SigningProfile, skey SigningKey) error { s, err := NewSigner(params, skey) if err != nil { @@ -140,42 +179,50 @@ func (s *Signer) Sign(req *http.Request) error { req.Header.Set("Content-Digest", digestValue) } - baseParams, err := s.baseParameters() - if err != nil { - return err - } - - return sign( - httpMessage{ - Req: req, - }, sigParameters{ - Base: baseParams, - Algo: s.profile.Algorithm, - PrivateKey: s.skey.Key, - Secret: s.skey.Secret, - Opts: s.skey.Opts, - Label: s.profile.Label, - }) + return s.internalSign(httpMessage{ + Req: req, + }) } func (s *Signer) SignResponse(resp *http.Response) error { + return s.internalSign(httpMessage{ + IsResponse: true, + Resp: resp, + }) +} + +func (s *Signer) internalSign(msg httpMessage) error { baseParams, err := s.baseParameters() if err != nil { return err } - return sign( - httpMessage{ - IsResponse: true, - Resp: resp, - }, sigParameters{ - Base: baseParams, - Algo: s.profile.Algorithm, - PrivateKey: s.skey.Key, - Secret: s.skey.Secret, - Opts: s.skey.Opts, - Label: s.profile.Label, - }) + // Generate a new key if the current one is expied. + s.mu.Lock() + if s.skey.Expired() { + newKey, err := s.keygen.GenerateKey(s.profile) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to generate signing key: %w", err) + } + s.skey = newKey + } + skey := s.skey + s.mu.Unlock() + + // Presign headers to allow for setting signing key information. + if s.skey.Opts.PresignHeaders != nil { + s.skey.Opts.PresignHeaders(msg.Headers()) + } + + return sign(msg, sigParameters{ + Base: baseParams, + Algo: s.profile.Algorithm, + PrivateKey: skey.Key, + Secret: skey.Secret, + Opts: skey.Opts, + Label: s.profile.Label, + }) } func (s *Signer) baseParameters() (sigBaseInput, error) { diff --git a/verify.go b/verify.go index 03825b5..22b40c3 100644 --- a/verify.go +++ b/verify.go @@ -43,11 +43,15 @@ var ( ) // Re-exported from key package for backwards compatibility. +//go:fix inline type KeySpec = key.KeySpec +//go:fix inline type KeySpecer = key.KeySpecer +//go:fix inline type KeyFetcher = key.KeyFetcher // MetadataProvider re-exported from types package for backwards compatibility. +//go:fix inline type MetadataProvider = types.MetadataProvider type KeyErrorReason string From 047c1f77eb40af68e6b6284660bc0cb71528958d Mon Sep 17 00:00:00 2001 From: Lee Date: Thu, 2 Apr 2026 10:41:30 -0700 Subject: [PATCH 2/3] Initial work on supporting Signature-Key header --- sigkeydraft/fetcher.go | 159 ++++++++++++++++ sigkeydraft/fetcher_test.go | 302 +++++++++++++++++++++++++++++++ sigkeydraft/oidc.go | 221 ++++++++++++++++++++++ sigkeydraft/oidc_test.go | 300 ++++++++++++++++++++++++++++++ sigkeydraft/sigkey.go | 239 ++++++++++++++++++++++++ sigkeydraft/sigkey_test.go | 253 ++++++++++++++++++++++++++ sigkeydraft/signaturekey_test.go | 69 +++++++ sign.go | 107 ++++++++--- verify.go | 4 + 9 files changed, 1624 insertions(+), 30 deletions(-) create mode 100644 sigkeydraft/fetcher.go create mode 100644 sigkeydraft/fetcher_test.go create mode 100644 sigkeydraft/oidc.go create mode 100644 sigkeydraft/oidc_test.go create mode 100644 sigkeydraft/sigkey.go create mode 100644 sigkeydraft/sigkey_test.go create mode 100644 sigkeydraft/signaturekey_test.go diff --git a/sigkeydraft/fetcher.go b/sigkeydraft/fetcher.go new file mode 100644 index 0000000..9ac0425 --- /dev/null +++ b/sigkeydraft/fetcher.go @@ -0,0 +1,159 @@ +package sigkeydraft + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "fmt" + "net/http" + + "github.com/remitly-oss/httpsig-go/key" + "github.com/remitly-oss/httpsig-go/keyutil" + "github.com/remitly-oss/httpsig-go/types" +) + +// SignatureKeyFetcher implements key.KeyFetcher by resolving keys from the +// Signature-Key header using draft-hardt-httpbis-signature-key-02. +// +// Only the jwt scheme is supported. For each verified signature label the +// fetcher validates the JWT via OIDCIssuerVerifier, extracts the public key +// from the cnf.jwk claim, and returns a KeySpec whose Identity field is +// populated from the JWT iss and sub claims. +type SignatureKeyFetcher struct { + label string + issuerVerifier *OIDCIssuerVerifier +} + +// NewSignatureKeyFetcher creates a SignatureKeyFetcher for the given signature +// label. issuerVerifier verifies the JWT and fetches the issuer's JWKS. +func NewSignatureKeyFetcher(label string, issuerVerifier *OIDCIssuerVerifier) *SignatureKeyFetcher { + return &SignatureKeyFetcher{ + label: label, + issuerVerifier: issuerVerifier, + } +} + +// FetchByKeyID implements key.KeyFetcher. The keyID from the signature +// metadata is ignored because the key material is carried in the +// Signature-Key header. +func (f *SignatureKeyFetcher) FetchByKeyID(ctx context.Context, headers http.Header, _ string) (key.KeySpecer, error) { + return f.fetchFromHeader(ctx, headers) +} + +// Fetch implements key.KeyFetcher. +func (f *SignatureKeyFetcher) Fetch(ctx context.Context, headers http.Header, _ types.MetadataProvider) (key.KeySpecer, error) { + return f.fetchFromHeader(ctx, headers) +} + +func (f *SignatureKeyFetcher) fetchFromHeader(ctx context.Context, headers http.Header) (key.KeySpecer, error) { + headerValue := headers.Get(Header) + if headerValue == "" { + return nil, fmt.Errorf("sigkey: Signature-Key header is missing or empty (label %q)", f.label) + } + + entries, err := ParseHeader(headerValue) + if err != nil { + return nil, fmt.Errorf("sigkey: failed to parse Signature-Key header: %w", err) + } + + entry, ok := entries[f.label] + if !ok { + return nil, fmt.Errorf("sigkey: Signature-Key header has no entry for label %q", f.label) + } + + if entry.Scheme != SchemeJWT { + return nil, fmt.Errorf("sigkey: scheme %q for label %q is not supported; only %q is supported", entry.Scheme, f.label, SchemeJWT) + } + + return f.resolveJWT(ctx, entry) +} + +// resolveJWT validates the JWT, extracts cnf.jwk, and builds a KeySpec. +func (f *SignatureKeyFetcher) resolveJWT(ctx context.Context, entry SigKeyHeader) (key.KeySpec, error) { + compactJWT, err := entry.JWT() + if err != nil { + return key.KeySpec{}, fmt.Errorf("sigkey: %w", err) + } + + claims, err := f.issuerVerifier.VerifyJWT(ctx, compactJWT) + if err != nil { + return key.KeySpec{}, fmt.Errorf("sigkey: JWT validation failed: %w", err) + } + + pubKey, algo, err := extractCNFKey(claims) + if err != nil { + return key.KeySpec{}, err + } + + iss, _ := claims["iss"].(string) + sub, _ := claims["sub"].(string) + + return key.KeySpec{ + Algo: algo, + PubKey: pubKey, + Identity: key.KeyIdentity{ + Identity: sub, + Issuer: iss, + IssuerType: key.IssuerIDP, + }, + }, nil +} + +// extractCNFKey extracts the public key and algorithm from the cnf.jwk claim. +func extractCNFKey(claims map[string]any) (pubKey any, algo types.Algorithm, err error) { + cnf, ok := claims["cnf"].(map[string]any) + if !ok { + return nil, "", fmt.Errorf("sigkey: JWT is missing required 'cnf' claim") + } + + jwkRaw, ok := cnf["jwk"] + if !ok { + return nil, "", fmt.Errorf("sigkey: JWT cnf claim is missing required 'jwk' member") + } + + // Re-marshal to JSON so we can use keyutil.ReadJWK for parsing. + jwkJSON, err := json.Marshal(jwkRaw) + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to marshal cnf.jwk to JSON: %w", err) + } + + jwk, err := keyutil.ReadJWK(jwkJSON) + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to parse cnf.jwk: %w", err) + } + + algo, err = algoFromJWK(jwk) + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to determine algorithm from cnf.jwk: %w", err) + } + + pk, err := jwk.PublicKey() + if err != nil { + return nil, "", fmt.Errorf("sigkey: failed to extract public key from cnf.jwk: %w", err) + } + + return pk, algo, nil +} + +// algoFromJWK infers the Algorithm from the public key extracted from the JWK. +// The spec requires that 'alg' MUST NOT be present in the JWK, so we derive +// the algorithm from the concrete key type and curve. +func algoFromJWK(j keyutil.JWK) (types.Algorithm, error) { + pub, err := j.PublicKey() + if err != nil { + return "", fmt.Errorf("sigkey: cannot extract public key from JWK: %w", err) + } + switch key := pub.(type) { + case *ecdsa.PublicKey: + switch key.Curve.Params().Name { + case "P-256": + return types.Algo_ECDSA_P256_SHA256, nil + case "P-384": + return types.Algo_ECDSA_P384_SHA384, nil + default: + return "", fmt.Errorf("sigkey: unsupported EC curve %q", key.Curve.Params().Name) + } + default: + return "", fmt.Errorf("sigkey: unsupported JWK key type %T", pub) + } +} diff --git a/sigkeydraft/fetcher_test.go b/sigkeydraft/fetcher_test.go new file mode 100644 index 0000000..8c732a9 --- /dev/null +++ b/sigkeydraft/fetcher_test.go @@ -0,0 +1,302 @@ +package sigkeydraft_test + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/remitly-oss/httpsig-go/key" + sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" + "github.com/remitly-oss/httpsig-go/types" +) + +// sigkeyServer is a test OIDC-like server that serves a JWKS and lets tests +// build signed JWTs against its key. +type sigkeyServer struct { + priv *ecdsa.PrivateKey + server *httptest.Server +} + +func newSigkeyServer(t *testing.T) *sigkeyServer { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + s := &sigkeyServer{priv: priv} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/jwks.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(s.jwksJSON(t)) + })) + t.Cleanup(s.server.Close) + return s +} + +func (s *sigkeyServer) issuer() string { return s.server.URL } + +func (s *sigkeyServer) jwksJSON(t *testing.T) []byte { + t.Helper() + pub := &s.priv.PublicKey + xPadded := padTo(pub.X.Bytes(), 32) + yPadded := padTo(pub.Y.Bytes(), 32) + type jwkJSON struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } + b, err := json.Marshal(struct { + Keys []jwkJSON `json:"keys"` + }{Keys: []jwkJSON{{ + Kty: "EC", Crv: "P-256", + X: base64.RawURLEncoding.EncodeToString(xPadded), + Y: base64.RawURLEncoding.EncodeToString(yPadded), + }}}) + if err != nil { + t.Fatal(err) + } + return b +} + +func (s *sigkeyServer) signJWT(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + signed, err := tok.SignedString(s.priv) + if err != nil { + t.Fatal(err) + } + return signed +} + +func (s *sigkeyServer) standardClaims(sub string) jwt.MapClaims { + return jwt.MapClaims{ + "iss": s.issuer(), + "sub": sub, + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } +} + +func padTo(b []byte, n int) []byte { + padded := make([]byte, n) + copy(padded[n-len(b):], b) + return padded +} + +func ecJWKMap(t *testing.T) (map[string]any, *ecdsa.PrivateKey) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + pub := &priv.PublicKey + return map[string]any{ + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(padTo(pub.X.Bytes(), 32)), + "y": base64.RawURLEncoding.EncodeToString(padTo(pub.Y.Bytes(), 32)), + }, priv +} + +func ed25519JWKMap(t *testing.T) (map[string]any, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return map[string]any{ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pub), + }, priv +} + +func makeSignatureKeyHeader(label, compactJWT string) string { + return label + `=jwt;jwt="` + compactJWT + `"` +} + +func TestSignatureKeyFetcherJWT_EC(t *testing.T) { + srv := newSigkeyServer(t) + jwkMap, signingKey := ecJWKMap(t) + + claims := srv.standardClaims("alice") + claims["cnf"] = map[string]any{"jwk": jwkMap} + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + ks, err := fetcher.FetchByKeyID(context.Background(), headers, "some-key-id") + if err != nil { + t.Fatalf("FetchByKeyID: %v", err) + } + spec, err := ks.KeySpec() + if err != nil { + t.Fatalf("KeySpec: %v", err) + } + + if spec.Algo != types.Algo_ECDSA_P256_SHA256 { + t.Errorf("Algo: got %q, want %q", spec.Algo, types.Algo_ECDSA_P256_SHA256) + } + ecPub, ok := spec.PubKey.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("PubKey type: got %T, want *ecdsa.PublicKey", spec.PubKey) + } + if ecPub.X.Cmp(signingKey.PublicKey.X) != 0 || ecPub.Y.Cmp(signingKey.PublicKey.Y) != 0 { + t.Error("extracted public key does not match expected") + } + if spec.Identity.IssuerType != key.IssuerIDP { + t.Errorf("IssuerType: got %q, want %q", spec.Identity.IssuerType, key.IssuerIDP) + } + if spec.Identity.Issuer != srv.issuer() { + t.Errorf("Issuer: got %q, want %q", spec.Identity.Issuer, srv.issuer()) + } + if spec.Identity.Identity != "alice" { + t.Errorf("Identity (sub): got %q, want %q", spec.Identity.Identity, "alice") + } +} + +func TestSignatureKeyFetcherJWT_Ed25519(t *testing.T) { + t.Skip("Ed25519/OKP JWK parsing not yet supported by keyutil") + srv := newSigkeyServer(t) + jwkMap, signingKey := ed25519JWKMap(t) + + claims := srv.standardClaims("bob") + claims["cnf"] = map[string]any{"jwk": jwkMap} + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + ks, err := fetcher.Fetch(context.Background(), headers, nil) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + spec, err := ks.KeySpec() + if err != nil { + t.Fatalf("KeySpec: %v", err) + } + + if spec.Algo != types.Algo_ED25519 { + t.Errorf("Algo: got %q, want %q", spec.Algo, types.Algo_ED25519) + } + edPub, ok := spec.PubKey.(ed25519.PublicKey) + if !ok { + t.Fatalf("PubKey type: got %T, want ed25519.PublicKey", spec.PubKey) + } + if string(edPub) != string(signingKey.Public().(ed25519.PublicKey)) { + t.Error("extracted Ed25519 public key does not match expected") + } + if spec.Identity.Identity != "bob" { + t.Errorf("Identity (sub): got %q, want %q", spec.Identity.Identity, "bob") + } +} + +func TestSignatureKeyFetcherMissingHeader(t *testing.T) { + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{"https://idp.example.com"})) + headers := http.Header{} + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing Signature-Key header") + } +} + +func TestSignatureKeyFetcherMissingLabel(t *testing.T) { + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{"https://idp.example.com"})) + headers := http.Header{} + headers.Set("Signature-Key", `other=jwt;jwt="tok"`) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing label") + } +} + +func TestSignatureKeyFetcherUnsupportedScheme(t *testing.T) { + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{"https://idp.example.com"})) + headers := http.Header{} + headers.Set("Signature-Key", `sig1=hwk;kty="EC";crv="P-256";x="abc";y="def"`) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for unsupported scheme") + } +} + +func TestSignatureKeyFetcherJWTVerificationError(t *testing.T) { + srv := newSigkeyServer(t) + + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + claims := srv.standardClaims("alice") + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + compactJWT, err := tok.SignedString(otherPriv) + if err != nil { + t.Fatal(err) + } + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + _, err = fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for JWT verification failure") + } +} + +func TestSignatureKeyFetcherMissingCNF(t *testing.T) { + srv := newSigkeyServer(t) + + claims := srv.standardClaims("alice") + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing cnf claim") + } +} + +func TestSignatureKeyFetcherMissingCNFJWK(t *testing.T) { + srv := newSigkeyServer(t) + + claims := srv.standardClaims("alice") + claims["cnf"] = map[string]any{"kid": "some-key"} + compactJWT := srv.signJWT(t, claims) + + headers := http.Header{} + headers.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + + _, err := fetcher.FetchByKeyID(context.Background(), headers, "key1") + if err == nil { + t.Fatal("expected error for missing cnf.jwk") + } +} diff --git a/sigkeydraft/oidc.go b/sigkeydraft/oidc.go new file mode 100644 index 0000000..0a9b1b8 --- /dev/null +++ b/sigkeydraft/oidc.go @@ -0,0 +1,221 @@ +package sigkeydraft + +import ( + "context" + "crypto" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/remitly-oss/httpsig-go/keyutil" +) + +// OIDCIssuerVerifier validates JWTs by fetching the issuer's +// JWKS from {iss}/.well-known/jwks.json, verifying the JWT signature, and +// validating standard claims (exp, iat). +// +// Only issuers in the allowedIssuers list are accepted. An empty list rejects +// all JWTs. +// +// JWKS responses are cached per issuer with a configurable TTL (default 5 +// minutes) to avoid fetching on every request. +type OIDCIssuerVerifier struct { + allowedIssuers map[string]struct{} + httpClient *http.Client + cacheTTL time.Duration + nowFunc func() time.Time + + mu sync.Mutex + cache map[string]jwksEntry // keyed by issuer URL +} + +type jwksEntry struct { + keys []jwksKey + fetchedAt time.Time +} + +// OIDCOption configures an OIDCIssuerVerifier. +type OIDCOption func(*OIDCIssuerVerifier) + +// WithHTTPClient sets the HTTP client used for JWKS fetches. +func WithHTTPClient(c *http.Client) OIDCOption { + return func(v *OIDCIssuerVerifier) { v.httpClient = c } +} + +// WithJWKSCacheTTL sets how long a fetched JWKS is cached before re-fetching. +// Default is 5 minutes. +func WithJWKSCacheTTL(d time.Duration) OIDCOption { + return func(v *OIDCIssuerVerifier) { v.cacheTTL = d } +} + +// NewOIDCIssuerVerifier creates an OIDCIssuerVerifier that accepts JWTs from +// any issuer in allowedIssuers. Pass an empty slice to reject all JWTs. +func NewOIDCIssuerVerifier(allowedIssuers []string, opts ...OIDCOption) *OIDCIssuerVerifier { + allowed := make(map[string]struct{}, len(allowedIssuers)) + for _, iss := range allowedIssuers { + allowed[iss] = struct{}{} + } + v := &OIDCIssuerVerifier{ + allowedIssuers: allowed, + httpClient: &http.Client{Timeout: 10 * time.Second}, + cacheTTL: 5 * time.Minute, + nowFunc: time.Now, + cache: make(map[string]jwksEntry), + } + for _, opt := range opts { + opt(v) + } + return v +} + +// VerifyJWT validates the JWT signature using the issuer's JWKS and returns +// the full claims map on success. +func (v *OIDCIssuerVerifier) VerifyJWT(ctx context.Context, compactJWT string) (map[string]any, error) { + // Parse without verification first to extract iss and kid. + unverified, _, err := jwt.NewParser().ParseUnverified(compactJWT, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT: %w", err) + } + + iss, err := unverified.Claims.GetIssuer() + if err != nil || iss == "" { + return nil, fmt.Errorf("JWT missing required 'iss' claim") + } + + if _, ok := v.allowedIssuers[iss]; !ok { + return nil, fmt.Errorf("JWT issuer %q is not in the allowed issuers list", iss) + } + + keys, err := v.jwksKeys(ctx, iss) + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS for issuer %q: %w", iss, err) + } + + // kid from the JWT header selects which key to try first. + kid, _ := unverified.Header["kid"].(string) + + key, err := selectKey(keys, kid) + if err != nil { + return nil, fmt.Errorf("no suitable key found in JWKS for issuer %q: %w", iss, err) + } + + claims := jwt.MapClaims{} + _, err = jwt.ParseWithClaims(compactJWT, claims, func(_ *jwt.Token) (any, error) { + return key, nil + }, jwt.WithExpirationRequired(), jwt.WithIssuedAt()) + if err != nil { + return nil, fmt.Errorf("JWT verification failed: %w", err) + } + + return map[string]any(claims), nil +} + +// jwksKeys returns cached or freshly fetched public keys for the issuer. +func (v *OIDCIssuerVerifier) jwksKeys(ctx context.Context, issuer string) ([]jwksKey, error) { + v.mu.Lock() + entry, ok := v.cache[issuer] + if ok && v.nowFunc().Sub(entry.fetchedAt) < v.cacheTTL { + v.mu.Unlock() + return entry.keys, nil + } + v.mu.Unlock() + + keys, err := v.fetchJWKS(ctx, issuer) + if err != nil { + return nil, err + } + + v.mu.Lock() + v.cache[issuer] = jwksEntry{keys: keys, fetchedAt: v.nowFunc()} + v.mu.Unlock() + + return keys, nil +} + +// fetchJWKS fetches {issuer}/.well-known/jwks.json and parses all public keys. +func (v *OIDCIssuerVerifier) fetchJWKS(ctx context.Context, issuer string) ([]jwksKey, error) { + url := issuer + "/.well-known/jwks.json" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to build JWKS request: %w", err) + } + + resp, err := v.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("JWKS fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read JWKS response: %w", err) + } + + return parseJWKS(body) +} + +// jwkSet is the JSON structure of a JWKS response. +type jwkSet struct { + Keys []json.RawMessage `json:"keys"` +} + +type jwksKey struct { + kid string + pub crypto.PublicKey +} + +// parseJWKS parses a JWKS JSON body and returns all usable public keys with +// their kid values. +func parseJWKS(body []byte) ([]jwksKey, error) { + var set jwkSet + if err := json.Unmarshal(body, &set); err != nil { + return nil, fmt.Errorf("failed to parse JWKS JSON: %w", err) + } + if len(set.Keys) == 0 { + return nil, fmt.Errorf("JWKS contains no keys") + } + + var keys []jwksKey + for _, raw := range set.Keys { + jwk, err := keyutil.ReadJWK(raw) + if err != nil { + // Skip unsupported key types rather than failing entirely. + continue + } + pub, err := jwk.PublicKey() + if err != nil { + continue + } + keys = append(keys, jwksKey{kid: jwk.KeyID, pub: pub}) + } + + if len(keys) == 0 { + return nil, fmt.Errorf("JWKS contained no usable public keys") + } + return keys, nil +} + +// selectKey picks the key matching kid. If kid is empty or no key matches, +// the first key is returned (single-key JWKS are common). +func selectKey(keys []jwksKey, kid string) (crypto.PublicKey, error) { + if len(keys) == 0 { + return nil, fmt.Errorf("empty key set") + } + if kid != "" { + for _, k := range keys { + if k.kid == kid { + return k.pub, nil + } + } + return nil, fmt.Errorf("no key with kid %q found in JWKS", kid) + } + return keys[0].pub, nil +} diff --git a/sigkeydraft/oidc_test.go b/sigkeydraft/oidc_test.go new file mode 100644 index 0000000..c4e4bae --- /dev/null +++ b/sigkeydraft/oidc_test.go @@ -0,0 +1,300 @@ +package sigkeydraft + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// buildJWKSResponse builds a minimal JWKS JSON body from an ECDSA public key. +func buildJWKSResponse(t *testing.T, pub *ecdsa.PublicKey, kid string) []byte { + t.Helper() + xBytes := pub.X.Bytes() + yBytes := pub.Y.Bytes() + xPadded := make([]byte, 32) + yPadded := make([]byte, 32) + copy(xPadded[32-len(xBytes):], xBytes) + copy(yPadded[32-len(yBytes):], yBytes) + + type jwkJSON struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + Kid string `json:"kid,omitempty"` + } + set := struct { + Keys []jwkJSON `json:"keys"` + }{ + Keys: []jwkJSON{{ + Kty: "EC", + Crv: "P-256", + X: base64.RawURLEncoding.EncodeToString(xPadded), + Y: base64.RawURLEncoding.EncodeToString(yPadded), + Kid: kid, + }}, + } + b, err := json.Marshal(set) + if err != nil { + t.Fatal(err) + } + return b +} + +// buildSignedJWT builds a compact JWT signed with the given ECDSA private key. +func buildSignedJWT(t *testing.T, priv *ecdsa.PrivateKey, issuer, subject, kid string, extra map[string]any) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": issuer, + "sub": subject, + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + for k, v := range extra { + claims[k] = v + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + if kid != "" { + tok.Header["kid"] = kid + } + signed, err := tok.SignedString(priv) + if err != nil { + t.Fatal(err) + } + return signed +} + +func TestOIDCIssuerVerifier_Valid(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "key1") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/jwks.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "key1", nil) + + v := NewOIDCIssuerVerifier([]string{issuer}) + claims, err := v.VerifyJWT(context.Background(), compactJWT) + if err != nil { + t.Fatalf("VerifyJWT: %v", err) + } + if claims["sub"] != "alice" { + t.Errorf("sub: got %v, want %q", claims["sub"], "alice") + } + if claims["iss"] != issuer { + t.Errorf("iss: got %v, want %q", claims["iss"], issuer) + } +} + +func TestOIDCIssuerVerifier_DisallowedIssuer(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + compactJWT := buildSignedJWT(t, priv, "https://untrusted.example.com", "alice", "", nil) + + v := NewOIDCIssuerVerifier([]string{"https://trusted.example.com"}) + _, err = v.VerifyJWT(context.Background(), compactJWT) + if err == nil { + t.Fatal("expected error for disallowed issuer") + } +} + +func TestOIDCIssuerVerifier_ExpiredJWT(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + claims := jwt.MapClaims{ + "iss": issuer, + "sub": "alice", + "iat": time.Now().Add(-10 * time.Minute).Unix(), + "exp": time.Now().Add(-5 * time.Minute).Unix(), // already expired + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + compactJWT, err := tok.SignedString(priv) + if err != nil { + t.Fatal(err) + } + + v := NewOIDCIssuerVerifier([]string{issuer}) + _, err = v.VerifyJWT(context.Background(), compactJWT) + if err == nil { + t.Fatal("expected error for expired JWT") + } +} + +func TestOIDCIssuerVerifier_WrongKey(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + // JWKS has otherPriv's public key, but JWT is signed with priv. + jwksBody := buildJWKSResponse(t, &otherPriv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "", nil) + + v := NewOIDCIssuerVerifier([]string{issuer}) + _, err = v.VerifyJWT(context.Background(), compactJWT) + if err == nil { + t.Fatal("expected error for wrong signing key") + } +} + +func TestOIDCIssuerVerifier_JWKSCached(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + fetchCount := 0 + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount++ + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + v := NewOIDCIssuerVerifier([]string{issuer}, WithJWKSCacheTTL(time.Minute)) + + for i := 0; i < 3; i++ { + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "", nil) + if _, err := v.VerifyJWT(context.Background(), compactJWT); err != nil { + t.Fatalf("VerifyJWT call %d: %v", i, err) + } + } + + if fetchCount != 1 { + t.Errorf("JWKS fetched %d times, want 1 (should be cached)", fetchCount) + } +} + +func TestOIDCIssuerVerifier_JWKSCacheExpiry(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + fetchCount := 0 + jwksBody := buildJWKSResponse(t, &priv.PublicKey, "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount++ + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + + now := time.Now() + v := NewOIDCIssuerVerifier([]string{issuer}, WithJWKSCacheTTL(time.Minute)) + v.nowFunc = func() time.Time { return now } + + compactJWT := buildSignedJWT(t, priv, issuer, "alice", "", nil) + if _, err := v.VerifyJWT(context.Background(), compactJWT); err != nil { + t.Fatalf("first VerifyJWT: %v", err) + } + + // Advance time past TTL. + v.nowFunc = func() time.Time { return now.Add(2 * time.Minute) } + + compactJWT = buildSignedJWT(t, priv, issuer, "alice", "", nil) + if _, err := v.VerifyJWT(context.Background(), compactJWT); err != nil { + t.Fatalf("second VerifyJWT: %v", err) + } + + if fetchCount != 2 { + t.Errorf("JWKS fetched %d times, want 2 (cache should have expired)", fetchCount) + } +} + +func TestOIDCIssuerVerifier_KidSelection(t *testing.T) { + priv1, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + priv2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + // JWKS with two keys; JWT uses kid "key2". + xBytes1 := priv1.PublicKey.X.Bytes() + yBytes1 := priv1.PublicKey.Y.Bytes() + xPadded1 := make([]byte, 32) + yPadded1 := make([]byte, 32) + copy(xPadded1[32-len(xBytes1):], xBytes1) + copy(yPadded1[32-len(yBytes1):], yBytes1) + + xBytes2 := priv2.PublicKey.X.Bytes() + yBytes2 := priv2.PublicKey.Y.Bytes() + xPadded2 := make([]byte, 32) + yPadded2 := make([]byte, 32) + copy(xPadded2[32-len(xBytes2):], xBytes2) + copy(yPadded2[32-len(yBytes2):], yBytes2) + + type jwkJSON struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + Kid string `json:"kid"` + } + set := struct { + Keys []jwkJSON `json:"keys"` + }{Keys: []jwkJSON{ + {Kty: "EC", Crv: "P-256", X: base64.RawURLEncoding.EncodeToString(xPadded1), Y: base64.RawURLEncoding.EncodeToString(yPadded1), Kid: "key1"}, + {Kty: "EC", Crv: "P-256", X: base64.RawURLEncoding.EncodeToString(xPadded2), Y: base64.RawURLEncoding.EncodeToString(yPadded2), Kid: "key2"}, + }} + jwksBody, _ := json.Marshal(set) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(jwksBody) + })) + defer server.Close() + + issuer := server.URL + // Sign with priv2, kid="key2" — verifier must select key2 not key1. + compactJWT := buildSignedJWT(t, priv2, issuer, "bob", "key2", nil) + + v := NewOIDCIssuerVerifier([]string{issuer}) + claims, err := v.VerifyJWT(context.Background(), compactJWT) + if err != nil { + t.Fatalf("VerifyJWT: %v", err) + } + if claims["sub"] != "bob" { + t.Errorf("sub: got %v, want %q", claims["sub"], "bob") + } +} diff --git a/sigkeydraft/sigkey.go b/sigkeydraft/sigkey.go new file mode 100644 index 0000000..db721af --- /dev/null +++ b/sigkeydraft/sigkey.go @@ -0,0 +1,239 @@ +// Package sigkey parses the Signature-Key HTTP header defined in +// draft-hardt-httpbis-signature-key. It has no dependency on the parent +// httpsig package and can be used standalone. +// +// This package does not perform any validation of keys or certificates and is only responsible for parsing the Signature-Key header. +package sigkeydraft + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + sfv "github.com/dunglas/httpsfv" +) + +type Scheme string + +const ( + // Header is the canonical name of the Signature-Key HTTP header. + Header = "Signature-Key" + + SchemeHWK Scheme = "hwk" + SchemeJWKSURI Scheme = "jwks_uri" + SchemeX509 Scheme = "x509" + SchemeJWT Scheme = "jwt" +) + +type ParametersHWK struct { + Kty string `sfv:"kty,omitempty"` + Crv string `sfv:"crv,omitempty"` + X string `sfv:"x,omitempty"` + Y string `sfv:"y,omitempty"` + // RSA + N string `sfv:"n,omitempty"` + E string `sfv:"e,omitempty"` +} + +type ParametersJWT struct { + JWT string `sfv:"jwt"` +} + +type ParametersJWKSURI struct { + JWKSURI string `sfv:"jwks_uri"` + KID string `sfv:"kid,omitempty"` + WellKnown string `sfv:"well-known,omitempty"` +} + +type ParametersX509 struct { + X5U string `sfv:"x5u"` + X5T string `sfv:"x5t,omitempty"` +} + +// SigKeyHeader is one parsed entry from the Signature-Key header dictionary. +// Each entry corresponds to a single signature label. +type SigKeyHeader struct { + // Label is the signature label (dictionary key), e.g. "sig1". + Label string + // Scheme is the key-transport scheme token, e.g. "jwt". + Scheme Scheme + // params holds the raw SFV parameters for scheme-specific values. + params *sfv.Params +} + +// NewSigKey constructs a SigKeyHeader from a label, scheme, and one of the +// Parameters structs. The resulting value can be serialized into a +// Signature-Key header value via DeriveHeader. +func NewSigKey(label string, scheme Scheme, params any) (SigKeyHeader, error) { + item := sfv.NewItem(sfv.Token(scheme)) + if err := structToSFVParams(params, item.Params); err != nil { + return SigKeyHeader{}, fmt.Errorf("sigkey: %w", err) + } + return SigKeyHeader{ + Label: label, + Scheme: scheme, + params: item.Params, + }, nil +} + +// ParseHeader parses a Signature-Key header value as an SFV Dictionary and +// returns a map from signature label to SigKey. Returns an error if the value +// is empty, not a valid SFV dictionary, or any entry has a malformed scheme. +func ParseHeader(headerValue string) (map[string]SigKeyHeader, error) { + dict, err := sfv.UnmarshalDictionary([]string{headerValue}) + if err != nil { + return nil, fmt.Errorf("sigkey: failed to parse Signature-Key header as SFV dictionary: %w", err) + } + + names := dict.Names() + if len(names) == 0 { + return nil, fmt.Errorf("sigkey: Signature-Key header is empty") + } + + entries := make(map[string]SigKeyHeader, len(names)) + for _, label := range names { + member, _ := dict.Get(label) + item, ok := member.(sfv.Item) + if !ok { + return nil, fmt.Errorf("sigkey: entry %q must be an SFV Item, got %T", label, member) + } + + var scheme Scheme + switch v := item.Value.(type) { + case sfv.Token: + scheme = Scheme(v) + case string: + scheme = Scheme(v) + default: + return nil, fmt.Errorf("sigkey: scheme for entry %q must be a token or string, got %T", label, item.Value) + } + + entries[label] = SigKeyHeader{ + Label: label, + Scheme: scheme, + params: item.Params, + } + } + return entries, nil +} + +// Param returns the raw SFV parameter value for the given name, along with +// whether it was present. This provides access to scheme-specific parameters +// (e.g. "jwt") without the caller needing to know the SFV types. +func (e SigKeyHeader) Param(name string) (any, bool) { + return e.params.Get(name) +} + +// StringParam returns the named parameter as a string. Returns an error if +// the parameter is absent or is not a string value. +func (e SigKeyHeader) StringParam(name string) (string, error) { + v, ok := e.params.Get(name) + if !ok { + return "", fmt.Errorf("sigkey: entry %q has no parameter %q", e.Label, name) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("sigkey: entry %q parameter %q has unexpected type %T", e.Label, name, v) + } + return s, nil +} + +// JWT returns the unvalidated JWT string from the entry's parameters. +// Convenience wrapper for StringParam(SchemeJWT). +func (e SigKeyHeader) JWT() (string, error) { + s, err := e.StringParam(string(SchemeJWT)) + if err != nil { + return "", fmt.Errorf("sigkey: entry %q is missing required 'jwt' parameter: %w", e.Label, err) + } + return s, nil +} + +// JWKSURI returns the JWKS URI from the entry's parameters. +// Convenience wrapper for StringParam(SchemeHWKSURI). +func (e SigKeyHeader) JWKSURI() (string, error) { + s, err := e.StringParam(string(SchemeJWKSURI)) + if err != nil { + return "", fmt.Errorf("sigkey: entry %q is missing required 'hwks_uri' parameter: %w", e.Label, err) + } + return s, nil +} + +func (skh SigKeyHeader) SetHeader(h http.Header) error { + // h.Set(Header, skh.DeriveHeader(h.Get(Header)) + updated, err := skh.DeriveHeader(h.Get(Header)) + if err != nil { + return err + } + h.Set(Header, updated) + return nil +} + +// DeriveHeader returns the value to set for a Signature-Key header, adding or +// replacing the entry for label. existing is the current header value (empty +// string if the header is not yet set). scheme is the key-transport token +// (e.g. SchemeJWT). params must be a pointer to or value of one of the +// Parameters structs (ParametersJWT, ParametersHWK, ParametersJWKSURI, +// ParametersX509). Fields are mapped to SFV parameters using the "sfv" struct +// tag; fields tagged with "omitempty" are skipped when zero. +func (skh SigKeyHeader) DeriveHeader(existingHeader string) (string, error) { + var dict *sfv.Dictionary + if existingHeader != "" { + var err error + dict, err = sfv.UnmarshalDictionary([]string{existingHeader}) + if err != nil { + return "", fmt.Errorf("sigkey: failed to parse existing Signature-Key header: %w", err) + } + } else { + dict = sfv.NewDictionary() + } + + item := sfv.NewItem(sfv.Token(skh.Scheme)) + for _, name := range skh.params.Names() { + v, _ := skh.params.Get(name) + item.Params.Add(name, v) + } + dict.Add(skh.Label, item) + + value, err := sfv.Marshal(dict) + if err != nil { + return "", fmt.Errorf("sigkey: failed to marshal Signature-Key header: %w", err) + } + return value, nil +} + +// structToSFVParams populates p from the exported fields of v using "sfv" +// struct tags. The tag format is `sfv:"name"` or `sfv:"name,omitempty"`. +// Only string fields are supported; other types return an error. +func structToSFVParams(v any, p *sfv.Params) error { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return fmt.Errorf("params must be a struct, got %T", v) + } + rt := rv.Type() + for i := range rt.NumField() { + field := rt.Field(i) + tag := field.Tag.Get("sfv") + if tag == "" || tag == "-" { + continue + } + name, opts, _ := strings.Cut(tag, ",") + omitempty := opts == "omitempty" + + fv := rv.Field(i) + if omitempty && fv.IsZero() { + continue + } + + switch fv.Kind() { + case reflect.String: + p.Add(name, fv.String()) + default: + return fmt.Errorf("unsupported field type %s for param %q", fv.Type(), name) + } + } + return nil +} diff --git a/sigkeydraft/sigkey_test.go b/sigkeydraft/sigkey_test.go new file mode 100644 index 0000000..e9ebf6b --- /dev/null +++ b/sigkeydraft/sigkey_test.go @@ -0,0 +1,253 @@ +package sigkeydraft_test + +import ( + "testing" + + sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" +) + +func TestParseHeader(t *testing.T) { + tests := []struct { + name string + header string + wantErr bool + wantLabels []string + wantSchemes map[string]sigkey.Scheme + }{ + { + name: "single jwt entry", + header: `sig1=jwt;jwt="eyJ.payload.sig"`, + wantLabels: []string{"sig1"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT}, + }, + { + name: "multiple entries", + header: `sig1=jwt;jwt="tok1", sig2=jwt;jwt="tok2"`, + wantLabels: []string{"sig1", "sig2"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT, "sig2": sigkey.SchemeJWT}, + }, + { + name: "jwks_uri entry", + header: `sig1=jwks_uri;jwks_uri="https://device.example.com/.well-known/jwks.json"`, + wantLabels: []string{"sig1"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWKSURI}, + }, + { + name: "mixed jwt and jwks_uri", + header: `sig1=jwt;jwt="eyJ.payload.sig", sig2=jwks_uri;jwks_uri="https://device.example.com/.well-known/jwks.json"`, + wantLabels: []string{"sig1", "sig2"}, + wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT, "sig2": sigkey.SchemeJWKSURI}, + }, + { + name: "empty header", + header: "", + wantErr: true, + }, + { + name: "invalid sfv", + header: "!!!", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + entries, err := sigkey.ParseHeader(tc.header) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, label := range tc.wantLabels { + e, ok := entries[label] + if !ok { + t.Errorf("missing entry for label %q", label) + continue + } + if wantScheme, ok := tc.wantSchemes[label]; ok { + if e.Scheme != wantScheme { + t.Errorf("entry %q scheme: got %q, want %q", label, e.Scheme, wantScheme) + } + } + } + }) + } +} + +func TestSigKeyJWT(t *testing.T) { + header := `sig1=jwt;jwt="eyJ.payload.sig"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e := entries["sig1"] + got, err := e.JWT() + if err != nil { + t.Fatalf("JWT(): %v", err) + } + if got != "eyJ.payload.sig" { + t.Errorf("JWT() = %q, want %q", got, "eyJ.payload.sig") + } +} + +func TestSigKeyJWT_Missing(t *testing.T) { + // A valid entry with no jwt param + header := `sig1=jwt` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + _, err = entries["sig1"].JWT() + if err == nil { + t.Fatal("expected error for missing jwt param, got nil") + } +} + +func TestSigKeyJWKSURI(t *testing.T) { + const wantURI = "https://device.example.com/.well-known/jwks.json" + header := `sig1=jwks_uri;jwks_uri="` + wantURI + `"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + got, err := entries["sig1"].JWKSURI() + if err != nil { + t.Fatalf("JWKSURI(): %v", err) + } + if got != wantURI { + t.Errorf("JWKSURI() = %q, want %q", got, wantURI) + } +} + +func TestSigKeyJWKSURI_Missing(t *testing.T) { + header := `sig1=jwks_uri` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + _, err = entries["sig1"].JWKSURI() + if err == nil { + t.Fatal("expected error for missing jwks_uri param, got nil") + } +} + +func TestSigKeyStringParam(t *testing.T) { + header := `sig1=jwt;jwt="mytoken"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + got, err := entries["sig1"].StringParam("jwt") + if err != nil { + t.Fatalf("StringParam: %v", err) + } + if got != "mytoken" { + t.Errorf("StringParam() = %q, want %q", got, "mytoken") + } +} + +func TestDeriveHeader_JWT(t *testing.T) { + sk, err := sigkey.NewSigKey("sig1", sigkey.SchemeJWT, sigkey.ParametersJWT{JWT: "eyJ.payload.sig"}) + if err != nil { + t.Fatalf("NewSigKey: %v", err) + } + value, err := sk.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader: %v", err) + } + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e, ok := entries["sig1"] + if !ok { + t.Fatal("missing entry for sig1") + } + if e.Scheme != sigkey.SchemeJWT { + t.Errorf("scheme: got %q, want %q", e.Scheme, sigkey.SchemeJWT) + } + got, err := e.JWT() + if err != nil { + t.Fatalf("JWT(): %v", err) + } + if got != "eyJ.payload.sig" { + t.Errorf("JWT() = %q, want %q", got, "eyJ.payload.sig") + } +} + +func TestDeriveHeader_AppendToExisting(t *testing.T) { + sk1, err := sigkey.NewSigKey("sig1", sigkey.SchemeJWT, sigkey.ParametersJWT{JWT: "token1"}) + if err != nil { + t.Fatalf("NewSigKey sig1: %v", err) + } + value, err := sk1.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader sig1: %v", err) + } + + const wantURI = "https://example.com/.well-known/jwks.json" + sk2, err := sigkey.NewSigKey("sig2", sigkey.SchemeJWKSURI, sigkey.ParametersJWKSURI{JWKSURI: wantURI}) + if err != nil { + t.Fatalf("NewSigKey sig2: %v", err) + } + value, err = sk2.DeriveHeader(value) + if err != nil { + t.Fatalf("DeriveHeader sig2: %v", err) + } + + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if tok, _ := entries["sig1"].JWT(); tok != "token1" { + t.Errorf("sig1 jwt = %q, want %q", tok, "token1") + } + if uri, _ := entries["sig2"].JWKSURI(); uri != wantURI { + t.Errorf("sig2 jwks_uri = %q, want %q", uri, wantURI) + } +} + +func TestDeriveHeader_HWK_OmitsEmptyFields(t *testing.T) { + sk, err := sigkey.NewSigKey("sig1", sigkey.SchemeHWK, sigkey.ParametersHWK{ + Kty: "EC", + Crv: "P-256", + X: "someXvalue", + Y: "someYvalue", + }) + if err != nil { + t.Fatalf("NewSigKey: %v", err) + } + value, err := sk.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader: %v", err) + } + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e := entries["sig1"] + for _, name := range []string{"kty", "crv", "x", "y"} { + if _, ok := e.Param(name); !ok { + t.Errorf("missing param %q", name) + } + } + // RSA fields should be absent + for _, name := range []string{"n", "e"} { + if _, ok := e.Param(name); ok { + t.Errorf("param %q should be absent (omitempty)", name) + } + } +} + +func TestNewSigKey_NonStructError(t *testing.T) { + if _, err := sigkey.NewSigKey("sig1", sigkey.SchemeJWT, "not-a-struct"); err == nil { + t.Fatal("expected error for non-struct params") + } +} diff --git a/sigkeydraft/signaturekey_test.go b/sigkeydraft/signaturekey_test.go new file mode 100644 index 0000000..577160a --- /dev/null +++ b/sigkeydraft/signaturekey_test.go @@ -0,0 +1,69 @@ +package sigkeydraft_test + +import ( + "net/http" + "testing" + + httpsig "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/key" + sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" +) + +// TestSignatureKeyRoundTrip signs a request then verifies it using +// SignatureKeyFetcher, exercising the full sign→verify pipeline. +func TestSignatureKeyRoundTrip(t *testing.T) { + srv := newSigkeyServer(t) + + jwkMap, signingPriv := ecJWKMap(t) + + claims := srv.standardClaims("alice") + claims["cnf"] = map[string]any{"jwk": jwkMap} + compactJWT := srv.signJWT(t, claims) + + req, err := http.NewRequest("GET", "https://example.com/path", nil) + if err != nil { + t.Fatal(err) + } + + profile := httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.Fields("@method", "@target-uri"), + Metadata: []httpsig.Metadata{httpsig.MetaCreated}, + Label: "sig1", + } + if err := httpsig.Sign(req, profile, httpsig.SigningKey{Key: signingPriv}); err != nil { + t.Fatalf("Sign: %v", err) + } + req.Header.Set("Signature-Key", makeSignatureKeyHeader("sig1", compactJWT)) + + fetcher := sigkey.NewSignatureKeyFetcher("sig1", sigkey.NewOIDCIssuerVerifier([]string{srv.issuer()})) + vp := httpsig.VerifyProfile{ + SignatureLabel: "sig1", + RequiredFields: httpsig.Fields("@method", "@target-uri"), + RequiredMetadata: []httpsig.Metadata{httpsig.MetaCreated}, + AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_ECDSA_P256_SHA256}, + DisableTimeEnforcement: true, + } + + result, err := httpsig.Verify(req, fetcher, vp) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !result.Verified { + t.Fatal("expected Verified=true") + } + + spec, err := result.KeySpecer.KeySpec() + if err != nil { + t.Fatalf("KeySpec: %v", err) + } + if spec.Identity.Issuer != srv.issuer() { + t.Errorf("Issuer: got %q, want %q", spec.Identity.Issuer, srv.issuer()) + } + if spec.Identity.Identity != "alice" { + t.Errorf("Identity: got %q, want %q", spec.Identity.Identity, "alice") + } + if spec.Identity.IssuerType != key.IssuerIDP { + t.Errorf("IssuerType: got %q, want %q", spec.Identity.IssuerType, key.IssuerIDP) + } +} diff --git a/sign.go b/sign.go index a55e0ba..ac363bd 100644 --- a/sign.go +++ b/sign.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "sync" "time" "unicode" @@ -88,16 +89,36 @@ type SigningKey struct { Secret []byte // Secret to use for symmetric algorithms Opts SigningKeyOpts // Options for advanced signing use cases like TPMs. // Meta fields - MetaKeyID string // 'keyid' - Only used if 'keyid' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. Metadata. - MetaTag string // 'tag'. Only used if 'tag' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. + MetaKeyID string // 'keyid' - Only used if 'keyid' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. Metadata. + MetaTag string // 'tag'. Only used if 'tag' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. + Expiration time.Time // Optional expiration time. If expired when Sign is called and a SigningKeyGenerator is available a new key is generated. +} + +func (skey *SigningKey) Expired() bool { + if skey.Expiration.IsZero() { + return false + } + + return time.Now().After(skey.Expiration) } type SigningKeyOpts struct { Signer crypto.Signer // crypto.Signer interface for TPMs and other custom use cases. ASN1ForECDSA bool // Set to true to indicate the crypto.Signer returns ASN.1 formatted signatures for ECDSA algorithms. False (default) indicates ECDSA signatures are concatenated R and S parameters as per the HTTP Signatures spec. + + // PresignHeaders is called before signing to allow header manipulation if not nil. + // It is intended for setting information about the signing key in the request. + PresignHeaders func(http.Header) error } + +type SigningKeyGenerator interface { + GenerateKey(SigningProfile) (SigningKey, error) +} + type Signer struct { profile SigningProfile + keygen SigningKeyGenerator + mu sync.Mutex skey SigningKey } @@ -115,6 +136,24 @@ func NewSigner(profile SigningProfile, skey SigningKey) (*Signer, error) { return s, nil } +func NewSignerWithKeyGenerator(profile SigningProfile, keygen SigningKeyGenerator) (*Signer, error) { + opts := profile.withDefaults() + skey, err := keygen.GenerateKey(profile) + if err != nil { + return nil, fmt.Errorf("Failed to generate signing key: %w", err) + } + err = profile.validate(skey) + if err != nil { + return nil, err + } + s := &Signer{ + profile: opts, + keygen: keygen, + skey: skey, + } + return s, nil +} + func Sign(req *http.Request, params SigningProfile, skey SigningKey) error { s, err := NewSigner(params, skey) if err != nil { @@ -140,42 +179,50 @@ func (s *Signer) Sign(req *http.Request) error { req.Header.Set("Content-Digest", digestValue) } - baseParams, err := s.baseParameters() - if err != nil { - return err - } - - return sign( - httpMessage{ - Req: req, - }, sigParameters{ - Base: baseParams, - Algo: s.profile.Algorithm, - PrivateKey: s.skey.Key, - Secret: s.skey.Secret, - Opts: s.skey.Opts, - Label: s.profile.Label, - }) + return s.internalSign(httpMessage{ + Req: req, + }) } func (s *Signer) SignResponse(resp *http.Response) error { + return s.internalSign(httpMessage{ + IsResponse: true, + Resp: resp, + }) +} + +func (s *Signer) internalSign(msg httpMessage) error { baseParams, err := s.baseParameters() if err != nil { return err } - return sign( - httpMessage{ - IsResponse: true, - Resp: resp, - }, sigParameters{ - Base: baseParams, - Algo: s.profile.Algorithm, - PrivateKey: s.skey.Key, - Secret: s.skey.Secret, - Opts: s.skey.Opts, - Label: s.profile.Label, - }) + // Generate a new key if the current one is expied. + s.mu.Lock() + if s.skey.Expired() { + newKey, err := s.keygen.GenerateKey(s.profile) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to generate signing key: %w", err) + } + s.skey = newKey + } + skey := s.skey + s.mu.Unlock() + + // Presign headers to allow for setting signing key information. + if s.skey.Opts.PresignHeaders != nil { + s.skey.Opts.PresignHeaders(msg.Headers()) + } + + return sign(msg, sigParameters{ + Base: baseParams, + Algo: s.profile.Algorithm, + PrivateKey: skey.Key, + Secret: skey.Secret, + Opts: skey.Opts, + Label: s.profile.Label, + }) } func (s *Signer) baseParameters() (sigBaseInput, error) { diff --git a/verify.go b/verify.go index 03825b5..22b40c3 100644 --- a/verify.go +++ b/verify.go @@ -43,11 +43,15 @@ var ( ) // Re-exported from key package for backwards compatibility. +//go:fix inline type KeySpec = key.KeySpec +//go:fix inline type KeySpecer = key.KeySpecer +//go:fix inline type KeyFetcher = key.KeyFetcher // MetadataProvider re-exported from types package for backwards compatibility. +//go:fix inline type MetadataProvider = types.MetadataProvider type KeyErrorReason string From a3fe92f5df823f06884c10cdedbafe17a96dcc65 Mon Sep 17 00:00:00 2001 From: Lee Date: Thu, 16 Apr 2026 21:59:57 -0700 Subject: [PATCH 3/3] Update to latest Signature Key draft --- accept.go | 36 +++++-- accept_test.go | 55 +++++++++++ sigkeydraft/fetcher.go | 2 +- sigkeydraft/sigerror.go | 119 ++++++++++++++++++++++ sigkeydraft/sigkey.go | 56 ++++++++--- sigkeydraft/sigkey_test.go | 196 ++++++++++++++++++++++++++++++++++--- 6 files changed, 432 insertions(+), 32 deletions(-) create mode 100644 sigkeydraft/sigerror.go diff --git a/accept.go b/accept.go index eb9f618..91b7983 100644 --- a/accept.go +++ b/accept.go @@ -6,11 +6,20 @@ import ( sfv "github.com/dunglas/httpsfv" ) +type AcceptSigkeyParam string + +const ( + SigkeyParamJKT AcceptSigkeyParam = "jkt" + SigkeyParamURI AcceptSigkeyParam = "uri" + SigkeyParamX509 AcceptSigkeyParam = "x509" +) + type AcceptSignature struct { - Profile SigningProfile - MetaNonce string // 'nonce' - MetaKeyID string // 'keyid' - MetaTag string // 'tag' - No default. A value must be provided if the parameter is in Metadata. + Profile SigningProfile + MetaNonce string // 'nonce' + MetaKeyID string // 'keyid' + MetaTag string // 'tag' - No default. A value must be provided if the parameter is in Metadata. + SigkeyParam AcceptSigkeyParam // 'sigkey' - key transport requirement from draft-hardt-httpbis-signature-key } func ParseAcceptSignature(acceptHeader string) (AcceptSignature, error) { @@ -48,18 +57,33 @@ func ParseAcceptSignature(acceptHeader string) (AcceptSignature, error) { md := metadataProviderFromParams{profileList.Params} for _, meta := range profileList.Params.Names() { - as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) switch Metadata(meta) { case MetaNonce: + as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) as.MetaNonce, _ = md.Nonce() case MetaAlgorithm: + as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) alg, _ := md.Alg() as.Profile.Algorithm = Algorithm(alg) case MetaKeyID: + as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) as.MetaKeyID, _ = md.KeyID() case MetaTag: + as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) as.MetaTag, _ = md.Tag() - + default: + if meta == "sigkey" { + if v, ok := profileList.Params.Get("sigkey"); ok { + switch val := v.(type) { + case sfv.Token: + as.SigkeyParam = AcceptSigkeyParam(val) + case string: + as.SigkeyParam = AcceptSigkeyParam(val) + } + } + } else { + as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) + } } } diff --git a/accept_test.go b/accept_test.go index 30eb974..0d1586d 100644 --- a/accept_test.go +++ b/accept_test.go @@ -49,6 +49,61 @@ func TestAcceptParseSignature(t *testing.T) { AcceptHeader: `sig1=("@method" 1 "@authority" "content-digest" "cache-control");keyid="test-key-rsa-pss";created;tag="app-123"`, ExpectedErrCode: ErrInvalidAcceptSignature, }, + { + Name: "SigkeyJKT", + Desc: "sigkey=jkt token parameter is parsed and not added to Profile.Metadata", + AcceptHeader: `sig1=("@method" "@path" "@authority");sigkey=jkt`, + Expected: AcceptSignature{ + SigkeyParam: SigkeyParamJKT, + Profile: SigningProfile{ + Fields: Fields("@method", "@path", "@authority"), + Metadata: []Metadata{}, + Label: "sig1", + }, + }, + }, + { + Name: "SigkeyURI", + Desc: "sigkey=uri token parameter is parsed", + AcceptHeader: `sig1=("@method" "@authority" "@path");alg="ecdsa-p256-sha256";sigkey=uri`, + Expected: AcceptSignature{ + SigkeyParam: SigkeyParamURI, + Profile: SigningProfile{ + Fields: Fields("@method", "@authority", "@path"), + Metadata: []Metadata{"alg"}, + Algorithm: Algo_ECDSA_P256_SHA256, + Label: "sig1", + }, + }, + }, + { + Name: "SigkeyX509", + Desc: "sigkey=x509 token parameter is parsed", + AcceptHeader: `sig1=("@method");sigkey=x509`, + Expected: AcceptSignature{ + SigkeyParam: SigkeyParamX509, + Profile: SigningProfile{ + Fields: Fields("@method"), + Metadata: []Metadata{}, + Label: "sig1", + }, + }, + }, + { + Name: "SigkeyWithOtherParams", + Desc: "sigkey alongside nonce and tag", + AcceptHeader: `sig1=("@method");nonce="abc123";tag="myapp";sigkey=jkt`, + Expected: AcceptSignature{ + MetaNonce: "abc123", + MetaTag: "myapp", + SigkeyParam: SigkeyParamJKT, + Profile: SigningProfile{ + Fields: Fields("@method"), + Metadata: []Metadata{"nonce", "tag"}, + Label: "sig1", + }, + }, + }, } for _, tc := range testcases { diff --git a/sigkeydraft/fetcher.go b/sigkeydraft/fetcher.go index 9ac0425..bf8ef87 100644 --- a/sigkeydraft/fetcher.go +++ b/sigkeydraft/fetcher.go @@ -13,7 +13,7 @@ import ( ) // SignatureKeyFetcher implements key.KeyFetcher by resolving keys from the -// Signature-Key header using draft-hardt-httpbis-signature-key-02. +// Signature-Key header using draft-hardt-httpbis-signature-key-04. // // Only the jwt scheme is supported. For each verified signature label the // fetcher validates the JWT via OIDCIssuerVerifier, extracts the public key diff --git a/sigkeydraft/sigerror.go b/sigkeydraft/sigerror.go new file mode 100644 index 0000000..25a6367 --- /dev/null +++ b/sigkeydraft/sigerror.go @@ -0,0 +1,119 @@ +package sigkeydraft + +import ( + "fmt" + "net/http" + + sfv "github.com/dunglas/httpsfv" +) + +// SignatureErrorCode is a token value for the Signature-Error response header. +type SignatureErrorCode string + +const ( + // SignatureErrorHeader is the canonical name of the Signature-Error HTTP response header. + SignatureErrorHeader = "Signature-Error" + + ErrCodeUnsupportedAlgorithm SignatureErrorCode = "unsupported_algorithm" + ErrCodeInvalidSignature SignatureErrorCode = "invalid_signature" + ErrCodeInvalidInput SignatureErrorCode = "invalid_input" + ErrCodeInvalidRequest SignatureErrorCode = "invalid_request" + ErrCodeInvalidKey SignatureErrorCode = "invalid_key" + ErrCodeUnknownKey SignatureErrorCode = "unknown_key" + ErrCodeInvalidJWT SignatureErrorCode = "invalid_jwt" + ErrCodeExpiredJWT SignatureErrorCode = "expired_jwt" +) + +// SignatureError represents a parsed Signature-Error response header. +type SignatureError struct { + // Code is the required error token. + Code SignatureErrorCode + // SupportedAlgorithms is populated for ErrCodeUnsupportedAlgorithm errors. + SupportedAlgorithms []string + // RequiredInput is optionally populated for ErrCodeInvalidInput errors. + RequiredInput []string +} + +// ParseSignatureError parses the value of a Signature-Error response header. +func ParseSignatureError(headerValue string) (SignatureError, error) { + dict, err := sfv.UnmarshalDictionary([]string{headerValue}) + if err != nil { + return SignatureError{}, fmt.Errorf("sigkey: failed to parse Signature-Error header: %w", err) + } + + member, ok := dict.Get("error") + if !ok { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error header missing required 'error' member") + } + + item, ok := member.(sfv.Item) + if !ok { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error 'error' member must be an Item, got %T", member) + } + + tok, ok := item.Value.(sfv.Token) + if !ok { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error 'error' value must be a token, got %T", item.Value) + } + + se := SignatureError{Code: SignatureErrorCode(tok)} + + if m, ok2 := dict.Get("supported_algorithms"); ok2 { + il, ok3 := m.(sfv.InnerList) + if !ok3 { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error 'supported_algorithms' must be an Inner List") + } + for _, algItem := range il.Items { + s, ok4 := algItem.Value.(string) + if !ok4 { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error 'supported_algorithms' contains non-string item") + } + se.SupportedAlgorithms = append(se.SupportedAlgorithms, s) + } + } + + if m, ok2 := dict.Get("required_input"); ok2 { + il, ok3 := m.(sfv.InnerList) + if !ok3 { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error 'required_input' must be an Inner List") + } + for _, inputItem := range il.Items { + s, ok4 := inputItem.Value.(string) + if !ok4 { + return SignatureError{}, fmt.Errorf("sigkey: Signature-Error 'required_input' contains non-string item") + } + se.RequiredInput = append(se.RequiredInput, s) + } + } + + return se, nil +} + +// SetSignatureError sets the Signature-Error response header on h. +func SetSignatureError(h http.Header, se SignatureError) error { + dict := sfv.NewDictionary() + dict.Add("error", sfv.NewItem(sfv.Token(se.Code))) + + if len(se.SupportedAlgorithms) > 0 { + il := sfv.InnerList{Params: sfv.NewParams()} + for _, alg := range se.SupportedAlgorithms { + il.Items = append(il.Items, sfv.NewItem(alg)) + } + dict.Add("supported_algorithms", il) + } + + if len(se.RequiredInput) > 0 { + il := sfv.InnerList{Params: sfv.NewParams()} + for _, inp := range se.RequiredInput { + il.Items = append(il.Items, sfv.NewItem(inp)) + } + dict.Add("required_input", il) + } + + value, err := sfv.Marshal(dict) + if err != nil { + return fmt.Errorf("sigkey: failed to marshal Signature-Error header: %w", err) + } + h.Set(SignatureErrorHeader, value) + return nil +} diff --git a/sigkeydraft/sigkey.go b/sigkeydraft/sigkey.go index db721af..d7531b3 100644 --- a/sigkeydraft/sigkey.go +++ b/sigkeydraft/sigkey.go @@ -24,6 +24,7 @@ const ( SchemeJWKSURI Scheme = "jwks_uri" SchemeX509 Scheme = "x509" SchemeJWT Scheme = "jwt" + SchemeJKTJWT Scheme = "jkt-jwt" ) type ParametersHWK struct { @@ -40,15 +41,30 @@ type ParametersJWT struct { JWT string `sfv:"jwt"` } +// ParametersJKTJWT are the parameters for the jkt-jwt scheme. The JWT must +// have its signing key in the jwk header parameter and its typ must be +// "jkt-s256+jwt" or "jkt-s512+jwt". +type ParametersJKTJWT struct { + JWT string `sfv:"jwt"` +} + +// ParametersJWKSURI are the parameters for the jwks_uri scheme. +// The verifier fetches {ID}/.well-known/{DWK} to obtain the JWKS URI, +// then retrieves the key matching KID. type ParametersJWKSURI struct { - JWKSURI string `sfv:"jwks_uri"` - KID string `sfv:"kid,omitempty"` - WellKnown string `sfv:"well-known,omitempty"` + // ID is the signer identifier (HTTPS URL). + ID string `sfv:"id"` + // DWK is the dot well-known metadata document name under /.well-known/. + DWK string `sfv:"dwk"` + // KID is the key identifier within the JWKS. + KID string `sfv:"kid"` } type ParametersX509 struct { X5U string `sfv:"x5u"` - X5T string `sfv:"x5t,omitempty"` + // X5T is the base64url-encoded SHA-256 hash of the DER-encoded + // end-entity certificate. Required per draft-04. + X5T string `sfv:"x5t"` } // SigKeyHeader is one parsed entry from the Signature-Key header dictionary. @@ -140,21 +156,39 @@ func (e SigKeyHeader) StringParam(name string) (string, error) { } // JWT returns the unvalidated JWT string from the entry's parameters. -// Convenience wrapper for StringParam(SchemeJWT). +// Used for both the jwt and jkt-jwt schemes, both of which carry the JWT in +// the "jwt" parameter. func (e SigKeyHeader) JWT() (string, error) { - s, err := e.StringParam(string(SchemeJWT)) + s, err := e.StringParam("jwt") if err != nil { return "", fmt.Errorf("sigkey: entry %q is missing required 'jwt' parameter: %w", e.Label, err) } return s, nil } -// JWKSURI returns the JWKS URI from the entry's parameters. -// Convenience wrapper for StringParam(SchemeHWKSURI). -func (e SigKeyHeader) JWKSURI() (string, error) { - s, err := e.StringParam(string(SchemeJWKSURI)) +// ID returns the signer identifier (HTTPS URL) from a jwks_uri entry. +func (e SigKeyHeader) ID() (string, error) { + s, err := e.StringParam("id") + if err != nil { + return "", fmt.Errorf("sigkey: entry %q is missing required 'id' parameter: %w", e.Label, err) + } + return s, nil +} + +// DWK returns the dot well-known metadata document name from a jwks_uri entry. +func (e SigKeyHeader) DWK() (string, error) { + s, err := e.StringParam("dwk") + if err != nil { + return "", fmt.Errorf("sigkey: entry %q is missing required 'dwk' parameter: %w", e.Label, err) + } + return s, nil +} + +// KID returns the key identifier from a jwks_uri entry. +func (e SigKeyHeader) KID() (string, error) { + s, err := e.StringParam("kid") if err != nil { - return "", fmt.Errorf("sigkey: entry %q is missing required 'hwks_uri' parameter: %w", e.Label, err) + return "", fmt.Errorf("sigkey: entry %q is missing required 'kid' parameter: %w", e.Label, err) } return s, nil } diff --git a/sigkeydraft/sigkey_test.go b/sigkeydraft/sigkey_test.go index e9ebf6b..fdf5f48 100644 --- a/sigkeydraft/sigkey_test.go +++ b/sigkeydraft/sigkey_test.go @@ -1,6 +1,7 @@ package sigkeydraft_test import ( + "net/http" "testing" sigkey "github.com/remitly-oss/httpsig-go/sigkeydraft" @@ -28,13 +29,13 @@ func TestParseHeader(t *testing.T) { }, { name: "jwks_uri entry", - header: `sig1=jwks_uri;jwks_uri="https://device.example.com/.well-known/jwks.json"`, + header: `sig1=jwks_uri;id="https://client.example";dwk="example-configuration";kid="key-1"`, wantLabels: []string{"sig1"}, wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWKSURI}, }, { name: "mixed jwt and jwks_uri", - header: `sig1=jwt;jwt="eyJ.payload.sig", sig2=jwks_uri;jwks_uri="https://device.example.com/.well-known/jwks.json"`, + header: `sig1=jwt;jwt="eyJ.payload.sig", sig2=jwks_uri;id="https://client.example";dwk="example-configuration";kid="key-1"`, wantLabels: []string{"sig1", "sig2"}, wantSchemes: map[string]sigkey.Scheme{"sig1": sigkey.SchemeJWT, "sig2": sigkey.SchemeJWKSURI}, }, @@ -108,18 +109,34 @@ func TestSigKeyJWT_Missing(t *testing.T) { } func TestSigKeyJWKSURI(t *testing.T) { - const wantURI = "https://device.example.com/.well-known/jwks.json" - header := `sig1=jwks_uri;jwks_uri="` + wantURI + `"` + const wantID = "https://client.example" + const wantDWK = "example-configuration" + const wantKID = "key-1" + header := `sig1=jwks_uri;id="` + wantID + `";dwk="` + wantDWK + `";kid="` + wantKID + `"` entries, err := sigkey.ParseHeader(header) if err != nil { t.Fatalf("ParseHeader: %v", err) } - got, err := entries["sig1"].JWKSURI() + gotID, err := entries["sig1"].ID() if err != nil { - t.Fatalf("JWKSURI(): %v", err) + t.Fatalf("ID(): %v", err) } - if got != wantURI { - t.Errorf("JWKSURI() = %q, want %q", got, wantURI) + if gotID != wantID { + t.Errorf("ID() = %q, want %q", gotID, wantID) + } + gotDWK, err := entries["sig1"].DWK() + if err != nil { + t.Fatalf("DWK(): %v", err) + } + if gotDWK != wantDWK { + t.Errorf("DWK() = %q, want %q", gotDWK, wantDWK) + } + gotKID, err := entries["sig1"].KID() + if err != nil { + t.Fatalf("KID(): %v", err) + } + if gotKID != wantKID { + t.Errorf("KID() = %q, want %q", gotKID, wantKID) } } @@ -129,9 +146,9 @@ func TestSigKeyJWKSURI_Missing(t *testing.T) { if err != nil { t.Fatalf("ParseHeader: %v", err) } - _, err = entries["sig1"].JWKSURI() + _, err = entries["sig1"].ID() if err == nil { - t.Fatal("expected error for missing jwks_uri param, got nil") + t.Fatal("expected error for missing id param, got nil") } } @@ -189,8 +206,10 @@ func TestDeriveHeader_AppendToExisting(t *testing.T) { t.Fatalf("DeriveHeader sig1: %v", err) } - const wantURI = "https://example.com/.well-known/jwks.json" - sk2, err := sigkey.NewSigKey("sig2", sigkey.SchemeJWKSURI, sigkey.ParametersJWKSURI{JWKSURI: wantURI}) + const wantID = "https://example.com" + const wantDWK = "example-configuration" + const wantKID = "key-1" + sk2, err := sigkey.NewSigKey("sig2", sigkey.SchemeJWKSURI, sigkey.ParametersJWKSURI{ID: wantID, DWK: wantDWK, KID: wantKID}) if err != nil { t.Fatalf("NewSigKey sig2: %v", err) } @@ -209,8 +228,8 @@ func TestDeriveHeader_AppendToExisting(t *testing.T) { if tok, _ := entries["sig1"].JWT(); tok != "token1" { t.Errorf("sig1 jwt = %q, want %q", tok, "token1") } - if uri, _ := entries["sig2"].JWKSURI(); uri != wantURI { - t.Errorf("sig2 jwks_uri = %q, want %q", uri, wantURI) + if id, _ := entries["sig2"].ID(); id != wantID { + t.Errorf("sig2 id = %q, want %q", id, wantID) } } @@ -251,3 +270,152 @@ func TestNewSigKey_NonStructError(t *testing.T) { t.Fatal("expected error for non-struct params") } } + +func TestParseHeader_JKTJWTScheme(t *testing.T) { + header := `sig1=jkt-jwt;jwt="eyJ.payload.sig"` + entries, err := sigkey.ParseHeader(header) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e, ok := entries["sig1"] + if !ok { + t.Fatal("missing entry for sig1") + } + if e.Scheme != sigkey.SchemeJKTJWT { + t.Errorf("scheme: got %q, want %q", e.Scheme, sigkey.SchemeJKTJWT) + } + got, err := e.JWT() + if err != nil { + t.Fatalf("JWT(): %v", err) + } + if got != "eyJ.payload.sig" { + t.Errorf("JWT() = %q, want %q", got, "eyJ.payload.sig") + } +} + +func TestDeriveHeader_JKTJWTScheme(t *testing.T) { + sk, err := sigkey.NewSigKey("sig1", sigkey.SchemeJKTJWT, sigkey.ParametersJKTJWT{JWT: "eyJ.payload.sig"}) + if err != nil { + t.Fatalf("NewSigKey: %v", err) + } + value, err := sk.DeriveHeader("") + if err != nil { + t.Fatalf("DeriveHeader: %v", err) + } + entries, err := sigkey.ParseHeader(value) + if err != nil { + t.Fatalf("ParseHeader: %v", err) + } + e, ok := entries["sig1"] + if !ok { + t.Fatal("missing entry for sig1") + } + if e.Scheme != sigkey.SchemeJKTJWT { + t.Errorf("scheme: got %q, want %q", e.Scheme, sigkey.SchemeJKTJWT) + } + got, err := e.JWT() + if err != nil { + t.Fatalf("JWT(): %v", err) + } + if got != "eyJ.payload.sig" { + t.Errorf("JWT() = %q, want %q", got, "eyJ.payload.sig") + } +} + +func TestParseSignatureError_Basic(t *testing.T) { + header := `error=invalid_signature` + se, err := sigkey.ParseSignatureError(header) + if err != nil { + t.Fatalf("ParseSignatureError: %v", err) + } + if se.Code != sigkey.ErrCodeInvalidSignature { + t.Errorf("Code: got %q, want %q", se.Code, sigkey.ErrCodeInvalidSignature) + } + if len(se.SupportedAlgorithms) != 0 { + t.Errorf("SupportedAlgorithms: got %v, want empty", se.SupportedAlgorithms) + } +} + +func TestParseSignatureError_UnsupportedAlgorithm(t *testing.T) { + header := `error=unsupported_algorithm, supported_algorithms=("ed25519" "ecdsa-p256-sha256")` + se, err := sigkey.ParseSignatureError(header) + if err != nil { + t.Fatalf("ParseSignatureError: %v", err) + } + if se.Code != sigkey.ErrCodeUnsupportedAlgorithm { + t.Errorf("Code: got %q, want %q", se.Code, sigkey.ErrCodeUnsupportedAlgorithm) + } + want := []string{"ed25519", "ecdsa-p256-sha256"} + if len(se.SupportedAlgorithms) != len(want) { + t.Fatalf("SupportedAlgorithms: got %v, want %v", se.SupportedAlgorithms, want) + } + for i, alg := range want { + if se.SupportedAlgorithms[i] != alg { + t.Errorf("SupportedAlgorithms[%d]: got %q, want %q", i, se.SupportedAlgorithms[i], alg) + } + } +} + +func TestParseSignatureError_InvalidInput(t *testing.T) { + header := `error=invalid_input, required_input=("@method" "@path")` + se, err := sigkey.ParseSignatureError(header) + if err != nil { + t.Fatalf("ParseSignatureError: %v", err) + } + if se.Code != sigkey.ErrCodeInvalidInput { + t.Errorf("Code: got %q, want %q", se.Code, sigkey.ErrCodeInvalidInput) + } + want := []string{"@method", "@path"} + if len(se.RequiredInput) != len(want) { + t.Fatalf("RequiredInput: got %v, want %v", se.RequiredInput, want) + } + for i, inp := range want { + if se.RequiredInput[i] != inp { + t.Errorf("RequiredInput[%d]: got %q, want %q", i, se.RequiredInput[i], inp) + } + } +} + +func TestSetSignatureError_RoundTrip(t *testing.T) { + h := make(http.Header) + se := sigkey.SignatureError{ + Code: sigkey.ErrCodeUnsupportedAlgorithm, + SupportedAlgorithms: []string{"ed25519", "ecdsa-p256-sha256"}, + } + if err := sigkey.SetSignatureError(h, se); err != nil { + t.Fatalf("SetSignatureError: %v", err) + } + value := h.Get(sigkey.SignatureErrorHeader) + if value == "" { + t.Fatal("Signature-Error header not set") + } + got, err := sigkey.ParseSignatureError(value) + if err != nil { + t.Fatalf("ParseSignatureError: %v", err) + } + if got.Code != se.Code { + t.Errorf("Code: got %q, want %q", got.Code, se.Code) + } + if len(got.SupportedAlgorithms) != len(se.SupportedAlgorithms) { + t.Fatalf("SupportedAlgorithms len: got %d, want %d", len(got.SupportedAlgorithms), len(se.SupportedAlgorithms)) + } + for i := range se.SupportedAlgorithms { + if got.SupportedAlgorithms[i] != se.SupportedAlgorithms[i] { + t.Errorf("SupportedAlgorithms[%d]: got %q, want %q", i, got.SupportedAlgorithms[i], se.SupportedAlgorithms[i]) + } + } +} + +func TestParseSignatureError_MissingErrorMember(t *testing.T) { + _, err := sigkey.ParseSignatureError(`foo=bar`) + if err == nil { + t.Fatal("expected error for missing 'error' member") + } +} + +func TestParseSignatureError_Invalid(t *testing.T) { + _, err := sigkey.ParseSignatureError("!!!") + if err == nil { + t.Fatal("expected error for invalid SFV") + } +}