Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions accept.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
}
}
}

Expand Down
55 changes: 55 additions & 0 deletions accept_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
160 changes: 160 additions & 0 deletions sigkeydraft/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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-04.
//
// 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)
}
}
Loading
Loading