diff --git a/README.md b/README.md index 019e765..d9a5a5c 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,53 @@ # go-keypairs -The lightest touch over top of Go's `crypto/ecdsa` and `crypto/rsa` to make them -*typesafe* and to provide JSON Web Key (JWK) support. +JSON Web Key (JWK) support and type safety lightly placed over top of Go's `crypto/ecdsa` and `crypto/rsa` -# Documentation +Useful for JWT, JOSE, etc. -Use the source, Luke! +```go +key, err := keypairs.ParsePrivateKey(bytesForJWKOrPEMOrDER) - +pub, err := keypairs.ParsePublicKey(bytesForJWKOrPEMOrDER) + +jwk, err := keypairs.MarshalJWKPublicKey(pub, time.Now().Add(2 * time.Day)) + +kid, err := keypairs.ThumbprintPublicKey(pub) +``` + +# API Documentation + +See # Philosophy -Always remember: +Go's standard library is great. -> Don't roll your own crypto. +Go has _excellent_ crytography support and provides wonderful +primitives for dealing with them. -But also remember: - -> Just because you _don't_ know someone doesn't make them smart. - -Don't get the two mixed up! - -(furthermore, [just because you _do_ know someone doesn't make them _not_ smart](https://www.humancondition.com/asid-prophets-without-honour-in-their-own-home/)) - -Although I would **not** want to invent my own cryptographic algorithm, -I've read enough source code to know that, for standards I know well, -I feel much more confident in the security, extensibility, and documentation -of tooling that I've write myself. +I prefer to stay as close to Go's `crypto` package as possible, +just adding a light touch for JWT support and type safety. # Type Safety -Go has _excellent_ crytography support and provides wonderful -primitives for dealing with them. Its Achilles' heel is they're **not typesafe**! +`crypto.PublicKey` is a "marker interface", meaning that it is **not typesafe**! -As of Go 1.11.5 `crypto.PublicKey` and `crypto.PrivateKey` are "marker interfaces" -or, in other words, empty interfaces that only serve to document intent without -actually providing a constraint to the type system. - -go-keypairs defines `type keypairs.PrivateKey interface { Public() crypto.PublicKey }`, +`go-keypairs` defines `type keypairs.PrivateKey interface { Public() crypto.PublicKey }`, which is implemented by `crypto/rsa` and `crypto/ecdsa` (but not `crypto/dsa`, which we really don't care that much about). +Go1.15 will add `[PublicKey.Equal(crypto.PublicKey)](https://github.com/golang/go/issues/21704)`, +which will make it possible to remove the additional wrapper over `PublicKey` +and use an interface instead. + Since there are no common methods between `rsa.PublicKey` and `ecdsa.PublicKey`, go-keypairs lightly wraps each to implement `Thumbprint() string` (part of the JOSE/JWK spec). -# JSON Web Key "codec" +## JSON Web Key (JWK) as a "codec" Although there are many, many ways that JWKs could be interpreted -(possibly why they haven't made it into the standard library), go-keypairs -follows the basic pattern of `encoding/x509` to Parse and Marshal +(possibly why they haven't made it into the standard library), `go-keypairs` +follows the basic pattern of `encoding/x509` to `Parse` and `Marshal` only the most basic and most meaningful parts of a key. I highly recommend that you use `Thumbprint()` for `KeyID` you also @@ -57,6 +56,7 @@ between the ASN.1, x509, PEM, and JWK formats. # LICENSE +Copyright (c) 2020-present AJ ONeal Copyright (c) 2018-2019 Big Squid, Inc. This work is licensed under the terms of the MIT license. diff --git a/keyfetch/fetch.go b/keyfetch/fetch.go index 915f8cb..7c23569 100644 --- a/keyfetch/fetch.go +++ b/keyfetch/fetch.go @@ -23,11 +23,23 @@ import ( "github.com/big-squid/go-keypairs/keyfetch/uncached" ) +// TODO should be ErrInvalidJWKURL + +// EInvalidJWKURL means that the url did not provide JWKs var EInvalidJWKURL = errors.New("url does not lead to valid JWKs") + +// KeyCache is an in-memory key cache var KeyCache = map[string]CachableKey{} + +// KeyCacheMux is used to guard the in-memory cache var KeyCacheMux = sync.Mutex{} + +// ErrInsecureDomain means that plain http was used where https was expected var ErrInsecureDomain = errors.New("Whitelists should only allow secure URLs (i.e. https://). To allow unsecured private networking (i.e. Docker) pass PrivateWhitelist as a list of private URLs") +// TODO Cacheable key (shouldn't this be private)? + +// CachableKey represents type CachableKey struct { Key keypairs.PublicKey Expiry time.Time @@ -55,50 +67,64 @@ type ID interface { } */ +// StaleTime defines when public keys should be renewed (15 minutes by default) var StaleTime = 15 * time.Minute + +// DefaultKeyDuration defines how long a key should be considered fresh (48 hours by default) var DefaultKeyDuration = 48 * time.Hour + +// MinimumKeyDuration defines the minimum time that a key will be cached (1 hour by default) var MinimumKeyDuration = time.Hour + +// MaximumKeyDuration defines the maximum time that a key will be cached (72 hours by default) var MaximumKeyDuration = 72 * time.Hour -type publicKeysMap map[string]keypairs.PublicKey +// PublicKeysMap is a newtype for a map of keypairs.PublicKey +type PublicKeysMap map[string]keypairs.PublicKey -// FetchOIDCPublicKeys fetches baseURL + ".well-known/openid-configuration" and then returns FetchPublicKeys(jwks_uri). -func OIDCJWKs(baseURL string) (publicKeysMap, error) { - if maps, keys, err := uncached.OIDCJWKs(baseURL); nil != err { +// OIDCJWKs fetches baseURL + ".well-known/openid-configuration" and then fetches and returns the Public Keys. +func OIDCJWKs(baseURL string) (PublicKeysMap, error) { + maps, keys, err := uncached.OIDCJWKs(baseURL) + + if nil != err { return nil, err - } else { - cacheKeys(maps, keys, baseURL) - return keys, err } + cacheKeys(maps, keys, baseURL) + return keys, err } +// OIDCJWK fetches baseURL + ".well-known/openid-configuration" and then returns the key matching kid (or thumbprint) func OIDCJWK(kidOrThumb, iss string) (keypairs.PublicKey, error) { return immediateOneOrFetch(kidOrThumb, iss, uncached.OIDCJWKs) } -func WellKnownJWKs(kidOrThumb, iss string) (publicKeysMap, error) { - if maps, keys, err := uncached.WellKnownJWKs(iss); nil != err { +// WellKnownJWKs fetches baseURL + ".well-known/jwks.json" and caches and returns the keys +func WellKnownJWKs(kidOrThumb, iss string) (PublicKeysMap, error) { + maps, keys, err := uncached.WellKnownJWKs(iss) + + if nil != err { return nil, err - } else { - cacheKeys(maps, keys, iss) - return keys, err } + cacheKeys(maps, keys, iss) + return keys, err } +// WellKnownJWK fetches baseURL + ".well-known/jwks.json" and returns the key matching kid (or thumbprint) func WellKnownJWK(kidOrThumb, iss string) (keypairs.PublicKey, error) { return immediateOneOrFetch(kidOrThumb, iss, uncached.WellKnownJWKs) } // JWKs returns a map of keys identified by their thumbprint // (since kid may or may not be present) -func JWKs(jwksurl string) (publicKeysMap, error) { - if maps, keys, err := uncached.JWKs(jwksurl); nil != err { +func JWKs(jwksurl string) (PublicKeysMap, error) { + maps, keys, err := uncached.JWKs(jwksurl) + + if nil != err { return nil, err - } else { - iss := strings.Replace(jwksurl, ".well-known/jwks.json", "", 1) - cacheKeys(maps, keys, iss) - return keys, err } + iss := strings.Replace(jwksurl, ".well-known/jwks.json", "", 1) + cacheKeys(maps, keys, iss) + return keys, err } // JWK tries to return a key from cache, falling back to the /.well-known/jwks.json of the issuer @@ -379,7 +405,7 @@ func hasImplicitTrust(issURL *url.URL, r *http.Request) bool { return true } -// Whitelist +// Whitelist is a newtype for an array of URLs type Whitelist []*url.URL // NewWhitelist turns an array of URLs (such as https://example.com/) into diff --git a/keyfetch/issuer_test.go b/keyfetch/issuer_test.go index 5baba5a..fb8e614 100644 --- a/keyfetch/issuer_test.go +++ b/keyfetch/issuer_test.go @@ -34,7 +34,7 @@ func TestIssuerMatches(t *testing.T) { _, err := NewWhitelist(append(trusted, privates...)) if nil == err { - t.Fatal(errors.New("An insecure domain got through!")) + t.Fatal(errors.New("an insecure domain got through")) } // Empty list is allowed... I guess? diff --git a/keyfetch/uncached/fetch.go b/keyfetch/uncached/fetch.go index 532170c..5d68d23 100644 --- a/keyfetch/uncached/fetch.go +++ b/keyfetch/uncached/fetch.go @@ -66,12 +66,13 @@ func JWKs(jwksurl string) (map[string]map[string]string, map[string]keypairs.Pub k := resp.Keys[i] m := getStringMap(k) - if key, err := keypairs.NewJWKPublicKey(m); nil != err { + key, err := keypairs.NewJWKPublicKey(m) + + if nil != err { return nil, nil, err - } else { - keys[key.Thumbprint()] = key - maps[key.Thumbprint()] = m } + keys[key.Thumbprint()] = key + maps[key.Thumbprint()] = m } return maps, keys, nil diff --git a/keypairs.go b/keypairs.go index 7194bc3..ae6012b 100644 --- a/keypairs.go +++ b/keypairs.go @@ -21,18 +21,37 @@ import ( "time" ) +// ErrInvalidPrivateKey means that the key is not a valid Private Key var ErrInvalidPrivateKey = errors.New("PrivateKey must be of type *rsa.PrivateKey or *ecdsa.PrivateKey") + +// ErrInvalidPublicKey means that the key is not a valid Public Key var ErrInvalidPublicKey = errors.New("PublicKey must be of type *rsa.PublicKey or *ecdsa.PublicKey") + +// ErrParsePublicKey means that the bytes cannot be parsed in any known format var ErrParsePublicKey = errors.New("PublicKey bytes could not be parsed as PEM or DER (PKIX/SPKI, PKCS1, or X509 Certificate) or JWK") + +// ErrParsePrivateKey means that the bytes cannot be parsed in any known format var ErrParsePrivateKey = errors.New("PrivateKey bytes could not be parsed as PEM or DER (PKCS8, SEC1, or PKCS1) or JWK") + +// ErrParseJWK means that the JWK is valid JSON but not a valid JWK var ErrParseJWK = errors.New("JWK is missing required base64-encoded JSON fields") + +// ErrInvalidKeyType means that the key is not an acceptable type var ErrInvalidKeyType = errors.New("The JWK's 'kty' must be either 'RSA' or 'EC'") + +// ErrInvalidCurve means that a non-standard curve was used var ErrInvalidCurve = errors.New("The JWK's 'crv' must be either of the NIST standards 'P-256' or 'P-384'") + +// ErrUnexpectedPublicKey means that a Private Key was expected var ErrUnexpectedPublicKey = errors.New("PrivateKey was given where PublicKey was expected") + +// ErrUnexpectedPrivateKey means that a Public Key was expected var ErrUnexpectedPrivateKey = errors.New("PublicKey was given where PrivateKey was expected") +// ErrDevSwapPrivatePublic means that the developer compiled bad code that swapped public and private keys const ErrDevSwapPrivatePublic = "[Developer Error] You passed either crypto.PrivateKey or crypto.PublicKey where the other was expected." +// ErrDevBadKeyType means that the developer compiled bad code that passes the wrong type const ErrDevBadKeyType = "[Developer Error] crypto.PublicKey and crypto.PrivateKey are somewhat deceptive. They're actually empty interfaces that accept any object, even non-crypto objects. You passed an object of type '%T' by mistake." // PrivateKey is a zero-cost typesafe substitue for crypto.PrivateKey @@ -63,34 +82,52 @@ type RSAPublicKey struct { Expiry time.Time } +// Thumbprint returns a JWK thumbprint. See https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk func (p *ECPublicKey) Thumbprint() string { return ThumbprintUntypedPublicKey(p.PublicKey) } + +// KeyID returns the JWK `kid`, which will be the Thumbprint for keys generated with this library func (p *ECPublicKey) KeyID() string { return p.KID } + +// Key returns the PublicKey func (p *ECPublicKey) Key() crypto.PublicKey { return p.PublicKey } + +// ExpireAt sets the time at which this Public Key should be considered invalid func (p *ECPublicKey) ExpireAt(t time.Time) { p.Expiry = t } + +// ExpiresAt gets the time at which this Public Key should be considered invalid func (p *ECPublicKey) ExpiresAt() time.Time { return p.Expiry } +// Thumbprint returns a JWK thumbprint. See https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk func (p *RSAPublicKey) Thumbprint() string { return ThumbprintUntypedPublicKey(p.PublicKey) } + +// KeyID returns the JWK `kid`, which will be the Thumbprint for keys generated with this library func (p *RSAPublicKey) KeyID() string { return p.KID } + +// Key returns the PublicKey func (p *RSAPublicKey) Key() crypto.PublicKey { return p.PublicKey } + +// ExpireAt sets the time at which this Public Key should be considered invalid func (p *RSAPublicKey) ExpireAt(t time.Time) { p.Expiry = t } + +// ExpiresAt gets the time at which this Public Key should be considered invalid func (p *RSAPublicKey) ExpiresAt() time.Time { return p.Expiry } @@ -126,9 +163,9 @@ func NewPublicKey(pub crypto.PublicKey, kid ...string) PublicKey { case *dsa.PublicKey: panic(ErrInvalidPublicKey) case *dsa.PrivateKey: - panic(ErrInvalidPublicKey) + panic(ErrInvalidPrivateKey) default: - panic(errors.New(fmt.Sprintf(ErrDevBadKeyType, pub))) + panic(fmt.Errorf(ErrDevBadKeyType, pub)) } return k @@ -236,7 +273,7 @@ func ParsePrivateKey(block []byte) (PrivateKey, error) { // Parse PEM blocks (openssl generates junk metadata blocks for ECs) // or the original DER, or the JWK - for i, _ := range blocks { + for i := range blocks { block = blocks[i] if key, err := parsePrivateKey(block); nil == err { return key, nil @@ -320,9 +357,8 @@ func getPEMBytes(block []byte) ([][]byte, error) { if len(blocks) > 0 { return blocks, nil - } else { - return nil, errors.New("no PEM blocks found") } + return nil, errors.New("no PEM blocks found") } // ParsePublicKey will try to parse the bytes you give it