Skip to content

Commit 6cbac06

Browse files
crewjamfloren
andauthored
Add support for ECDSA in Service Providers (crewjam#602)
Rebased and refactored from crewjam#586 from @floren Co-authored-by: John Floren <[email protected]>
1 parent e074531 commit 6cbac06

21 files changed

+148
-96
lines changed

samlsp/new.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
package samlsp
33

44
import (
5+
"crypto"
6+
"crypto/ecdsa"
57
"crypto/rsa"
68
"crypto/x509"
9+
"fmt"
710
"net/http"
811
"net/url"
912

13+
"github.com/golang-jwt/jwt/v4"
1014
dsig "github.com/russellhaering/goxmldsig"
1115

1216
"github.com/crewjam/saml"
@@ -16,7 +20,7 @@ import (
1620
type Options struct {
1721
EntityID string
1822
URL url.URL
19-
Key *rsa.PrivateKey
23+
Key crypto.Signer
2024
Certificate *x509.Certificate
2125
Intermediates []*x509.Certificate
2226
HTTPClient *http.Client
@@ -33,11 +37,23 @@ type Options struct {
3337
LogoutBindings []string
3438
}
3539

40+
func getDefaultSigningMethod(signer crypto.Signer) jwt.SigningMethod {
41+
if signer != nil {
42+
switch signer.Public().(type) {
43+
case *ecdsa.PublicKey:
44+
return jwt.SigningMethodES256
45+
case *rsa.PublicKey:
46+
return jwt.SigningMethodRS256
47+
}
48+
}
49+
return jwt.SigningMethodRS256
50+
}
51+
3652
// DefaultSessionCodec returns the default SessionCodec for the provided options,
3753
// a JWTSessionCodec configured to issue signed tokens.
3854
func DefaultSessionCodec(opts Options) JWTSessionCodec {
3955
return JWTSessionCodec{
40-
SigningMethod: defaultJWTSigningMethod,
56+
SigningMethod: getDefaultSigningMethod(opts.Key),
4157
Audience: opts.URL.String(),
4258
Issuer: opts.URL.String(),
4359
MaxAge: defaultSessionMaxAge,
@@ -67,7 +83,7 @@ func DefaultSessionProvider(opts Options) CookieSessionProvider {
6783
// options, a JWTTrackedRequestCodec that uses a JWT to encode TrackedRequests.
6884
func DefaultTrackedRequestCodec(opts Options) JWTTrackedRequestCodec {
6985
return JWTTrackedRequestCodec{
70-
SigningMethod: defaultJWTSigningMethod,
86+
SigningMethod: getDefaultSigningMethod(opts.Key),
7187
Audience: opts.URL.String(),
7288
Issuer: opts.URL.String(),
7389
MaxAge: saml.MaxIssueDelay,
@@ -99,7 +115,8 @@ func DefaultServiceProvider(opts Options) saml.ServiceProvider {
99115
if opts.ForceAuthn {
100116
forceAuthn = &opts.ForceAuthn
101117
}
102-
signatureMethod := dsig.RSASHA1SignatureMethod
118+
119+
signatureMethod := defaultSigningMethodForKey(opts.Key)
103120
if !opts.SignRequest {
104121
signatureMethod = ""
105122
}
@@ -131,6 +148,19 @@ func DefaultServiceProvider(opts Options) saml.ServiceProvider {
131148
}
132149
}
133150

151+
func defaultSigningMethodForKey(key crypto.Signer) string {
152+
switch key.(type) {
153+
case *rsa.PrivateKey:
154+
return dsig.RSASHA1SignatureMethod
155+
case *ecdsa.PrivateKey:
156+
return dsig.ECDSASHA256SignatureMethod
157+
case nil:
158+
return ""
159+
default:
160+
panic(fmt.Sprintf("programming error: unsupported key type %T", key))
161+
}
162+
}
163+
134164
// DefaultAssertionHandler returns the default AssertionHandler for the provided options,
135165
// a NopAssertionHandler configured to do nothing.
136166
func DefaultAssertionHandler(_ Options) NopAssertionHandler {

samlsp/request_tracker_jwt.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package samlsp
22

33
import (
4-
"crypto/rsa"
4+
"crypto"
55
"fmt"
66
"time"
77

@@ -10,15 +10,13 @@ import (
1010
"github.com/crewjam/saml"
1111
)
1212

13-
var defaultJWTSigningMethod = jwt.SigningMethodRS256
14-
1513
// JWTTrackedRequestCodec encodes TrackedRequests as signed JWTs
1614
type JWTTrackedRequestCodec struct {
1715
SigningMethod jwt.SigningMethod
1816
Audience string
1917
Issuer string
2018
MaxAge time.Duration
21-
Key *rsa.PrivateKey
19+
Key crypto.Signer
2220
}
2321

2422
var _ TrackedRequestCodec = JWTTrackedRequestCodec{}

samlsp/session_jwt.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package samlsp
22

33
import (
4-
"crypto/rsa"
4+
"crypto"
55
"errors"
66
"fmt"
77
"time"
@@ -23,7 +23,7 @@ type JWTSessionCodec struct {
2323
Audience string
2424
Issuer string
2525
MaxAge time.Duration
26-
Key *rsa.PrivateKey
26+
Key crypto.Signer
2727
}
2828

2929
var _ SessionCodec = JWTSessionCodec{}

service_provider.go

Lines changed: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"compress/flate"
66
"context"
7+
"crypto"
8+
"crypto/ecdsa"
79
"crypto/rsa"
810
"crypto/sha256"
911
"crypto/sha512"
@@ -68,8 +70,9 @@ type ServiceProvider struct {
6870
// Entity ID is optional - if not specified then MetadataURL will be used
6971
EntityID string
7072

71-
// Key is the RSA private key we use to sign requests.
72-
Key *rsa.PrivateKey
73+
// Key is private key we use to sign requests. It must be either an
74+
// *rsa.PrivateKey or an *ecdsa.PrivateKey.
75+
Key crypto.Signer
7376

7477
// Certificate is the RSA public part of Key.
7578
Certificate *x509.Certificate
@@ -131,7 +134,17 @@ type ServiceProvider struct {
131134
// to verify signatures.
132135
SignatureVerifier SignatureVerifier
133136

134-
// SignatureMethod, if non-empty, authentication requests will be signed
137+
// SignatureMethod, if non-empty, authentication requests will be signed.
138+
//
139+
// The method specified here must be consistent with the type of Key.
140+
//
141+
// If Key is *rsa.PrivateKey, then this must be one of dsig.RSASHA1SignatureMethod,
142+
// dsig.RSASHA256SignatureMethod, dsig.RSASHA384SignatureMethod, or
143+
// dsig.RSASHA512SignatureMethod:
144+
//
145+
// If Key is *ecdsa.PrivateKey, then this must be one of dsig.ECDSASHA1SignatureMethod,
146+
// dsig.ECDSASHA256SignatureMethod, dsig.ECDSASHA384SignatureMethod, or
147+
// dsig.ECDSASHA512SignatureMethod.
135148
SignatureMethod string
136149

137150
// LogoutBindings specify the bindings available for SLO endpoint. If empty,
@@ -548,17 +561,38 @@ func GetSigningContext(sp *ServiceProvider) (*dsig.SigningContext, error) {
548561
// for _, cert := range sp.Intermediates {
549562
// keyPair.Certificate = append(keyPair.Certificate, cert.Raw)
550563
// }
551-
keyStore := dsig.TLSCertKeyStore(keyPair)
552564

553-
if sp.SignatureMethod != dsig.RSASHA1SignatureMethod &&
554-
sp.SignatureMethod != dsig.RSASHA256SignatureMethod &&
555-
sp.SignatureMethod != dsig.RSASHA512SignatureMethod {
565+
switch sp.SignatureMethod {
566+
case dsig.RSASHA1SignatureMethod,
567+
dsig.RSASHA256SignatureMethod,
568+
dsig.RSASHA384SignatureMethod,
569+
dsig.RSASHA512SignatureMethod:
570+
if _, ok := sp.Key.(*rsa.PrivateKey); !ok {
571+
return nil, fmt.Errorf("signature method %s requires a key of type rsa.PrivateKey, not %T", sp.SignatureMethod, sp.Key)
572+
}
573+
574+
case dsig.ECDSASHA1SignatureMethod,
575+
dsig.ECDSASHA256SignatureMethod,
576+
dsig.ECDSASHA384SignatureMethod,
577+
dsig.ECDSASHA512SignatureMethod:
578+
if _, ok := sp.Key.(*ecdsa.PrivateKey); !ok {
579+
return nil, fmt.Errorf("signature method %s requires a key of type ecdsa.PrivateKey, not %T", sp.SignatureMethod, sp.Key)
580+
}
581+
default:
556582
return nil, fmt.Errorf("invalid signing method %s", sp.SignatureMethod)
557583
}
558-
signatureMethod := sp.SignatureMethod
559-
signingContext := dsig.NewDefaultSigningContext(keyStore)
584+
585+
keyStore := dsig.TLSCertKeyStore(keyPair)
586+
chain, err := keyStore.GetChain()
587+
if err != nil {
588+
return nil, err
589+
}
590+
signingContext, err := dsig.NewSigningContext(sp.Key, chain)
591+
if err != nil {
592+
return nil, err
593+
}
560594
signingContext.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList(canonicalizerPrefixList)
561-
if err := signingContext.SetSignatureMethod(signatureMethod); err != nil {
595+
if err := signingContext.SetSignatureMethod(sp.SignatureMethod); err != nil {
562596
return nil, err
563597
}
564598

@@ -1307,31 +1341,12 @@ func (sp *ServiceProvider) validateSignature(el *etree.Element) error {
13071341

13081342
// SignLogoutRequest adds the `Signature` element to the `LogoutRequest`.
13091343
func (sp *ServiceProvider) SignLogoutRequest(req *LogoutRequest) error {
1310-
keyPair := tls.Certificate{
1311-
Certificate: [][]byte{sp.Certificate.Raw},
1312-
PrivateKey: sp.Key,
1313-
Leaf: sp.Certificate,
1314-
}
1315-
// TODO: add intermediates for SP
1316-
// for _, cert := range sp.Intermediates {
1317-
// keyPair.Certificate = append(keyPair.Certificate, cert.Raw)
1318-
// }
1319-
keyStore := dsig.TLSCertKeyStore(keyPair)
1320-
1321-
if sp.SignatureMethod != dsig.RSASHA1SignatureMethod &&
1322-
sp.SignatureMethod != dsig.RSASHA256SignatureMethod &&
1323-
sp.SignatureMethod != dsig.RSASHA512SignatureMethod {
1324-
return fmt.Errorf("invalid signing method %s", sp.SignatureMethod)
1325-
}
1326-
signatureMethod := sp.SignatureMethod
1327-
signingContext := dsig.NewDefaultSigningContext(keyStore)
1328-
signingContext.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList(canonicalizerPrefixList)
1329-
if err := signingContext.SetSignatureMethod(signatureMethod); err != nil {
1344+
signingContext, err := GetSigningContext(sp)
1345+
if err != nil {
13301346
return err
13311347
}
13321348

13331349
assertionEl := req.Element()
1334-
13351350
signedRequestEl, err := signingContext.SignEnveloped(assertionEl)
13361351
if err != nil {
13371352
return err
@@ -1361,7 +1376,7 @@ func (sp *ServiceProvider) MakeLogoutRequest(idpURL, nameID string) (*LogoutRequ
13611376
SPNameQualifier: sp.Metadata().EntityID,
13621377
},
13631378
}
1364-
if len(sp.SignatureMethod) > 0 {
1379+
if sp.SignatureMethod != "" {
13651380
if err := sp.SignLogoutRequest(&req); err != nil {
13661381
return nil, err
13671382
}
@@ -1475,7 +1490,7 @@ func (sp *ServiceProvider) MakeLogoutResponse(idpURL, logoutRequestID string) (*
14751490
},
14761491
}
14771492

1478-
if len(sp.SignatureMethod) > 0 {
1493+
if sp.SignatureMethod != "" {
14791494
if err := sp.SignLogoutResponse(&response); err != nil {
14801495
return nil, err
14811496
}
@@ -1572,31 +1587,12 @@ func (r *LogoutResponse) Post(relayState string) []byte {
15721587

15731588
// SignLogoutResponse adds the `Signature` element to the `LogoutResponse`.
15741589
func (sp *ServiceProvider) SignLogoutResponse(resp *LogoutResponse) error {
1575-
keyPair := tls.Certificate{
1576-
Certificate: [][]byte{sp.Certificate.Raw},
1577-
PrivateKey: sp.Key,
1578-
Leaf: sp.Certificate,
1579-
}
1580-
// TODO: add intermediates for SP
1581-
// for _, cert := range sp.Intermediates {
1582-
// keyPair.Certificate = append(keyPair.Certificate, cert.Raw)
1583-
// }
1584-
keyStore := dsig.TLSCertKeyStore(keyPair)
1585-
1586-
if sp.SignatureMethod != dsig.RSASHA1SignatureMethod &&
1587-
sp.SignatureMethod != dsig.RSASHA256SignatureMethod &&
1588-
sp.SignatureMethod != dsig.RSASHA512SignatureMethod {
1589-
return fmt.Errorf("invalid signing method %s", sp.SignatureMethod)
1590-
}
1591-
signatureMethod := sp.SignatureMethod
1592-
signingContext := dsig.NewDefaultSigningContext(keyStore)
1593-
signingContext.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList(canonicalizerPrefixList)
1594-
if err := signingContext.SetSignatureMethod(signatureMethod); err != nil {
1590+
signingContext, err := GetSigningContext(sp)
1591+
if err != nil {
15951592
return err
15961593
}
15971594

15981595
assertionEl := resp.Element()
1599-
16001596
signedRequestEl, err := signingContext.SignEnveloped(assertionEl)
16011597
if err != nil {
16021598
return err

service_provider_test.go

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -267,39 +267,53 @@ func TestSPCanProducePostRequest(t *testing.T) {
267267
}
268268

269269
func TestSPCanProduceSignedRequestRedirectBinding(t *testing.T) {
270-
test := NewServiceProviderTest(t)
271-
TimeNow = func() time.Time {
272-
rv, _ := time.Parse("Mon Jan 2 15:04:05.999999999 UTC 2006", "Mon Dec 1 01:31:21.123456789 UTC 2015")
273-
return rv
274-
}
275-
Clock = dsig.NewFakeClockAt(TimeNow())
276-
s := ServiceProvider{
277-
Key: test.Key,
278-
Certificate: test.Certificate,
279-
MetadataURL: mustParseURL("https://15661444.ngrok.io/saml2/metadata"),
280-
AcsURL: mustParseURL("https://15661444.ngrok.io/saml2/acs"),
281-
IDPMetadata: &EntityDescriptor{},
282-
SignatureMethod: dsig.RSASHA1SignatureMethod,
270+
for _, alg := range []string{
271+
dsig.RSASHA1SignatureMethod,
272+
dsig.RSASHA256SignatureMethod,
273+
dsig.RSASHA384SignatureMethod,
274+
dsig.RSASHA512SignatureMethod,
275+
dsig.ECDSASHA1SignatureMethod,
276+
dsig.ECDSASHA256SignatureMethod,
277+
dsig.ECDSASHA384SignatureMethod,
278+
dsig.ECDSASHA512SignatureMethod,
279+
} {
280+
testName := strings.Split(alg, "#")[1]
281+
t.Run(testName, func(t *testing.T) {
282+
test := NewServiceProviderTest(t)
283+
TimeNow = func() time.Time {
284+
rv, _ := time.Parse("Mon Jan 2 15:04:05.999999999 UTC 2006", "Mon Dec 1 01:31:21.123456789 UTC 2015")
285+
return rv
286+
}
287+
Clock = dsig.NewFakeClockAt(TimeNow())
288+
s := ServiceProvider{
289+
Key: test.Key,
290+
Certificate: test.Certificate,
291+
MetadataURL: mustParseURL("https://15661444.ngrok.io/saml2/metadata"),
292+
AcsURL: mustParseURL("https://15661444.ngrok.io/saml2/acs"),
293+
IDPMetadata: &EntityDescriptor{},
294+
SignatureMethod: dsig.RSASHA1SignatureMethod,
295+
}
296+
err := xml.Unmarshal(test.IDPMetadata, &s.IDPMetadata)
297+
assert.Check(t, err)
298+
299+
redirectURL, err := s.MakeRedirectAuthenticationRequest("relayState")
300+
assert.Assert(t, err)
301+
// Signature we check against in the query string was validated with
302+
// https://www.samltool.com/validate_authn_req.php . Once we add
303+
// support for validating signed AuthN requests in the IDP implementation
304+
// we can switch to testing using that.
305+
golden.Assert(t, redirectURL.RawQuery, t.Name()+"_queryString")
306+
307+
decodedRequest, err := testsaml.ParseRedirectRequest(redirectURL)
308+
assert.Check(t, err)
309+
assert.Check(t, is.Equal("idp.testshib.org",
310+
redirectURL.Host))
311+
assert.Check(t, is.Equal("/idp/profile/SAML2/Redirect/SSO",
312+
redirectURL.Path))
313+
// Contains no enveloped signature
314+
golden.Assert(t, string(decodedRequest), t.Name()+"_decodedRequest")
315+
})
283316
}
284-
err := xml.Unmarshal(test.IDPMetadata, &s.IDPMetadata)
285-
assert.Check(t, err)
286-
287-
redirectURL, err := s.MakeRedirectAuthenticationRequest("relayState")
288-
assert.Check(t, err)
289-
// Signature we check against in the query string was validated with
290-
// https://www.samltool.com/validate_authn_req.php . Once we add
291-
// support for validating signed AuthN requests in the IDP implementation
292-
// we can switch to testing using that.
293-
golden.Assert(t, redirectURL.RawQuery, t.Name()+"_queryString")
294-
295-
decodedRequest, err := testsaml.ParseRedirectRequest(redirectURL)
296-
assert.Check(t, err)
297-
assert.Check(t, is.Equal("idp.testshib.org",
298-
redirectURL.Host))
299-
assert.Check(t, is.Equal("/idp/profile/SAML2/Redirect/SSO",
300-
redirectURL.Path))
301-
// Contains no enveloped signature
302-
golden.Assert(t, string(decodedRequest), t.Name()+"_decodedRequest")
303317
}
304318

305319
func TestSPCanProduceSignedRequestPostBinding(t *testing.T) {
File renamed without changes.
File renamed without changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<samlp:AuthnRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="id-00020406080a0c0e10121416181a1c1e20222426" Version="2.0" IssueInstant="2015-12-01T01:31:21.123Z" Destination="https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" AssertionConsumerServiceURL="https://15661444.ngrok.io/saml2/acs" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://15661444.ngrok.io/saml2/metadata</saml:Issuer><samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" AllowCreate="true"/></samlp:AuthnRequest>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SAMLRequest=nFJNb9swDP0rhu62RNU1CqE2kDUYFqBbgzjbYTfVZhNituSJ9Lb%2B%2B8Fph2WXDOiV4uP70LtlPw6TW81yDDv8PiNL9mscArvloVZzCi56JnbBj8hOOteuPt47WxjnmTEJxaDOINNlzJSixC4OKtusa0V9boyxpjSVuTHedAbBgIUSKrgBDx2gNdba0lYq%2B4KJKYZa2cKobMM84yaw%2BCC1sgauc7C5gb0BdwXOQgH26qvK1shCwcsJeRSZ2GlN%2FVQIsvCRHouYDstATyk%2B0YB60Wr1DntK2Ilu2weVrf5YvYuB5xFTi%2BkHdfh5d%2F%2F3KlxXFZRlWYRDit8KinoJxGrfscq2r8bfUegpHC6n9PiyxO7Dfr%2FNtw%2FtXjWnn3In2yl7H9Po5fKRZUJ9%2FnRadRiE5Fk1%2FxM7ovjei7%2FVZ3zNa00%2B%2BRE3620cqHt%2BgwZJPjBhEJWthiH%2BvEvoBWslaUalmxfKf8vY%2FA4AAP%2F%2F&RelayState=relayState&SigAlg=http%3A%2F%2Fwww.w3.org%2F2000%2F09%2Fxmldsig%23rsa-sha1&Signature=WqMc7vKRJVNXwNHJmTemdfw5OML2XkLntYw%2FzwKoLMfavV%2FYy6fBP0GeGYlJVMweZBvbpjwoe%2BgpRkUCHKDUgixCG7hPi41p6MpQC%2Fp7ExTW5plvlS97iVAOvaF5V1MjvQCgBNKYnKNnvwAuxK%2Bu3N4rZjwGM%2F4JGgjJ5pannFQ%3D
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<samlp:AuthnRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="id-00020406080a0c0e10121416181a1c1e20222426" Version="2.0" IssueInstant="2015-12-01T01:31:21.123Z" Destination="https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" AssertionConsumerServiceURL="https://15661444.ngrok.io/saml2/acs" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://15661444.ngrok.io/saml2/metadata</saml:Issuer><samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" AllowCreate="true"/></samlp:AuthnRequest>

0 commit comments

Comments
 (0)