diff --git a/examples_test.go b/examples_test.go index 37b8e9e..cd44342 100644 --- a/examples_test.go +++ b/examples_test.go @@ -36,6 +36,28 @@ BznPJ5sSI1Jn+srosJB/GbEZ3Kg6PcEi+jODF9fdpNEaHGbbGdaVhJi1 signer.Sign(req) } +func ExampleSigningKeyOpts() { + // Create your TPM instance. It must implement crypto.Signer + var tpm crypto.Signer + + req := httptest.NewRequest("GET", "https://example.com/data", nil) + + profile := httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, + } + skey := httpsig.SigningKey{ + Opts: httpsig.SigningKeyOpts{ + Signer: tpm, + }, + MetaKeyID: "key123", + } + + signer, _ := httpsig.NewSigner(profile, skey) + signer.Sign(req) +} + func ExampleVerify() { pubkeyEncoded := `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIUctKvU5L/eEYxua5Zlz0HIQJRQq diff --git a/keyutil/keyutil.go b/keyutil/keyutil.go index 4018dbf..529d92a 100644 --- a/keyutil/keyutil.go +++ b/keyutil/keyutil.go @@ -5,6 +5,8 @@ package keyutil import ( "crypto" + "crypto/ecdsa" + "crypto/ed25519" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" @@ -68,6 +70,21 @@ func MustReadPrivateKeyFile(pkFile string) crypto.PrivateKey { return pk } +func MustReadPrivateKeyFileECDSA(pkFile string) *ecdsa.PrivateKey { + pk := MustReadPrivateKeyFile(pkFile) + if ecpk, ok := pk.(*ecdsa.PrivateKey); ok { + return ecpk + } + panic(fmt.Sprintf("Not ECDSA private key file: %s", pkFile)) +} +func MustReadPrivateKeyFileED25519(pkFile string) ed25519.PrivateKey { + pk := MustReadPrivateKeyFile(pkFile) + if edpk, ok := pk.(ed25519.PrivateKey); ok { + return edpk + } + panic("Not ed25519 private key file") +} + func MustReadPrivateKey(encodedPrivateKey []byte) crypto.PrivateKey { pkey, err := ReadPrivateKey(encodedPrivateKey) if err != nil { diff --git a/roundtrip_test.go b/roundtrip_test.go index 5f35c5c..65911d9 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -2,6 +2,9 @@ package httpsig_test import ( "crypto" + "crypto/ecdsa" + "io" + "math/big" "testing" "github.com/remitly-oss/httpsig-go" @@ -16,6 +19,7 @@ func TestRoundTrip(t *testing.T) { testcases := []struct { Name string PrivateKey crypto.PrivateKey + SigningOpts httpsig.SigningKeyOpts MetaKeyID string Secret []byte SignProfile httpsig.SigningProfile @@ -90,7 +94,7 @@ func TestRoundTrip(t *testing.T) { Profile: createVerifyProfile("sig1"), }, { - Name: "ECDSA-p265", + Name: "ECDSA-p256", PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p256.key"), MetaKeyID: "test-key-ecdsa", SignProfile: httpsig.SigningProfile{ @@ -109,6 +113,54 @@ func TestRoundTrip(t *testing.T) { }), Profile: createVerifyProfile("tst-ecdsa"), }, + { + Name: "ECDSA-p256-Signer-ASN1", + SigningOpts: httpsig.SigningKeyOpts{ + Signer: keyutil.MustReadPrivateKeyFileECDSA("testdata/test-key-ecc-p256.key"), + ASN1ForECDSA: true, + }, + 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", + }, + 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"), + }, + { + Name: "ECDSA-p256-Signer-NoASN1", + SigningOpts: httpsig.SigningKeyOpts{ + Signer: &testSignerNoASN1{ + PK: keyutil.MustReadPrivateKeyFileECDSA("testdata/test-key-ecc-p256.key"), + SigSize: 64, + }, + }, + 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", + }, + 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"), + }, { Name: "ECDSA-p384", PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p384.key"), @@ -129,6 +181,54 @@ func TestRoundTrip(t *testing.T) { }), Profile: createVerifyProfile("tst-ecdsa"), }, + { + Name: "ECDSA-p384-Signer-ASN1", + SigningOpts: httpsig.SigningKeyOpts{ + Signer: keyutil.MustReadPrivateKeyFileECDSA("testdata/test-key-ecc-p384.key"), + ASN1ForECDSA: true, + }, + MetaKeyID: "test-key-ecdsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.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{ + "test-key-ecdsa": { + KeyID: "test-key-ecdsa", + Algo: httpsig.Algo_ECDSA_P384_SHA384, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p384.pub"), + }, + }), + Profile: createVerifyProfile("tst-ecdsa"), + }, + { + Name: "ECDSA-p384-Signer-NoASN1", + SigningOpts: httpsig.SigningKeyOpts{ + Signer: &testSignerNoASN1{ + PK: keyutil.MustReadPrivateKeyFileECDSA("testdata/test-key-ecc-p384.key"), + SigSize: 96, + }, + }, + MetaKeyID: "test-key-ecdsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.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{ + "test-key-ecdsa": { + KeyID: "test-key-ecdsa", + Algo: httpsig.Algo_ECDSA_P384_SHA384, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p384.pub"), + }, + }), + Profile: createVerifyProfile("tst-ecdsa"), + }, { Name: "ED25519", PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ed25519.key"), @@ -149,6 +249,28 @@ func TestRoundTrip(t *testing.T) { }), Profile: createVerifyProfile("tst-ed"), }, + { + Name: "ED25519-Signer", + SigningOpts: httpsig.SigningKeyOpts{ + Signer: keyutil.MustReadPrivateKeyFileED25519("testdata/test-key-ed25519.key"), + }, + MetaKeyID: "test-key-ed", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.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{ + "test-key-ed": { + KeyID: "test-key-ed", + Algo: httpsig.Algo_ED25519, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ed25519.pub"), + }, + }), + Profile: createVerifyProfile("tst-ed"), + }, { Name: "BadDigest", PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ed25519.key"), @@ -178,6 +300,7 @@ func TestRoundTrip(t *testing.T) { var signer *httpsig.Signer sk := httpsig.SigningKey{ Key: tc.PrivateKey, + Opts: tc.SigningOpts, Secret: tc.Secret, MetaKeyID: tc.MetaKeyID, } @@ -221,3 +344,28 @@ func createVerifyProfile(label string) httpsig.VerifyProfile { vp.SignatureLabel = label return vp } + +type testSignerNoASN1 struct { + PK *ecdsa.PrivateKey + SigSize int +} + +func (ts *testSignerNoASN1) Sign(rand io.Reader, message []byte, opts crypto.SignerOpts) (signature []byte, err error) { + r, s, err := ecdsa.Sign(rand, ts.PK, message) + if err != nil { + return nil, err + } + return ecdsaConcatRS(r, s, ts.SigSize), nil +} + +func (ts *testSignerNoASN1) Public() crypto.PublicKey { + return ts.PK.PublicKey +} + +func ecdsaConcatRS(r, s *big.Int, signatureSize int) []byte { + half := signatureSize / 2 + sigBytes := make([]byte, signatureSize) + r.FillBytes(sigBytes[0:half]) + s.FillBytes(sigBytes[half:signatureSize]) + return sigBytes +} diff --git a/sign.go b/sign.go index 025721c..8e2b5eb 100644 --- a/sign.go +++ b/sign.go @@ -84,11 +84,16 @@ func Fields(fields ...string) []SignedField { type SigningKey struct { Key crypto.PrivateKey // private key for asymmetric algorithms 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. } +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. +} type Signer struct { profile SigningProfile skey SigningKey @@ -146,6 +151,7 @@ func (s *Signer) Sign(req *http.Request) error { Algo: s.profile.Algorithm, PrivateKey: s.skey.Key, Secret: s.skey.Secret, + Opts: s.skey.Opts, Label: s.profile.Label, }) } @@ -165,6 +171,7 @@ func (s *Signer) SignResponse(resp *http.Response) error { Algo: s.profile.Algorithm, PrivateKey: s.skey.Key, Secret: s.skey.Secret, + Opts: s.skey.Opts, Label: s.profile.Label, }) } @@ -214,8 +221,8 @@ func (so SigningProfile) validate(skey SigningKey) error { if so.Algorithm.symmetric() && len(skey.Secret) == 0 { return newError(ErrInvalidSignatureOptions, "Missing required 'Secret' value in SigningKey") } - if !so.Algorithm.symmetric() && skey.Key == nil { - return newError(ErrInvalidSignatureOptions, "Missing required 'Key' value in SigningKey") + 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) { return fmt.Errorf("Invalid label name '%s'", so.Label) diff --git a/signatures.go b/signatures.go index 8043e58..cd0aa37 100644 --- a/signatures.go +++ b/signatures.go @@ -9,7 +9,9 @@ import ( "crypto/rsa" "crypto/sha256" "crypto/sha512" + "encoding/asn1" "fmt" + "math/big" "time" sfv "github.com/dunglas/httpsfv" @@ -24,6 +26,9 @@ const ( path derived = "@path" targetURI derived = "@target-uri" authority derived = "@authority" + + ecdsaP256SignatureSize = 64 + ecdsaP384SignatureSize = 96 ) // MetadataProvider allows customized functions for metadata parameter values. Not needed for default usage. @@ -47,6 +52,7 @@ type sigParameters struct { Label string PrivateKey crypto.PrivateKey Secret []byte + Opts SigningKeyOpts } func sign(hrr httpMessage, sp sigParameters) error { @@ -56,65 +62,98 @@ func sign(hrr httpMessage, sp sigParameters) error { } var sigBytes []byte - switch sp.Algo { + 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 + // If crypto.PrivateKey is provided do a type check that the PrivateKey type matches the signing algorithm. + switch sp.Algo { case Algo_RSA_PSS_SHA512: - if rsapk, ok := sp.PrivateKey.(*rsa.PrivateKey); ok { - msgHash := sha512.Sum512(base.base) - opts := &rsa.PSSOptions{ - SaltLength: 64, - Hash: crypto.SHA512, + 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)) } - sigBytes, err = rsa.SignPSS(rand.Reader, rsapk, crypto.SHA512, msgHash[:], opts) - if err != nil { - return err - } - } else { - return fmt.Errorf("Invalid private key. Requires rsa.PrivateKey: %T", sp.PrivateKey) + } + + msgHash := sha512.Sum512(base.base) + opts := &rsa.PSSOptions{ + SaltLength: 64, + Hash: crypto.SHA512, + } + 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) } case Algo_RSA_v1_5_sha256: - if rsapk, ok := sp.PrivateKey.(*rsa.PrivateKey); ok { - msgHash := sha256.Sum256(base.base) - sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsapk, crypto.SHA256, msgHash[:]) - if err != nil { - return err + 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)) } - } else { - return fmt.Errorf("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) } case Algo_ECDSA_P256_SHA256: - if eccpk, ok := sp.PrivateKey.(*ecdsa.PrivateKey); ok { - msgHash := sha256.Sum256(base.base) - r, s, err := ecdsa.Sign(rand.Reader, eccpk, msgHash[:]) - if err != nil { - return newError(ErrInternal, "Failed to sign with ecdsa private key", err) + msgHash := sha256.Sum256(base.base) + if pkSigner == nil { + if eccpk, ok := sp.PrivateKey.(*ecdsa.PrivateKey); ok { + // 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) + } + // 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") } - // Concatenate r and s to make the signature as per the spec. r and s are *not* encoded in ASN1 format - sigBytes = make([]byte, 64) - r.FillBytes(sigBytes[0:32]) - s.FillBytes(sigBytes[32:64]) } else { - return fmt.Errorf("Invalid private key. Requires *ecdsa.PrivateKey") + // 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) + } } case Algo_ECDSA_P384_SHA384: - if eccpk, ok := sp.PrivateKey.(*ecdsa.PrivateKey); ok { - msgHash := sha512.Sum384(base.base) - r, s, err := ecdsa.Sign(rand.Reader, eccpk, msgHash[:]) - if err != nil { - return newError(ErrInternal, "Failed to sign with ecdsa private key", err) + msgHash := sha512.Sum384(base.base) + if pkSigner == nil { + 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) + } + // 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") } - // Concatenate r and s to make the signature as per the spec. r and s are *not* encoded in ASN1 format - sigBytes = make([]byte, 96) - r.FillBytes(sigBytes[0:48]) - s.FillBytes(sigBytes[48:96]) } else { - return fmt.Errorf("Invalid private key. Requires *ecdsa.PrivateKey") + // 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) + } } case Algo_ED25519: - if edpk, ok := sp.PrivateKey.(ed25519.PrivateKey); ok { - sigBytes = ed25519.Sign(edpk, base.base) + if pkSigner == nil { + if edpk, ok := sp.PrivateKey.(ed25519.PrivateKey); ok { + sigBytes = ed25519.Sign(edpk, base.base) + } else { + return newError(ErrInternal, "Invalid private key. Requires ed25519.PrivateKey") + } } else { - return fmt.Errorf("Invalid private key. Requires ed25519.PrivateKey") + // 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) + } } case Algo_HMAC_SHA256: if len(sp.Secret) == 0 { @@ -137,6 +176,32 @@ func sign(hrr httpMessage, sp sigParameters) error { return 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. +func (sp sigParameters) ecdsaHandleASN1(origErr error, sig []byte, sigsize int) ([]byte, error) { + if origErr != nil { + return sig, origErr + } + if !sp.Opts.ASN1ForECDSA { + return sig, nil + } + // HTTP Signatures spec uses R|S concat instead of ASN.1. Have to decode the crypto.Sign result. + encSig := struct { + R, S *big.Int + }{} + if _, err := asn1.Unmarshal(sig, &encSig); err != nil || encSig.R == nil || encSig.S == nil { + return sig, newError(ErrInternal, "Failed to sign with ecdsa private key. Sign did not return ASN.1 signature", err) + } + return ecdsaConcatRS(encSig.R, encSig.S, sigsize), nil +} + +func ecdsaConcatRS(r, s *big.Int, signatureSize int) []byte { + half := signatureSize / 2 + sigBytes := make([]byte, signatureSize) + r.FillBytes(sigBytes[0:half]) + s.FillBytes(sigBytes[half:signatureSize]) + return sigBytes +} + func timestamp(nowtime func() time.Time) int { return int(nowtime().Unix()) }