diff --git a/base.go b/base.go index f9e4780..c6b2698 100644 --- a/base.go +++ b/base.go @@ -145,7 +145,7 @@ func calculateSignatureBase(msg httpMessage, bp sigBaseInput) (signatureBase, er base.WriteString(fmt.Sprintf("\"%s\": %s", sigparams, paramsOut)) return signatureBase{ base: []byte(base.String()), - signatureInput: paramsOut, + signatureInput: signatureParams, }, nil } @@ -218,11 +218,11 @@ func deriveComponentValueRequest(req *http.Request, component componentID) (stri case "@query-param": paramKey, found := component.Item.Params.Get("name") if !found { - return "", newError(ErrInvalidSignatureOptions, fmt.Sprintf("@query-param specified but missing 'name' parameter to indicate which parameter.")) + return "", newError(ErrInvalidSignatureOptions, "@query-param specified but missing 'name' parameter to indicate which parameter.") } paramName, ok := paramKey.(string) if !ok { - return "", newError(ErrInvalidSignatureOptions, fmt.Sprintf("@query-param specified but the 'name' parameter must be a string to indicate which parameter.")) + return "", newError(ErrInvalidSignatureOptions, "@query-param specified but the 'name' parameter must be a string to indicate which parameter.") } paramValue := req.URL.Query().Get(paramName) // TODO support empty - is this still a string with a space in it? diff --git a/fz_test.go b/fz_test.go index 9e5335d..87ff6a1 100644 --- a/fz_test.go +++ b/fz_test.go @@ -170,6 +170,10 @@ func FuzzExtractSignatures(f *testing.F) { {"sig1=:dGVzdA==:", "sig1=invalid"}, {"=:dGVzdA==:", "=(\"@method\")"}, {"sig1=:dGVzdA==:", "sig1=("}, + { + SignatureInputHeader: `tst-ecdsa-0=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-1=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-2=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-3=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-4=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-5=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-6=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa", tst-ecdsa-7=("content-digest" "@method" "@target-uri");created=1755899955;keyid="test-key-ecdsa"`, + SignatureHeader: `tst-ecdsa-0=:ktNUTq0MYt1iIDU8AxBi39Nrw6vVLAD7BL1EMuHxtmpDatYx7CFxxFee1cPqE3hkOKknGnQgS6Umrv0ct3wq3A==:, tst-ecdsa-1=:XFoz0EbrdxZFhsvVi0xhRBI2F5NNaQ5SsdRUjmyBiaTqZvC7Ud15gD+AmTipZxyjx5iX9YNKDNAnILv0fViWHA==:, tst-ecdsa-2=:EmUtrfTosRFr1uIKCu89ggqpfUClw4La3C3esWuXYdcg7xntY5dpqoqFCwyrlwxHveLcBxoHcVp1trSGsUJGAA==:, tst-ecdsa-3=:jJw65CIeODTbYxTAqBPof9U70FU1+uL/k8GLLJR3YMOHbk5enJqi9GCI6Gpn7SUamw1d3u6YqUb9BYo2C1WdTA==:, tst-ecdsa-4=:yhFGqoxuKpzApUccnjspLYhneYXf9Y/CTNbrCrQuzRUf6OzDGJFxJB1deDwdLJzIGB2DnICNVtHc3Zp/MI5+jQ==:, tst-ecdsa-5=:jxZ+G1ZFY9yc0GyhTXG5vSKtHTEQ2Slacb6AJzV8lHuuoTJpn08eZeJijLXhiRzBNzHEoFc9PwcsKehIp5dGjA==:, tst-ecdsa-6=:HLFRyXXkrIZbRYEoVQl3aL1EpFtg52JkAK8wad+wHnehVFYzO3M7tyEpdGBcr/6ZpUr1rkc1J1Ru9wRa3WkqRQ==:, tst-ecdsa-7=:b7+mkQt02cvV/cucQcSn9J+lJ9/cRkWcVX8mAdNh+p3avD1ULj/hg0z7ZkgEnlfDuWaTskdu+CLncxseLsEIWQ==:`, + }, } for _, tc := range testcases { @@ -193,7 +197,7 @@ func FuzzExtractSignatures(f *testing.F) { req.Header.Set("signature", sigHeader) req.Header.Set("signature-input", sigInputHeader) - sigSFV, err := parseSignaturesFromRequest(req.Header) + sigSFV, err := parseSignaturesFromRequest(req.Header, false) if err != nil { return } diff --git a/go.mod b/go.mod index ca025e3..e0cf845 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/remitly-oss/httpsig-go -go 1.21.4 +go 1.22 require ( github.com/dunglas/httpsfv v1.0.2 diff --git a/roundtrip_test.go b/roundtrip_test.go index 65911d9..1d05d36 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -3,6 +3,7 @@ package httpsig_test import ( "crypto" "crypto/ecdsa" + "fmt" "io" "math/big" "testing" @@ -339,6 +340,122 @@ func TestRoundTrip(t *testing.T) { } } +func TestRoundTripMultiSig(t *testing.T) { + + testcases := []struct { + Name string + NumberOfSignatures int + PrivateKey crypto.PrivateKey + SigningOpts httpsig.SigningKeyOpts + MetaKeyID string + Secret []byte + SignProfile httpsig.SigningProfile + RequestFile string + Keys httpsig.KeyFetcher + Profile httpsig.VerifyProfile + ExpectedErrCodeVerify httpsig.ErrCode + }{ + { + Name: "RSA-PSS", + NumberOfSignatures: 2, + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-rsa-pss.key"), + MetaKeyID: "test-key-rsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.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{ + "test-key-rsa": { + KeyID: "test-key-rsa", + Algo: httpsig.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}, + }, + }, + { + Name: "ECDSA-p256", + NumberOfSignatures: 8, + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p256.key"), + MetaKeyID: "test-key-ecdsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.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{ + "test-key-ecdsa": { + KeyID: "test-key-ecds", + Algo: httpsig.Algo_ECDSA_P256_SHA256, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p256.pub"), + }, + }), + Profile: createVerifyProfile("tst-ecdsa-%d"), + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + req := sigtest.ReadRequest(t, tc.RequestFile) + + for i := range tc.NumberOfSignatures { + var signer *httpsig.Signer + sk := httpsig.SigningKey{ + Key: tc.PrivateKey, + Opts: tc.SigningOpts, + Secret: tc.Secret, + MetaKeyID: tc.MetaKeyID, + } + + sp := tc.SignProfile + sp.Label = fmt.Sprintf(sp.Label, i) + signer, err := httpsig.NewSigner(sp, sk) + if err != nil { + t.Fatal(err) + } + err = signer.Sign(req) + if err != nil { + t.Fatalf("%#v", err) + } + } + + t.Log(req.Header.Get("Signature-Input")) + t.Log(req.Header.Get("Signature")) + + for i := range tc.NumberOfSignatures { + vpf := tc.Profile + vpf.SignatureLabel = fmt.Sprintf(vpf.SignatureLabel, i) + ver, err := httpsig.NewVerifier(tc.Keys, vpf) + if err != nil { + t.Fatal(err) + } + vf, err := ver.Verify(req) + if err != nil { + if tc.ExpectedErrCodeVerify != "" { + if sigerr, ok := err.(*httpsig.SignatureError); ok { + sigtest.Diff(t, tc.ExpectedErrCodeVerify, sigerr.Code, "Wrong err code") + } + } else { + t.Fatalf("%#v", err) + } + } else if tc.ExpectedErrCodeVerify != "" { + t.Fatal("Expected error") + } + t.Logf("%+v\n", vf) + } + }) + + } +} + func createVerifyProfile(label string) httpsig.VerifyProfile { vp := httpsig.DefaultVerifyProfile vp.SignatureLabel = label diff --git a/signatures.go b/signatures.go index cd0aa37..6b751c6 100644 --- a/signatures.go +++ b/signatures.go @@ -42,8 +42,8 @@ type MetadataProvider interface { } type signatureBase struct { - base []byte // The full signature base. Use this as input to signing and verification - signatureInput string // The signature-input line + base []byte // The full signature base. Use this as input to signing and verification + signatureInput sfv.InnerList // The signature-input line } type sigParameters struct { @@ -56,12 +56,45 @@ type sigParameters struct { } func sign(hrr httpMessage, sp sigParameters) error { + // get the signature base base, err := calculateSignatureBase(hrr, sp.Base) if err != nil { return err } + // create the signature from the base + sigBytes, err := createSignature(sp, base) + if err != nil { + return err + } + // Add it to the request + sigsSFV, err := parseSignaturesFromRequest(hrr.Headers(), true) + if err != nil { + return err + } + + // Note this will overwrite an existing signature with the same label. + sigsSFV.SigInputs.Add(sp.Label, base.signatureInput) + sigsSFV.Sigs.Add(sp.Label, sfv.NewItem(sigBytes)) + + sigInput, err := sfv.Marshal(sigsSFV.SigInputs) + if err != nil { + return newError(ErrInternal, "Failed to marshal signature-input for http header value", err) + } + sigs, err := sfv.Marshal(sigsSFV.Sigs) + if err != nil { + return newError(ErrInternal, "Failed to marshal signature for http header value.", err) + } + + hrr.Headers().Set("Signature-Input", sigInput) + hrr.Headers().Set("Signature", sigs) + + return nil +} + +func createSignature(sp sigParameters, base signatureBase) (sig []byte, e error) { var sigBytes []byte + var err error pkSigner := sp.Opts.Signer // Use crypto.Signer interface if set. // Use the crypto.Signer generic `Sign` method if crypto.Signer is provided in the SigningKeyOpts @@ -72,7 +105,7 @@ func sign(hrr httpMessage, sp sigParameters) error { if rsapk, ok := sp.PrivateKey.(*rsa.PrivateKey); ok { pkSigner = rsapk } else { - return newError(ErrInternal, fmt.Sprintf("Invalid private key. Requires *rsa.PrivateKey: %T", sp.PrivateKey)) + return nil, newError(ErrInternal, fmt.Sprintf("Invalid private key. Requires *rsa.PrivateKey: %T", sp.PrivateKey)) } } @@ -83,20 +116,20 @@ func sign(hrr httpMessage, sp sigParameters) error { } sigBytes, err = pkSigner.Sign(rand.Reader, msgHash[:], opts) if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_RSA_PSS_SHA512), err) + return nil, newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_RSA_PSS_SHA512), err) } case Algo_RSA_v1_5_sha256: if pkSigner == nil { if rsapk, ok := sp.PrivateKey.(*rsa.PrivateKey); ok { pkSigner = rsapk } else { - return newError(ErrInternal, fmt.Sprintf("Invalid private key. Requires *rsa.PrivateKey: %T", sp.PrivateKey)) + return nil, newError(ErrInternal, fmt.Sprintf("Invalid private key. Requires *rsa.PrivateKey: %T", sp.PrivateKey)) } } msgHash := sha256.Sum256(base.base) sigBytes, err = pkSigner.Sign(rand.Reader, msgHash[:], crypto.SHA256) if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_RSA_v1_5_sha256), err) + return nil, newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_RSA_v1_5_sha256), err) } case Algo_ECDSA_P256_SHA256: msgHash := sha256.Sum256(base.base) @@ -105,19 +138,19 @@ func sign(hrr httpMessage, sp sigParameters) error { // Use the native ecdsa.Sign method to avoid needing to decode ASN.1 result. r, s, err := ecdsa.Sign(rand.Reader, eccpk, msgHash[:]) if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_ECDSA_P256_SHA256), err) + return nil, newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_ECDSA_P256_SHA256), err) } // Concatenate r and s to make the signature as per the spec. r and s are *not* encoded in ASN1 format sigBytes = ecdsaConcatRS(r, s, ecdsaP256SignatureSize) } else { - return newError(ErrInternal, "Invalid private key. Requires *ecdsa.PrivateKey") + return nil, newError(ErrInternal, "Invalid private key. Requires *ecdsa.PrivateKey") } } else { // crypto.Signer for ECDSA may return the signature in ASN.1 format sigBytes, err = pkSigner.Sign(rand.Reader, msgHash[:], crypto.SHA256) sigBytes, err = sp.ecdsaHandleASN1(err, sigBytes, ecdsaP256SignatureSize) if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_ECDSA_P256_SHA256), err) + return nil, newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_ECDSA_P256_SHA256), err) } } case Algo_ECDSA_P384_SHA384: @@ -126,19 +159,19 @@ func sign(hrr httpMessage, sp sigParameters) error { if eccpk, ok := sp.PrivateKey.(*ecdsa.PrivateKey); ok { r, s, err := ecdsa.Sign(rand.Reader, eccpk, msgHash[:]) if err != nil { - return newError(ErrInternal, "Failed to sign with ecdsa private key", err) + return nil, newError(ErrInternal, "Failed to sign with ecdsa private key", err) } // Concatenate r and s to make the signature as per the spec. r and s are *not* encoded in ASN1 format sigBytes = ecdsaConcatRS(r, s, ecdsaP384SignatureSize) } else { - return newError(ErrInternal, "Invalid private key. Requires *ecdsa.PrivateKey") + return nil, newError(ErrInternal, "Invalid private key. Requires *ecdsa.PrivateKey") } } else { // crypto.Signer for ECDSA may return the signature in ASN.1 format sigBytes, err = pkSigner.Sign(rand.Reader, msgHash[:], crypto.SHA384) sigBytes, err = sp.ecdsaHandleASN1(err, sigBytes, ecdsaP384SignatureSize) if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_ECDSA_P384_SHA384), err) + return nil, newError(ErrInternal, fmt.Sprintf("Failed to sign algorithm '%s'", Algo_ECDSA_P384_SHA384), err) } } case Algo_ED25519: @@ -146,34 +179,27 @@ func sign(hrr httpMessage, sp sigParameters) error { if edpk, ok := sp.PrivateKey.(ed25519.PrivateKey); ok { sigBytes = ed25519.Sign(edpk, base.base) } else { - return newError(ErrInternal, "Invalid private key. Requires ed25519.PrivateKey") + return nil, newError(ErrInternal, "Invalid private key. Requires ed25519.PrivateKey") } } else { // No prehash function per the spec. sigBytes, err = pkSigner.Sign(nil, base.base, crypto.Hash(0)) if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to sign with crypto.Signer. Algorithm '%s'", Algo_ED25519), err) + return nil, newError(ErrInternal, fmt.Sprintf("Failed to sign with crypto.Signer. Algorithm '%s'", Algo_ED25519), err) } } case Algo_HMAC_SHA256: if len(sp.Secret) == 0 { - return newError(ErrInvalidSignatureOptions, fmt.Sprintf("No secret provided for symmetric algorithm '%s'", Algo_HMAC_SHA256)) + return nil, newError(ErrInvalidSignatureOptions, fmt.Sprintf("No secret provided for symmetric algorithm '%s'", Algo_HMAC_SHA256)) } msgHash := hmac.New(sha256.New, sp.Secret) msgHash.Write(base.base) // write does not return an error per hash.Hash documentation sigBytes = msgHash.Sum(nil) default: - return newError(ErrInvalidSignatureOptions, fmt.Sprintf("Signing algorithm not supported: '%s'", sp.Algo)) - } - sigField := sfv.NewDictionary() - sigField.Add(sp.Label, sfv.NewItem(sigBytes)) - signature, err := sfv.Marshal(sigField) - if err != nil { - return newError(ErrInternal, fmt.Sprintf("Failed to marshal signature for label '%s'", sp.Label), err) + return nil, newError(ErrInvalidSignatureOptions, fmt.Sprintf("Signing algorithm not supported: '%s'", sp.Algo)) } - hrr.Headers().Set("Signature-Input", fmt.Sprintf("%s=%s", sp.Label, base.signatureInput)) - hrr.Headers().Set("Signature", signature) - return nil + + return sigBytes, nil } // ecdsaHandleASN1 returns the R | S concatenated signature. If sigParameters expect an ASN1 encoded signature it will decode it first. 'origErr' can be passed in to avoid an extra error check step. diff --git a/verify.go b/verify.go index 9bc7217..3059a41 100644 --- a/verify.go +++ b/verify.go @@ -175,7 +175,7 @@ func (ver *Verifier) verify(hrr httpMessage) (VerifyResult, error) { } /* parse and extract the signature */ - sigsfv, err := parseSignaturesFromRequest(hrr.Headers()) + sigsfv, err := parseSignaturesFromRequest(hrr.Headers(), false) if err != nil { return vr, err } @@ -229,29 +229,37 @@ type signaturesSFV struct { Sigs *sfv.Dictionary } -func parseSignaturesFromRequest(headers http.Header) (signaturesSFV, error) { - psd := signaturesSFV{} +func parseSignaturesFromRequest(headers http.Header, allowEmpty bool) (signaturesSFV, error) { + psd := signaturesSFV{ + SigInputs: sfv.NewDictionary(), + Sigs: sfv.NewDictionary(), + } + /* Pull signature and signature-input header */ sigHeader := headers.Get("signature") - if sigHeader == "" { + if sigHeader == "" && !allowEmpty { return psd, newError(ErrNoSigMissingSignature, "Missing signature header") } sigInputHeader := headers.Get("signature-input") - if sigInputHeader == "" { + if sigInputHeader == "" && !allowEmpty { return psd, newError(ErrNoSigMissingSignature, "Missing signature-input header") } /* Parse headers into their appropriate HTTP structured field values */ // signature-input must be a HTTP structured field value of type Dictionary. var err error - psd.SigInputs, err = sfv.UnmarshalDictionary([]string{sigInputHeader}) - if err != nil { - return psd, newError(ErrNoSigInvalidSignature, "Invalid signature-input header. Not a valid Dictionary", err) + if sigInputHeader != "" { + psd.SigInputs, err = sfv.UnmarshalDictionary([]string{sigInputHeader}) + if err != nil { + return psd, newError(ErrNoSigInvalidSignature, "Invalid signature-input header. Not a valid Dictionary", err) + } } - // signature must be a HTTP structured field value of type Dictionary. - psd.Sigs, err = sfv.UnmarshalDictionary([]string{sigHeader}) - if err != nil { - return psd, newError(ErrNoSigInvalidSignature, "Invalid signature header. Not a valid Dictionary", err) + if sigHeader != "" { + // signature must be a HTTP structured field value of type Dictionary. + psd.Sigs, err = sfv.UnmarshalDictionary([]string{sigHeader}) + if err != nil { + return psd, newError(ErrNoSigInvalidSignature, "Invalid signature header. Not a valid Dictionary", err) + } } return psd, nil }