From 5969c704f916e2764de83cd0a0ba57be2f1fbe31 Mon Sep 17 00:00:00 2001 From: Lee Date: Fri, 13 Mar 2026 17:56:45 -0700 Subject: [PATCH] Refactor shared types to types package and keyspec to a key package to allow for support for signature-key header --- .gitignore | 1 + examples_test.go | 12 +++-- go.mod | 2 + go.sum | 2 + key/key.go | 56 ++++++++++++++++++++++ keyman/keyman.go | 15 +++--- roundtrip_test.go | 96 +++++++++++++++++++------------------ sign.go | 27 +++++------ signatures.go | 9 ---- types/types.go | 31 ++++++++++++ validate_test.go | 120 +++++++++++++++++++++++++++++++++++++++++++++- verify.go | 118 +++++++++++++++------------------------------ verify_test.go | 32 +++++++------ 13 files changed, 341 insertions(+), 180 deletions(-) create mode 100644 .gitignore create mode 100644 key/key.go create mode 100644 types/types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71a7d50 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +sigdebug diff --git a/examples_test.go b/examples_test.go index cd44342..6a30a7a 100644 --- a/examples_test.go +++ b/examples_test.go @@ -8,8 +8,10 @@ import ( "net/http/httptest" "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/key" "github.com/remitly-oss/httpsig-go/keyman" "github.com/remitly-oss/httpsig-go/keyutil" + "github.com/remitly-oss/httpsig-go/types" ) func ExampleSign() { @@ -23,7 +25,7 @@ BznPJ5sSI1Jn+srosJB/GbEZ3Kg6PcEi+jODF9fdpNEaHGbbGdaVhJi1 req := httptest.NewRequest("GET", "https://example.com/data", nil) profile := httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, } @@ -43,7 +45,7 @@ func ExampleSigningKeyOpts() { req := httptest.NewRequest("GET", "https://example.com/data", nil) profile := httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, } @@ -67,10 +69,10 @@ MTQ7eYQXwqpTvTJkuTffGXKLilT75wY2YZWfybv9flu5d6bCfw+4UB9+cg== pubkey, _ := keyutil.ReadPublicKey([]byte(pubkeyEncoded)) req := httptest.NewRequest("GET", "https://example.com/data", nil) - kf := keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + kf := keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "key123": { KeyID: "key123", - Algo: httpsig.Algo_ECDSA_P256_SHA256, + Algo: types.Algo_ECDSA_P256_SHA256, PubKey: pubkey, }, }) @@ -99,7 +101,7 @@ func ExampleNewHandler() { func ExampleClient() { profile := httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, } diff --git a/go.mod b/go.mod index e0cf845..578061d 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( github.com/dunglas/httpsfv v1.0.2 github.com/google/go-cmp v0.7.0 ) + +require github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/go.sum b/go.sum index 239a929..377d054 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/key/key.go b/key/key.go new file mode 100644 index 0000000..4b466f1 --- /dev/null +++ b/key/key.go @@ -0,0 +1,56 @@ +// Package key defines the key specification types used for HTTP signature +// verification. +package key + +import ( + "context" + "crypto" + "net/http" + + "github.com/remitly-oss/httpsig-go/types" +) + +// IssuerType categorises the authority that vouches for a key's identity. +type IssuerType string + +const ( + IssuerSelf IssuerType = "self" // Public key provided without a third-party identity. See 'hwk' in Signature-Key spec. + IssuerIDP IssuerType = "idp" // Identity Provider domain name. See 'jwt' and 'jwks_uri' in Signature-Key spec. + IssuerCARoot IssuerType = "ca" // CA root thumbprint. +) + +// KeyIdentity carries the verified identity associated with a key. +type KeyIdentity struct { + Identity string + IssuerType IssuerType + Issuer string +} + +// KeySpec is the per-key information needed to verify a signature. +type KeySpec struct { + KeyID string + Identity KeyIdentity // Optional. The key may be associated with an identity. + Algo types.Algorithm + PubKey crypto.PublicKey + Secret []byte // Shared secret for symmetric algorithms. +} + +// KeySpec implements KeySpecer. +func (ks KeySpec) KeySpec() (KeySpec, error) { + return ks, nil +} + +// KeySpecer should be implemented by your key/credential store. +type KeySpecer interface { + KeySpec() (KeySpec, error) +} + + +// KeyFetcher resolves a KeySpec for each incoming signature. +type KeyFetcher interface { + // FetchByKeyID looks up a KeySpec from the 'keyid' metadata parameter on + // the signature. + FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (KeySpecer, error) + // Fetch looks up a KeySpec when keyid is not present in the signature. + Fetch(ctx context.Context, rh http.Header, md types.MetadataProvider) (KeySpecer, error) +} diff --git a/keyman/keyman.go b/keyman/keyman.go index 9161cc3..2a8e4f9 100644 --- a/keyman/keyman.go +++ b/keyman/keyman.go @@ -6,22 +6,23 @@ import ( "fmt" "net/http" - "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/key" + "github.com/remitly-oss/httpsig-go/types" ) -// KeyFetchInMemory implements KeyFetcher for public keys stored in memory. +// KeyFetchInMemory implements key.KeyFetcher for public keys stored in memory. type KeyFetchInMemory struct { - pubkeys map[string]httpsig.KeySpec + pubkeys map[string]key.KeySpec } -func NewKeyFetchInMemory(pubkeys map[string]httpsig.KeySpec) *KeyFetchInMemory { +func NewKeyFetchInMemory(pubkeys map[string]key.KeySpec) *KeyFetchInMemory { if pubkeys == nil { - pubkeys = map[string]httpsig.KeySpec{} + pubkeys = map[string]key.KeySpec{} } return &KeyFetchInMemory{pubkeys} } -func (kf *KeyFetchInMemory) FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (httpsig.KeySpecer, error) { +func (kf *KeyFetchInMemory) FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (key.KeySpecer, error) { ks, found := kf.pubkeys[keyID] if !found { return nil, fmt.Errorf("Key for keyid '%s' not found", keyID) @@ -29,6 +30,6 @@ func (kf *KeyFetchInMemory) FetchByKeyID(ctx context.Context, rh http.Header, ke return ks, nil } -func (kf *KeyFetchInMemory) Fetch(context.Context, http.Header, httpsig.MetadataProvider) (httpsig.KeySpecer, error) { +func (kf *KeyFetchInMemory) Fetch(context.Context, http.Header, types.MetadataProvider) (key.KeySpecer, error) { return nil, fmt.Errorf("Fetch without keyid not supported") } diff --git a/roundtrip_test.go b/roundtrip_test.go index 1d05d36..7aa2b50 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -9,9 +9,11 @@ import ( "testing" "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/key" "github.com/remitly-oss/httpsig-go/keyman" "github.com/remitly-oss/httpsig-go/keyutil" "github.com/remitly-oss/httpsig-go/sigtest" + "github.com/remitly-oss/httpsig-go/types" ) // TestRoundTrip tests that the signing code can be verified by the verify code. @@ -25,7 +27,7 @@ func TestRoundTrip(t *testing.T) { Secret []byte SignProfile httpsig.SigningProfile RequestFile string - Keys httpsig.KeyFetcher + Keys key.KeyFetcher Profile httpsig.VerifyProfile ExpectedErrCodeVerify httpsig.ErrCode }{ @@ -34,22 +36,22 @@ func TestRoundTrip(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-rsa-pss.key"), MetaKeyID: "test-key-rsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_RSA_PSS_SHA512, + Algorithm: types.Algo_RSA_PSS_SHA512, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-rsa-pss", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-rsa": { KeyID: "test-key-rsa", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }), Profile: httpsig.VerifyProfile{ SignatureLabel: "tst-rsa-pss", - AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_RSA_PSS_SHA512}, + AllowedAlgorithms: []types.Algorithm{types.Algo_RSA_PSS_SHA512}, }, }, { @@ -57,22 +59,22 @@ func TestRoundTrip(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/key-rsa-v15.key"), MetaKeyID: "test-key-rsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_RSA_v1_5_sha256, + Algorithm: types.Algo_RSA_v1_5_sha256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-rsa-pss", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-rsa": { KeyID: "test-key-rsa", - Algo: httpsig.Algo_RSA_v1_5_sha256, + Algo: types.Algo_RSA_v1_5_sha256, PubKey: keyutil.MustReadPublicKeyFile("testdata/key-rsa-v15.pub"), }, }), Profile: httpsig.VerifyProfile{ SignatureLabel: "tst-rsa-pss", - AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_RSA_v1_5_sha256}, + AllowedAlgorithms: []types.Algorithm{types.Algo_RSA_v1_5_sha256}, }, }, { @@ -80,15 +82,15 @@ func TestRoundTrip(t *testing.T) { Secret: sigtest.MustReadFile("testdata/test-shared-secret"), MetaKeyID: "test-key-shared", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_HMAC_SHA256, + Algorithm: types.Algo_HMAC_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-shared": { KeyID: "test-key-shared", - Algo: httpsig.Algo_HMAC_SHA256, + Algo: types.Algo_HMAC_SHA256, Secret: sigtest.MustReadFile("testdata/test-shared-secret"), }, }), @@ -99,16 +101,16 @@ func TestRoundTrip(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p256.key"), MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecds", - Algo: httpsig.Algo_ECDSA_P256_SHA256, + Algo: types.Algo_ECDSA_P256_SHA256, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p256.pub"), }, }), @@ -122,16 +124,16 @@ func TestRoundTrip(t *testing.T) { }, MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecds", - Algo: httpsig.Algo_ECDSA_P256_SHA256, + Algo: types.Algo_ECDSA_P256_SHA256, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p256.pub"), }, }), @@ -147,16 +149,16 @@ func TestRoundTrip(t *testing.T) { }, MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecds", - Algo: httpsig.Algo_ECDSA_P256_SHA256, + Algo: types.Algo_ECDSA_P256_SHA256, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p256.pub"), }, }), @@ -167,16 +169,16 @@ func TestRoundTrip(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p384.key"), MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P384_SHA384, + Algorithm: types.Algo_ECDSA_P384_SHA384, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecdsa", - Algo: httpsig.Algo_ECDSA_P384_SHA384, + Algo: types.Algo_ECDSA_P384_SHA384, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p384.pub"), }, }), @@ -190,16 +192,16 @@ func TestRoundTrip(t *testing.T) { }, MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P384_SHA384, + Algorithm: types.Algo_ECDSA_P384_SHA384, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecdsa", - Algo: httpsig.Algo_ECDSA_P384_SHA384, + Algo: types.Algo_ECDSA_P384_SHA384, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p384.pub"), }, }), @@ -215,16 +217,16 @@ func TestRoundTrip(t *testing.T) { }, MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P384_SHA384, + Algorithm: types.Algo_ECDSA_P384_SHA384, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecdsa", - Algo: httpsig.Algo_ECDSA_P384_SHA384, + Algo: types.Algo_ECDSA_P384_SHA384, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p384.pub"), }, }), @@ -235,16 +237,16 @@ func TestRoundTrip(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ed25519.key"), MetaKeyID: "test-key-ed", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ED25519, + Algorithm: types.Algo_ED25519, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ed", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ed": { KeyID: "test-key-ed", - Algo: httpsig.Algo_ED25519, + Algo: types.Algo_ED25519, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ed25519.pub"), }, }), @@ -257,16 +259,16 @@ func TestRoundTrip(t *testing.T) { }, MetaKeyID: "test-key-ed", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ED25519, + Algorithm: types.Algo_ED25519, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ed", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ed": { KeyID: "test-key-ed", - Algo: httpsig.Algo_ED25519, + Algo: types.Algo_ED25519, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ed25519.pub"), }, }), @@ -278,16 +280,16 @@ func TestRoundTrip(t *testing.T) { MetaKeyID: "test-key-ed", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ED25519, + Algorithm: types.Algo_ED25519, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-content-digest", }, RequestFile: "request_bad_digest.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ed": { KeyID: "test-key-ed", - Algo: httpsig.Algo_ED25519, + Algo: types.Algo_ED25519, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ed25519.pub"), }, }), @@ -351,7 +353,7 @@ func TestRoundTripMultiSig(t *testing.T) { Secret []byte SignProfile httpsig.SigningProfile RequestFile string - Keys httpsig.KeyFetcher + Keys key.KeyFetcher Profile httpsig.VerifyProfile ExpectedErrCodeVerify httpsig.ErrCode }{ @@ -361,22 +363,22 @@ func TestRoundTripMultiSig(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-rsa-pss.key"), MetaKeyID: "test-key-rsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_RSA_PSS_SHA512, + Algorithm: types.Algo_RSA_PSS_SHA512, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-rsa-pss-%d", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-rsa": { KeyID: "test-key-rsa", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }), Profile: httpsig.VerifyProfile{ SignatureLabel: "tst-rsa-pss-%d", - AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_RSA_PSS_SHA512}, + AllowedAlgorithms: []types.Algorithm{types.Algo_RSA_PSS_SHA512}, }, }, { @@ -385,16 +387,16 @@ func TestRoundTripMultiSig(t *testing.T) { PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p256.key"), MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ - Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Algorithm: types.Algo_ECDSA_P256_SHA256, Fields: httpsig.DefaultRequiredFields, Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, Label: "tst-ecdsa-%d", }, RequestFile: "rfc-test-request.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-ecdsa": { KeyID: "test-key-ecds", - Algo: httpsig.Algo_ECDSA_P256_SHA256, + Algo: types.Algo_ECDSA_P256_SHA256, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p256.pub"), }, }), diff --git a/sign.go b/sign.go index 8e2b5eb..a55e0ba 100644 --- a/sign.go +++ b/sign.go @@ -11,9 +11,11 @@ import ( "unicode" sfv "github.com/dunglas/httpsfv" + "github.com/remitly-oss/httpsig-go/types" ) -type Algorithm string +// Algorithm is re-exported from types for backwards compatibility. +type Algorithm = types.Algorithm type Digest string // Metadata are the named signature metadata parameters @@ -25,12 +27,12 @@ type NonceScheme int const ( // Supported signing algorithms - Algo_RSA_PSS_SHA512 Algorithm = "rsa-pss-sha512" - Algo_RSA_v1_5_sha256 Algorithm = "rsa-v1_5-sha256" - Algo_HMAC_SHA256 Algorithm = "hmac-sha256" - Algo_ECDSA_P256_SHA256 Algorithm = "ecdsa-p256-sha256" - Algo_ECDSA_P384_SHA384 Algorithm = "ecdsa-p384-sha384" - Algo_ED25519 Algorithm = "ed25519" + Algo_RSA_PSS_SHA512 = types.Algo_RSA_PSS_SHA512 + Algo_RSA_v1_5_sha256 = types.Algo_RSA_v1_5_sha256 + Algo_HMAC_SHA256 = types.Algo_HMAC_SHA256 + Algo_ECDSA_P256_SHA256 = types.Algo_ECDSA_P256_SHA256 + Algo_ECDSA_P384_SHA384 = types.Algo_ECDSA_P384_SHA384 + Algo_ED25519 = types.Algo_ED25519 DigestSHA256 Digest = "sha-256" DigestSHA512 Digest = "sha-512" @@ -218,10 +220,10 @@ func (so SigningProfile) validate(skey SigningKey) error { if so.Algorithm == "" { return fmt.Errorf("Missing required signing option 'Algorithm'") } - if so.Algorithm.symmetric() && len(skey.Secret) == 0 { + if so.Algorithm.Symmetric() && len(skey.Secret) == 0 { return newError(ErrInvalidSignatureOptions, "Missing required 'Secret' value in SigningKey") } - if !so.Algorithm.symmetric() && skey.Key == nil && skey.Opts.Signer == nil { + if !so.Algorithm.Symmetric() && skey.Key == nil && skey.Opts.Signer == nil { return newError(ErrInvalidSignatureOptions, "Missing required 'Key' or 'Opts.Signer' value in SigningKey") } if !isSafeString(so.Label) { @@ -301,13 +303,6 @@ func (sf signedFields) includes(field string) bool { return false } -func (a Algorithm) symmetric() bool { - switch a { - case Algo_HMAC_SHA256: - return true - } - return false -} func componentsIDs(sfs []SignedField) []componentID { cIDs := []componentID{} for _, sf := range sfs { diff --git a/signatures.go b/signatures.go index 6b751c6..05720bf 100644 --- a/signatures.go +++ b/signatures.go @@ -31,15 +31,6 @@ const ( ecdsaP384SignatureSize = 96 ) -// MetadataProvider allows customized functions for metadata parameter values. Not needed for default usage. -type MetadataProvider interface { - Created() (int, error) - Expires() (int, error) - Nonce() (string, error) - Alg() (string, error) - KeyID() (string, error) - Tag() (string, error) -} type signatureBase struct { base []byte // The full signature base. Use this as input to signing and verification diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..b3ec919 --- /dev/null +++ b/types/types.go @@ -0,0 +1,31 @@ +// Package types defines shared types used across the httpsig module. +package types + +// Algorithm identifies the cryptographic signing algorithm. +type Algorithm string + +const ( + Algo_RSA_PSS_SHA512 Algorithm = "rsa-pss-sha512" + Algo_RSA_v1_5_sha256 Algorithm = "rsa-v1_5-sha256" + Algo_HMAC_SHA256 Algorithm = "hmac-sha256" + Algo_ECDSA_P256_SHA256 Algorithm = "ecdsa-p256-sha256" + Algo_ECDSA_P384_SHA384 Algorithm = "ecdsa-p384-sha384" + Algo_ED25519 Algorithm = "ed25519" +) + +// Symmetric reports whether the algorithm uses a shared secret rather than +// an asymmetric key pair. +func (a Algorithm) Symmetric() bool { + return a == Algo_HMAC_SHA256 +} + +// MetadataProvider gives read access to the signature metadata parameters on +// an individual signature. +type MetadataProvider interface { + Created() (int, error) + Expires() (int, error) + Nonce() (string, error) + Alg() (string, error) + KeyID() (string, error) + Tag() (string, error) +} diff --git a/validate_test.go b/validate_test.go index defd4a8..2ea3dc7 100644 --- a/validate_test.go +++ b/validate_test.go @@ -2,10 +2,12 @@ package httpsig import ( "errors" + "net/http/httptest" "strings" "testing" "time" + "github.com/remitly-oss/httpsig-go/key" "github.com/remitly-oss/httpsig-go/sigtest" ) @@ -15,6 +17,7 @@ func TestValidateProfile(t *testing.T) { Sig extractedSignature Profile VerifyProfile KeySpecAlgo Algorithm + KeySpec KeySpec Expected ErrCode // Expected ErrCode if an error. Empty string if expecting no error ExpectedMsg string }{ @@ -465,11 +468,75 @@ func TestValidateProfile(t *testing.T) { Expected: ErrSigProfile, ExpectedMsg: "Signature missing required meta parameter 'created'", }, + // Identity validation tests — these pass a KeySpec directly via the table's KeySpec field. + { + Name: "VerifyIdentity_Trusted", + Sig: extractedSignature{Label: "sig1", Signature: []byte{}, Input: sigBaseInput{MetadataValues: nil}}, + Profile: VerifyProfile{ + VerifyIdentity: true, + TrustedIssuers: []TrustedIssuer{ + {IssuerType: key.IssuerIDP, Issuer: "https://idp.example.com"}, + }, + DisableTimeEnforcement: true, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + KeySpec: KeySpec{ + Identity: key.KeyIdentity{IssuerType: key.IssuerIDP, Issuer: "https://idp.example.com"}, + }, + Expected: ErrCode(""), + }, + { + Name: "VerifyIdentity_Untrusted", + Sig: extractedSignature{Label: "sig1", Signature: []byte{}, Input: sigBaseInput{MetadataValues: nil}}, + Profile: VerifyProfile{ + VerifyIdentity: true, + TrustedIssuers: []TrustedIssuer{ + {IssuerType: key.IssuerIDP, Issuer: "https://idp.example.com"}, + }, + DisableTimeEnforcement: true, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + KeySpec: KeySpec{ + Identity: key.KeyIdentity{IssuerType: key.IssuerIDP, Issuer: "https://evil.example.com"}, + }, + Expected: ErrSigProfile, + ExpectedMsg: "not in the trusted issuers list", + }, + { + Name: "VerifyIdentity_WrongType", + Sig: extractedSignature{Label: "sig1", Signature: []byte{}, Input: sigBaseInput{MetadataValues: nil}}, + Profile: VerifyProfile{ + VerifyIdentity: true, + TrustedIssuers: []TrustedIssuer{ + {IssuerType: key.IssuerIDP, Issuer: "https://idp.example.com"}, + }, + DisableTimeEnforcement: true, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + KeySpec: KeySpec{ + Identity: key.KeyIdentity{IssuerType: key.IssuerSelf, Issuer: "https://idp.example.com"}, + }, + Expected: ErrSigProfile, + ExpectedMsg: "not in the trusted issuers list", + }, + { + Name: "VerifyIdentity_Disabled", + Sig: extractedSignature{Label: "sig1", Signature: []byte{}, Input: sigBaseInput{MetadataValues: nil}}, + Profile: VerifyProfile{ + VerifyIdentity: false, + DisableTimeEnforcement: true, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + KeySpec: KeySpec{ + Identity: key.KeyIdentity{IssuerType: key.IssuerIDP, Issuer: "https://untrusted.example.com"}, + }, + Expected: ErrCode(""), + }, } for _, tc := range testcases { t.Run(tc.Name, func(t *testing.T) { - err := tc.Profile.validate(tc.Sig, tc.KeySpecAlgo) + err := tc.Profile.validate(tc.Sig, tc.KeySpecAlgo, tc.KeySpec) if tc.Expected == ErrCode("") { sigtest.Diff(t, nil, err, "Diff") return @@ -883,3 +950,54 @@ func TestValidateTiming(t *testing.T) { }) } } + +func TestDeriveTargetURI(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "simple path no query", + url: "https://example.com/path", + expected: "https://example.com/path", + }, + { + name: "path with query string", + url: "https://example.com/path?foo=bar", + expected: "https://example.com/path?foo=bar", + }, + { + name: "path with multiple query params", + url: "https://example.com/data?name=value&other=123", + expected: "https://example.com/data?name=value&other=123", + }, + { + name: "root path with query", + url: "https://example.com/?query=test", + expected: "https://example.com/?query=test", + }, + { + name: "nested path no query", + url: "https://example.com/api/v1/users", + expected: "https://example.com/api/v1/users", + }, + { + name: "empty query string preserved", + url: "https://example.com/path?", + expected: "https://example.com/path?", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // httptest.NewRequest sets req.TLS for https URLs + req := httptest.NewRequest("GET", tc.url, nil) + + got := deriveTargetURI(req) + if got != tc.expected { + t.Errorf("deriveTargetURI() = %q, want %q", got, tc.expected) + } + }) + } +} diff --git a/verify.go b/verify.go index 0473398..03825b5 100644 --- a/verify.go +++ b/verify.go @@ -13,13 +13,13 @@ import ( "fmt" "math/big" "net/http" - "net/http/httptest" "slices" "strings" - "testing" "time" sfv "github.com/dunglas/httpsfv" + "github.com/remitly-oss/httpsig-go/key" + "github.com/remitly-oss/httpsig-go/types" ) var ( @@ -42,23 +42,13 @@ var ( defaultCreatedValidDuration = 5 * time.Minute ) -// KeySpec is the per-key information needed to verify a signature. -type KeySpec struct { - KeyID string - Algo Algorithm - PubKey crypto.PublicKey - Secret []byte // shared secret for symmetric algorithms -} +// Re-exported from key package for backwards compatibility. +type KeySpec = key.KeySpec +type KeySpecer = key.KeySpecer +type KeyFetcher = key.KeyFetcher -// KeySpec implements KeySpecer -func (ks KeySpec) KeySpec() (KeySpec, error) { - return ks, nil -} - -// KeySpecer should be implemented by your key/credential store -type KeySpecer interface { - KeySpec() (KeySpec, error) -} +// MetadataProvider re-exported from types package for backwards compatibility. +type MetadataProvider = types.MetadataProvider type KeyErrorReason string type KeyError struct { @@ -67,15 +57,14 @@ type KeyError struct { Message string } -type KeyFetcher interface { - // FetchByKeyID looks up a KeySpec from the 'keyid' metadata parameter on the signature. - FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (KeySpecer, error) - // Fetch looks up a KeySpec when the keyid is not in the signature. - Fetch(ctx context.Context, rh http.Header, md MetadataProvider) (KeySpecer, error) -} - // VerifyProfile sets the parameters for a fully valid request or response. // A valid signature is a relatively easy accomplishment. Did the signature include all the important parts of the request? Did it use a strong enough algorithm? Was it signed 41 days ago? There are choices to make about what constitutes a valid signed request or response beyond just a verified signature. +// TrustedIssuer identifies a trusted identity issuer by type and issuer string. +type TrustedIssuer struct { + IssuerType key.IssuerType + Issuer string +} + type VerifyProfile struct { SignatureLabel string // Which signature this profile applies to. RequiredFields []SignedField @@ -83,6 +72,10 @@ type VerifyProfile struct { DisallowedMetadata []Metadata AllowedAlgorithms []Algorithm // Which algorithms are allowed, either from keyid meta or in the KeySpec + // Identity enforcement options + VerifyIdentity bool // If true, the KeySpec Identity must match a TrustedIssuer entry. + TrustedIssuers []TrustedIssuer // Issuers considered trusted when VerifyIdentity is true. + // Timing enforcement options DisableTimeEnforcement bool // If true do no time enforcement on any fields DisableExpirationEnforcement bool // If expiration is present default to enforce @@ -94,10 +87,10 @@ type VerifyProfile struct { } type VerifyResult struct { - Verified bool + Verified bool // True only if the signature has been verified and validated according to the VerifyProfile. Label string KeySpecer KeySpecer - DebugInfo VerifyDebugInfo // Present if the verifier debug flag is set and the signature was valid. + DebugInfo VerifyDebugInfo // Present if the verifier debug flag is set and the signature was syntactically valid. MetadataProvider } @@ -206,7 +199,7 @@ func (ver *Verifier) verify(hrr httpMessage) (VerifyResult, error) { return vr, err } - if err := ver.profile.validate(sig, ks.Algo); err != nil { + if err := ver.profile.validate(sig, ks.Algo, ks); err != nil { return vr, err } @@ -452,8 +445,8 @@ func (vp VerifyProfile) now() time.Time { return vp.nowTime() } -// validate enforces the VeriryProfile settings are met for the given signature. This should only done after the signature is *verified*. -func (vp VerifyProfile) validate(sig extractedSignature, ksAlgo Algorithm) error { +// validate enforces the VerifyProfile settings are met for the given signature. This should only be done after the signature is *verified*. +func (vp VerifyProfile) validate(sig extractedSignature, ksAlgo Algorithm, ks KeySpec) error { // Validate signature label if vp.SignatureLabel != "" && sig.Label != vp.SignatureLabel { return newError(ErrSigProfile, fmt.Sprintf("Signature label '%s' does not match required label '%s'", sig.Label, vp.SignatureLabel)) @@ -497,9 +490,24 @@ func (vp VerifyProfile) validate(sig extractedSignature, ksAlgo Algorithm) error } } + if vp.VerifyIdentity { + if err := vp.validateIdentity(ks.Identity); err != nil { + return err + } + } + return vp.validateTiming(sig, vp.now()) } +func (vp VerifyProfile) validateIdentity(identity key.KeyIdentity) error { + for _, trusted := range vp.TrustedIssuers { + if trusted.IssuerType == identity.IssuerType && trusted.Issuer == identity.Issuer { + return nil + } + } + return newError(ErrSigProfile, fmt.Sprintf("Identity issuer %q (type %q) is not in the trusted issuers list", identity.Issuer, identity.IssuerType)) +} + // validateTiming validates all the time based properties are within tolerance of the VerifyProfile. currentTime is passed in as a parameter to capture a stable time for all subsequent checks. func (vp VerifyProfile) validateTiming(sig extractedSignature, currentTime time.Time) error { // Early return if time enforcement is disabled @@ -555,56 +563,6 @@ func (vp VerifyProfile) validateTiming(sig extractedSignature, currentTime time. return nil } -func TestDeriveTargetURI(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - { - name: "simple path no query", - url: "https://example.com/path", - expected: "https://example.com/path", - }, - { - name: "path with query string", - url: "https://example.com/path?foo=bar", - expected: "https://example.com/path?foo=bar", - }, - { - name: "path with multiple query params", - url: "https://example.com/data?name=value&other=123", - expected: "https://example.com/data?name=value&other=123", - }, - { - name: "root path with query", - url: "https://example.com/?query=test", - expected: "https://example.com/?query=test", - }, - { - name: "nested path no query", - url: "https://example.com/api/v1/users", - expected: "https://example.com/api/v1/users", - }, - { - name: "empty query string preserved", - url: "https://example.com/path?", - expected: "https://example.com/path?", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // httptest.NewRequest sets req.TLS for https URLs - req := httptest.NewRequest("GET", tc.url, nil) - - got := deriveTargetURI(req) - if got != tc.expected { - t.Errorf("deriveTargetURI() = %q, want %q", got, tc.expected) - } - }) - } -} type metadataProviderFromParams struct { Params *sfv.Params diff --git a/verify_test.go b/verify_test.go index a7f9a3a..42ae123 100644 --- a/verify_test.go +++ b/verify_test.go @@ -6,9 +6,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/key" "github.com/remitly-oss/httpsig-go/keyman" "github.com/remitly-oss/httpsig-go/keyutil" "github.com/remitly-oss/httpsig-go/sigtest" + "github.com/remitly-oss/httpsig-go/types" ) // TestVerifyResult ensures the VerifyReuslt contains the expected shape. @@ -18,17 +20,17 @@ func TestVerifyResult(t *testing.T) { RequestFile string Label string AddDebugInfo bool - Keys httpsig.KeyFetcher + Keys key.KeyFetcher Expected httpsig.VerifyResult }{ { Name: "OneValid", Label: "sig-b21", RequestFile: "verify_request1.txt", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-rsa-pss": { KeyID: "test-key-rsa-pss", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }), @@ -40,9 +42,9 @@ func TestVerifyResult(t *testing.T) { httpsig.MetaCreated: int64(1618884473), httpsig.MetaNonce: "b3k2pp5k7z-50gnwp.yemd", }}, - KeySpecer: httpsig.KeySpec{ + KeySpecer: key.KeySpec{ KeyID: "test-key-rsa-pss", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }, @@ -52,10 +54,10 @@ func TestVerifyResult(t *testing.T) { Label: "sig-b21", RequestFile: "verify_request1.txt", AddDebugInfo: true, - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-rsa-pss": { KeyID: "test-key-rsa-pss", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }), @@ -67,9 +69,9 @@ func TestVerifyResult(t *testing.T) { httpsig.MetaCreated: int64(1618884473), httpsig.MetaNonce: "b3k2pp5k7z-50gnwp.yemd", }}, - KeySpecer: httpsig.KeySpec{ + KeySpecer: key.KeySpec{ KeyID: "test-key-rsa-pss", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, DebugInfo: httpsig.VerifyDebugInfo{ @@ -108,17 +110,17 @@ func TestVerifyInvalid(t *testing.T) { Name string RequestFile string Label string - Keys httpsig.KeyFetcher + Keys key.KeyFetcher Expected httpsig.ErrCode }{ { Name: "SignatureVerificationFailure", RequestFile: "verify_request2.txt", Label: "bad-sig", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{ "test-key-rsa-pss": { KeyID: "test-key-rsa-pss", - Algo: httpsig.Algo_RSA_PSS_SHA512, + Algo: types.Algo_RSA_PSS_SHA512, PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }), @@ -128,14 +130,14 @@ func TestVerifyInvalid(t *testing.T) { Name: "KeyFetchError", RequestFile: "verify_request2.txt", Label: "sig-b21", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{}), + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{}), Expected: httpsig.ErrSigKeyFetch, }, { Name: "KeyFetchError2", RequestFile: "verify_request2.txt", Label: "bad-sig", - Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{}), + Keys: keyman.NewKeyFetchInMemory(map[string]key.KeySpec{}), Expected: httpsig.ErrSigKeyFetch, }, } @@ -218,7 +220,7 @@ func getCmdOpts() []cmp.Option { } } -func TransformMeta(md httpsig.MetadataProvider) map[string]any { +func TransformMeta(md types.MetadataProvider) map[string]any { out := map[string]any{} if md == nil {