2
0
mirror of https://github.com/therootcompany/keypairs synced 2025-04-21 15:00:44 +00:00

Compare commits

...

33 Commits

Author SHA1 Message Date
88a0b05c2f update usage example 2020-11-25 03:15:34 -07:00
a62ae2ea87 add inspect with OIDC key fetch 2020-11-25 03:10:33 -07:00
be38720eba doc: update README and help 2020-10-21 04:26:53 -06:00
286cf11070 transitioning to go1.15 PublicKey interface 2020-10-21 03:38:05 -06:00
743731c804 move away from PublicKeyDeprecated 2020-10-21 03:16:34 -06:00
098f92178a move to PublicKeyTransitional, toward go1.15 keypairs 2020-10-21 01:53:40 -06:00
2e3ead4102 make -o alias of --key 2020-10-21 00:41:41 -06:00
7e6fd174ef bugfix no subcommand 2020-10-21 00:26:07 -06:00
d4f18f8c78 add goreleaser 2020-10-21 00:22:40 -06:00
f275cd82d3 update version output 2020-10-21 00:22:33 -06:00
d42763516f add verify subcommand 2020-10-02 02:44:26 -06:00
a560441c61 whitespace 2020-10-01 23:59:41 -06:00
db958734f5 update doc links and author info 2020-10-01 23:58:33 -06:00
22ba73fa12 add sign subcommand 2020-10-01 23:53:17 -06:00
a2aa6b5411 add trailing newline to output 2020-10-01 21:52:59 -06:00
94f22e1948 fix flag parsing 2020-10-01 21:45:43 -06:00
f46e11257b add CLI to generate and marshal keypair 2020-10-01 21:30:26 -06:00
db6a30b358 update documented import path 2020-05-10 13:11:26 -06:00
e4ff8ac55b update canonical import path 2020-05-10 12:34:01 -06:00
8f66f1d235 go lint and update docs 2020-04-10 13:59:44 -04:00
ignore
78847a9cfd add MIT license 2019-08-19 14:48:28 -06:00
AJ ONeal
5701d77d7a friendlier error reporting 2019-07-11 10:59:19 -06:00
AJ ONeal
fe5cf33bef update error message 2019-04-22 10:10:36 -06:00
AJ ONeal
6fc75632c8 Warn on empty string in whitelist 2019-04-17 17:08:53 -06:00
671ea1250d v0.4.0 require separate string for private issuers 2019-04-15 11:09:34 -06:00
1205ea05e1 add specific key test 2019-03-25 17:48:39 -06:00
c4fc285259 add String method to Whitelist 2019-03-22 16:17:13 -06:00
3ab2594234 make IsTrustedIssuer a method of Whitelist 2019-03-22 15:28:11 -06:00
d981fb3a7c Merge branch 'master' of github.com:big-squid/go-keypairs 2019-03-22 14:01:30 -06:00
ada07e4446 better error for private networking 2019-03-22 14:01:19 -06:00
AJ ONeal
f2d341a3c0 v0.2.0: add support for fetching /pem 2019-03-15 17:52:53 -06:00
90b05bac5f add user agent 2019-03-08 14:28:23 -07:00
ce652e0590 doc updates 2019-03-06 20:18:21 -07:00
28 changed files with 1966 additions and 239 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/keypairs
/dist/
.DS_Store
.*.sw*

41
.goreleaser.yml Normal file
View File

@ -0,0 +1,41 @@
# This is an example goreleaser.yaml file with some sane defaults.
# Make sure to check the documentation at http://goreleaser.com
before:
hooks:
- go generate ./...
builds:
- id: keypairs
main: ./cmd/keypairs/keypairs.go
env:
- CGO_ENABLED=0
flags:
- -mod=vendor
goos:
- linux
- windows
- darwin
- freebsd
goarch:
- amd64
- arm
- arm64
archives:
- replacements:
386: i386
amd64: x86-64
arm64: aarch64
format_overrides:
- goos: windows
format: zip
env_files:
github_token: ~/.config/goreleaser/github_token.txt
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

1
AUTHORS Normal file
View File

@ -0,0 +1 @@
AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2018-2019 Big Squid, Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

106
README.md
View File

@ -1,56 +1,102 @@
# go-keypairs
# [keypairs](https://git.rootprojects.org/root/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.
A cross-platform Command Line Tool and Golang Library that works
with RSA, ECDSA, PEM, DER, JWK, and the JOSE suite.
# Documentation
# Keypairs CLI
Use the source, Luke!
Generates, signs, and verifies with NIST-strength asymmetric keys.
<https://godoc.org/github.com/big-squid/go-keypairs>
```bash
# Generate JSON Web Keys (JWKs)
keypairs gen > key.jwk.json 2> pub.jwk.json
# Generate PEM (or DER) Keys, by extension
keypairs gen --key key.pem --pub pub.pem
# Sign a payload
keypairs sign key.jwk.json --exp 1h '{ "sub": "me@example.com" }' > token.jwt 2> sig.jws
# Verify a signature
keypairs verify pub.jwk.json token.jwt
```
Cheat Sheet at <https://webinstall.dev/keypairs>.
### Install
**Mac**, **Linux**:
```bash
curl -sS https://webinstall.dev/keypairs | bash
```
**Windows 10**:
```bash
curl.exe -A MS https://webinstall.dev/keypairs | powershell
```
# Keypairs Go Library
JSON Web Key (JWK) support and type safety lightly placed over top of Go's `crypto/ecdsa` and `crypto/rsa`
Useful for JWT, JOSE, etc.
```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)
```
# GoDoc API Documentation
See <https://pkg.go.dev/git.rootprojects.org/root/keypairs>
# 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
get the benefit of not losing information when encoding and decoding
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. \
For a copy, see <https://opensource.org/licenses/MIT>.

19
cli_test.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
set -u
go build -mod=vendor cmd/keypairs/*.go
./keypairs gen > testkey.jwk.json 2> testpub.jwk.json
./keypairs sign --exp 1h ./testkey.jwk.json '{"foo":"bar"}' > testjwt.txt 2> testjws.json
echo ""
echo "Should pass:"
./keypairs verify ./testpub.jwk.json testjwt.txt > /dev/null
./keypairs verify ./testpub.jwk.json "$(cat testjwt.txt)" > /dev/null
./keypairs verify ./testpub.jwk.json testjws.json > /dev/null
./keypairs verify ./testpub.jwk.json "$(cat testjws.json)" > /dev/null
echo ""
echo "Should fail:"
./keypairs sign --exp -1m ./testkey.jwk.json '{"bar":"foo"}' > errjwt.txt 2> errjws.json
./keypairs verify ./testpub.jwk.json errjwt.txt > /dev/null

477
cmd/keypairs/keypairs.go Normal file
View File

@ -0,0 +1,477 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"os"
"strings"
"time"
"git.rootprojects.org/root/keypairs"
"git.rootprojects.org/root/keypairs/keyfetch"
)
var (
name = "keypairs"
version = "0.0.0"
date = "0001-01-01T00:00:00Z"
commit = "0000000"
)
func usage() {
fmt.Println(ver())
fmt.Println()
fmt.Println("Usage")
fmt.Printf(" %s <command> [flags] args...\n", name)
fmt.Println("")
fmt.Printf("See usage: %s help <command>\n", name)
fmt.Println("")
fmt.Println("Commands:")
fmt.Println(" version")
fmt.Println(" gen")
fmt.Println(" sign")
fmt.Println(" inspect (decode)")
fmt.Println(" verify")
fmt.Println("")
fmt.Println("Examples:")
fmt.Println(" keypairs gen --key key.jwk.json [--pub <public-key>]")
fmt.Println("")
fmt.Println(" keypairs sign --exp 15m key.jwk.json payload.json")
fmt.Println(" keypairs sign --exp 15m key.jwk.json '{ \"sub\": \"xxxx\" }'")
fmt.Println("")
fmt.Println(" keypairs inspect --verbose 'xxxx.yyyy.zzzz'")
fmt.Println("")
fmt.Println(" keypairs verify ./pub.jwk.json 'xxxx.yyyy.zzzz'")
// TODO fmt.Println(" keypairs verify --issuer https://example.com '{ \"sub\": \"xxxx\" }'")
fmt.Println("")
}
func ver() string {
return fmt.Sprintf("%s v%s (%s) %s", name, version, commit[:7], date)
}
func main() {
args := os.Args[:]
if len(args) < 2 || "help" == args[1] {
// top-level help
if len(args) <= 2 {
usage()
os.Exit(0)
return
}
// move help to subcommand argument
self := args[0]
args = append([]string{self}, args[2:]...)
args = append(args, "--help")
}
switch args[1] {
case "version":
fmt.Println(ver())
os.Exit(0)
return
case "gen":
gen(args[2:])
case "sign":
sign(args[2:])
case "decode":
fallthrough
case "inspect":
inspect(args[2:])
case "verify":
verify(args[2:])
default:
usage()
os.Exit(1)
return
}
}
func gen(args []string) {
var keyname string
var keynameAlt string
//var keynameAlt2 string
var pubname string
flags := flag.NewFlagSet("gen", flag.ExitOnError)
flags.StringVar(&keynameAlt, "o", "", "output file (alias of --key)")
//flags.StringVar(&keynameAlt2, "priv", "", "private key file (alias of --key)")
flags.StringVar(&keyname, "key", "", "private key file (ex: key.jwk.json or key.pem)")
flags.StringVar(&pubname, "pub", "", "public key file (ex: pub.jwk.json or pub.pem)")
flags.Parse(args)
if 0 == len(keyname) {
keyname = keynameAlt
}
/*
if 0 == len(keyname) {
keyname = keynameAlt2
}
*/
key := keypairs.NewDefaultPrivateKey()
marshalPriv(key, keyname)
pub := key.Public().(keypairs.PublicKey)
marshalPub(pub, pubname)
}
func sign(args []string) {
var exp time.Duration
flags := flag.NewFlagSet("sign", flag.ExitOnError)
flags.DurationVar(&exp, "exp", 0, "duration until token expires (Default 15m)")
flags.Parse(args)
if len(flags.Args()) <= 1 {
fmt.Fprintf(os.Stderr, "Usage: keypairs sign --exp 1h <private PEM or JWK> ./payload.json\n")
os.Exit(1)
}
keyname := flags.Args()[0]
payload := flags.Args()[1]
key, err := readKey(keyname)
if nil != err {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
return
}
if "" == payload {
// TODO should this be null? I forget
payload = "{}"
}
b, err := ioutil.ReadFile(payload)
claims := map[string]interface{}{}
if nil != err {
var err2 error
err2 = json.Unmarshal([]byte(payload), &claims)
if nil != err2 {
fmt.Fprintf(os.Stderr,
"could not read payload as file (or parse as string) %q: %s\n", payload, err)
os.Exit(1)
return
}
}
if 0 == len(claims) {
var err3 error
err3 = json.Unmarshal(b, &claims)
if nil != err3 {
fmt.Fprintf(os.Stderr,
"could not parse palyoad from file %q: %s\n", payload, err3)
os.Exit(1)
return
}
}
if 0 != exp {
claims["exp"] = exp.Seconds()
}
if _, ok := claims["exp"]; !ok {
claims["exp"] = (15 * time.Minute).Seconds()
}
jws, err := keypairs.SignClaims(key, nil, claims)
if nil != err {
fmt.Fprintf(os.Stderr, "could not sign claims: %v\n%#v\n", err, claims)
os.Exit(1)
return
}
b, _ = json.Marshal(&jws)
fmt.Fprintf(os.Stderr, "%s\n", indentJSON(b))
fmt.Fprintf(os.Stdout, "%s\n", keypairs.JWSToJWT(jws))
}
func inspect(args []string) {
var verbose bool
flags := flag.NewFlagSet("inspect", flag.ExitOnError)
flags.BoolVar(&verbose, "verbose", true, "print extra info")
flags.Usage = func() {
fmt.Println("Usage: keypairs inspect --verbose <jwt-or-jwt>")
fmt.Println("")
fmt.Println(" <jwt-or-jws>: a JWT or JWS File or String, if JWS the payload must be Base64")
fmt.Println("")
}
flags.Parse(args)
if len(flags.Args()) < 1 {
flags.Usage()
os.Exit(1)
return
}
payload := flags.Args()[0]
jws, err := readJWS(payload)
if nil != err {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
return
}
var pub keypairs.PublicKey = nil
// because interfaces are never truly nil
hasPub := false
jwk, _ := jws.Header["jwk"].(map[string]interface{})
jwkE, _ := jwk["e"].(string)
jwkX, _ := jwk["x"].(string)
kid, _ := jws.Header["kid"].(string)
if len(jwkE) > 0 || len(jwkX) > 0 {
// TODO verify self-signed certificate
//b, _ := json.MarshalIndent(&jwk, "", " ")
if len(kid) > 0 {
fmt.Fprintf(os.Stderr, "[warn] jws header has both 'kid' (Key ID) and 'jwk' (for self-signed only)\n")
} else {
fmt.Fprintf(os.Stderr, "[debug] token is self-signed (jwk)\n")
//pub = pubx
//hasPub = true
}
} else if len(kid) > 0 {
iss, _ := jws.Claims["iss"].(string)
if strings.HasPrefix(iss, "http:") || strings.HasPrefix(iss, "https:") {
//fmt.Printf("iss: %s\n", iss)
//fmt.Printf("kid: %s\n", kid)
fmt.Fprintf(os.Stderr, "Checking for OIDC key... ")
pubx, err := keyfetch.OIDCJWK(kid, iss)
if nil != err {
fmt.Fprintf(os.Stderr, "not found.\n")
// ignore
} else {
fmt.Fprintf(os.Stderr, "found:\n")
if verbose {
b := keypairs.MarshalJWKPublicKey(pubx)
fmt.Fprintf(os.Stderr, "%s\n", indentJSON(b))
}
pub = pubx
hasPub = true
}
}
}
validSig := false
if hasPub {
errs := keypairs.VerifyClaims(pub, jws)
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "error:\n")
for _, err := range errs {
fmt.Fprintf(os.Stderr, "\t%v\n", err)
}
} else {
validSig = true
}
}
b, _ := json.MarshalIndent(&jws, "", " ")
fmt.Fprintf(os.Stdout, "%s\n", b)
if validSig {
fmt.Fprintf(os.Stderr, "Signature is Valid\n")
}
}
func verify(args []string) {
flags := flag.NewFlagSet("verify", flag.ExitOnError)
flags.Usage = func() {
fmt.Println("Usage: keypairs verify [public key] <jwt-or-jwt>")
fmt.Println("")
fmt.Println(" <public key>: a File or String of an EC or RSA key in JWK or PEM format")
fmt.Println(" <jwt-or-jws>: a JWT or JWS File or String, if JWS the payload must be Base64")
fmt.Println("")
}
flags.Parse(args)
if len(flags.Args()) < 1 {
flags.Usage()
os.Exit(1)
return
}
if 1 == len(flags.Args()) {
inspect(args)
return
}
pubname := flags.Args()[0]
payload := flags.Args()[1]
pub, err := readPub(pubname)
if nil != err {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
return
}
jws, err := readJWS(payload)
if nil != err {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
return
}
b, _ := json.Marshal(&jws)
fmt.Fprintf(os.Stdout, "%s\n", indentJSON(b))
errs := keypairs.VerifyClaims(pub, jws)
if nil != errs {
fmt.Fprintf(os.Stderr, "error:\n")
for _, err := range errs {
fmt.Fprintf(os.Stderr, "\t%v\n", err)
}
os.Exit(1)
return
}
fmt.Fprintf(os.Stderr, "Signature is Valid\n")
}
func readKey(keyname string) (keypairs.PrivateKey, error) {
var key keypairs.PrivateKey = nil
// Read as file
b, err := ioutil.ReadFile(keyname)
if nil != err {
// Tis not a file! Perhaps a string?
var err2 error
key, err2 = keypairs.ParsePrivateKey([]byte(keyname))
if nil != err2 {
// Neither a valid string. Blast!
return nil, fmt.Errorf(
"could not read private key as file (or parse as string) %q:\n%s",
keyname, err2,
)
}
}
if nil == key {
var err3 error
key, err3 = keypairs.ParsePrivateKey(b)
if nil != err3 {
return nil, fmt.Errorf(
"could not parse private key from file %q:\n%s",
keyname, err3,
)
}
}
return key, nil
}
func readPub(pubname string) (keypairs.PublicKey, error) {
var pub keypairs.PublicKey = nil
// Read as file
b, err := ioutil.ReadFile(pubname)
if nil != err {
// No file? Try as string!
pub2, err2 := keypairs.ParsePublicKey([]byte(pubname))
if nil != err2 {
return nil, fmt.Errorf(
"could not read public key as file (or parse as string) %q:\n%w",
pubname, err,
)
}
pub = pub2.Key()
}
// Oh, it was a file.
if nil == pub {
pub3, err3 := keypairs.ParsePublicKey(b)
if nil != err3 {
return nil, fmt.Errorf(
"could not parse public key from file %q:\n%w",
pubname, err3,
)
}
pub = pub3.Key()
}
return pub, nil
}
func readJWS(payload string) (*keypairs.JWS, error) {
// Is it a file?
b, err := ioutil.ReadFile(payload)
if nil != err {
// Or a JWS or JWS String!?
b = []byte(payload)
}
// Either way, we have some bytes now
jws := &keypairs.JWS{}
jwt := string(b)
jwsb := []byte(jwt)
if !strings.Contains(jwt, " \t\n{}[]") {
jws = keypairs.JWTToJWS(string(b))
if nil != jws {
b, _ = json.Marshal(jws)
jwsb = (b)
}
}
// And now we have a string that may be a JWS
if err := json.Unmarshal(jwsb, &jws); nil != err {
// Nope, it's not
return nil, fmt.Errorf(
"could not read signed payload from file or string as JWT or JWS %q:\n%w",
payload, err,
)
}
if err := jws.DecodeComponents(); nil != err {
// bah! so close!
return nil, fmt.Errorf(
"could not decode the JWS Header and Claims components: %w\n%s",
err, string(jwsb),
)
}
return jws, nil
}
func marshalPriv(key keypairs.PrivateKey, keyname string) {
if "" == keyname {
b := indentJSON(keypairs.MarshalJWKPrivateKey(key))
fmt.Fprintf(os.Stdout, string(b)+"\n")
return
}
var b []byte
if strings.HasSuffix(keyname, ".json") {
b = indentJSON(keypairs.MarshalJWKPrivateKey(key))
} else if strings.HasSuffix(keyname, ".pem") {
b, _ = keypairs.MarshalPEMPrivateKey(key)
} else if strings.HasSuffix(keyname, ".der") {
b, _ = keypairs.MarshalDERPrivateKey(key)
} else {
fmt.Fprintf(os.Stderr, "private key extension should be .jwk.json, .pem, or .der")
os.Exit(1)
return
}
ioutil.WriteFile(keyname, b, 0600)
}
func marshalPub(pub keypairs.PublicKey, pubname string) {
var b []byte
if "" == pubname {
b = indentJSON(keypairs.MarshalJWKPublicKey(pub))
fmt.Fprintf(os.Stderr, string(b)+"\n")
return
}
if strings.HasSuffix(pubname, ".json") {
b = indentJSON(keypairs.MarshalJWKPublicKey(pub))
} else if strings.HasSuffix(pubname, ".pem") {
b, _ = keypairs.MarshalPEMPublicKey(pub)
} else if strings.HasSuffix(pubname, ".der") {
b, _ = keypairs.MarshalDERPublicKey(pub)
}
ioutil.WriteFile(pubname, b, 0644)
}
func indentJSON(b []byte) []byte {
m := map[string]interface{}{}
_ = json.Unmarshal(b, &m)
b, _ = json.MarshalIndent(&m, "", " ")
return append(b, '\n')
}

2
doc.go
View File

@ -17,7 +17,7 @@ Convenience functions are available which will fetch keys
(or retrieve them from cache) via OIDC, .well-known/jwks.json, and direct urls.
All keys are cached by Thumbprint, as well as kid(@issuer), if available.
import "github.com/big-squid/go-keypairs/keyfetch"
import "git.rootprojects.org/root/keypairs/keyfetch"
pubs, err := keyfetch.OIDCJWKs("https://example.com/")
pubs, err := keyfetch.OIDCJWK(ThumbOrKeyID, "https://example.com/")

5
examples/go.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/example/foobar
go 1.12
replace github.com/big-squid/go-keypairs => git.rootprojects.org/root/keypairs

View File

@ -0,0 +1,6 @@
{
"kty": "EC",
"crv": "P-256",
"x": "IT1SWLxsacPiE5Z16jkopAn8_-85rMjgyCokrnjDft4",
"y": "mP2JwOAOdMmXuwpxbKng3KZz27mz-nKWIlXJ3rzSGMo"
}

69
generate.go Normal file
View File

@ -0,0 +1,69 @@
package keypairs
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"io"
mathrand "math/rand"
"time"
)
var randReader io.Reader = rand.Reader
var allowMocking = false
// KeyOptions are the things that we may need to know about a request to fulfill it properly
type keyOptions struct {
//Key string `json:"key"`
KeyType string `json:"kty"`
mockSeed int64 //`json:"-"`
//SeedStr string `json:"seed"`
//Claims Object `json:"claims"`
//Header Object `json:"header"`
}
func (o *keyOptions) nextReader() io.Reader {
if allowMocking {
return o.maybeMockReader()
}
return randReader
}
// NewDefaultPrivateKey generates a key with reasonable strength.
// Today that means a 256-bit equivalent - either RSA 2048 or EC P-256.
func NewDefaultPrivateKey() PrivateKey {
// insecure random is okay here,
// it's just used for a coin toss
mathrand.Seed(time.Now().UnixNano())
coin := mathrand.Int()
// the idea here is that we want to make
// it dead simple to support RSA and EC
// so it shouldn't matter which is used
if 0 == coin%2 {
return newPrivateKey(&keyOptions{
KeyType: "RSA",
})
}
return newPrivateKey(&keyOptions{
KeyType: "EC",
})
}
// newPrivateKey generates a 256-bit entropy RSA or ECDSA private key
func newPrivateKey(opts *keyOptions) PrivateKey {
var privkey PrivateKey
if "RSA" == opts.KeyType {
keylen := 2048
privkey, _ = rsa.GenerateKey(opts.nextReader(), keylen)
if allowMocking {
privkey = maybeDerandomizeMockKey(privkey, keylen, opts)
}
} else {
// TODO: EC keys may also suffer the same random problems in the future
privkey, _ = ecdsa.GenerateKey(elliptic.P256(), opts.nextReader())
}
return privkey
}

4
go.mod
View File

@ -1 +1,3 @@
module github.com/big-squid/go-keypairs
module git.rootprojects.org/root/keypairs
go 1.12

69
jwk.go Normal file
View File

@ -0,0 +1,69 @@
package keypairs
import (
"fmt"
)
// JWK abstracts EC and RSA keys
type JWK interface {
marshalJWK() ([]byte, error)
}
// ECJWK is the EC variant
type ECJWK struct {
KeyID string `json:"kid,omitempty"`
Curve string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
Use []string `json:"use,omitempty"`
Seed string `json:"_seed,omitempty"`
}
func (k *ECJWK) marshalJWK() ([]byte, error) {
return []byte(fmt.Sprintf(`{"crv":%q,"kty":"EC","x":%q,"y":%q}`, k.Curve, k.X, k.Y)), nil
}
// RSAJWK is the RSA variant
type RSAJWK struct {
KeyID string `json:"kid,omitempty"`
Exp string `json:"e"`
N string `json:"n"`
Use []string `json:"use,omitempty"`
Seed string `json:"_seed,omitempty"`
}
func (k *RSAJWK) marshalJWK() ([]byte, error) {
return []byte(fmt.Sprintf(`{"e":%q,"kty":"RSA","n":%q}`, k.Exp, k.N)), nil
}
/*
// ToPublicJWK exposes only the public parts
func ToPublicJWK(pubkey PublicKey) JWK {
switch k := pubkey.Key().(type) {
case *ecdsa.PublicKey:
return ECToPublicJWK(k)
case *rsa.PublicKey:
return RSAToPublicJWK(k)
default:
panic(errors.New("impossible key type"))
//return nil
}
}
// ECToPublicJWK will output the most minimal version of an EC JWK (no key id, no "use" flag, nada)
func ECToPublicJWK(k *ecdsa.PublicKey) *ECJWK {
return &ECJWK{
Curve: k.Curve.Params().Name,
X: base64.RawURLEncoding.EncodeToString(k.X.Bytes()),
Y: base64.RawURLEncoding.EncodeToString(k.Y.Bytes()),
}
}
// RSAToPublicJWK will output the most minimal version of an RSA JWK (no key id, no "use" flag, nada)
func RSAToPublicJWK(p *rsa.PublicKey) *RSAJWK {
return &RSAJWK{
Exp: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(p.E)).Bytes()),
N: base64.RawURLEncoding.EncodeToString(p.N.Bytes()),
}
}
*/

63
jws.go Normal file
View File

@ -0,0 +1,63 @@
package keypairs
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
)
// JWS is a parsed JWT, representation as signable/verifiable and human-readable parts
type JWS struct {
Header Object `json:"header"` // JSON
Claims Object `json:"claims"` // JSON
Protected string `json:"protected"` // base64
Payload string `json:"payload"` // base64
Signature string `json:"signature"` // base64
}
// JWSToJWT joins JWS parts into a JWT as {ProtectedHeader}.{SerializedPayload}.{Signature}.
func JWSToJWT(jwt *JWS) string {
return fmt.Sprintf(
"%s.%s.%s",
jwt.Protected,
jwt.Payload,
jwt.Signature,
)
}
// JWTToJWS splits the JWT into its JWS segments
func JWTToJWS(jwt string) (jws *JWS) {
jwt = strings.TrimSpace(jwt)
parts := strings.Split(jwt, ".")
if 3 != len(parts) {
return nil
}
return &JWS{
Protected: parts[0],
Payload: parts[1],
Signature: parts[2],
}
}
// DecodeComponents decodes JWS Header and Claims
func (jws *JWS) DecodeComponents() error {
protected, err := base64.RawURLEncoding.DecodeString(jws.Protected)
if nil != err {
return errors.New("invalid JWS header base64Url encoding")
}
if err := json.Unmarshal([]byte(protected), &jws.Header); nil != err {
return errors.New("invalid JWS header")
}
payload, err := base64.RawURLEncoding.DecodeString(jws.Payload)
if nil != err {
return errors.New("invalid JWS payload base64Url encoding")
}
if err := json.Unmarshal([]byte(payload), &jws.Claims); nil != err {
return errors.New("invalid JWS claims")
}
return nil
}

View File

@ -11,6 +11,7 @@ package keyfetch
import (
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
@ -18,16 +19,29 @@ import (
"sync"
"time"
keypairs "github.com/big-squid/go-keypairs"
"github.com/big-squid/go-keypairs/keyfetch/uncached"
"git.rootprojects.org/root/keypairs"
"git.rootprojects.org/root/keypairs/keyfetch/uncached"
)
var EInvalidJWKURL = errors.New("url does not lead to valid JWKs")
// TODO should be ErrInvalidJWKURL
// ErrInvalidJWKURL means that the url did not provide JWKs
var ErrInvalidJWKURL = 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
Key keypairs.PublicKeyDeprecated
Expiry time.Time
}
@ -53,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.PublicKeyDeprecated
// 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
}
}
// 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
}
}
// 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
}
}
// JWK tries to return a key from cache, falling back to the /.well-known/jwks.json of the issuer
@ -104,10 +132,44 @@ func JWK(kidOrThumb, iss string) (keypairs.PublicKey, error) {
return immediateOneOrFetch(kidOrThumb, iss, uncached.JWKs)
}
// PEM tries to return a key from cache, falling back to the specified PEM url
func PEM(url string) (keypairs.PublicKey, error) {
// url is kid in this case
return immediateOneOrFetch(url, url, func(string) (map[string]map[string]string, map[string]keypairs.PublicKeyDeprecated, error) {
m, key, err := uncached.PEM(url)
if nil != err {
return nil, nil, err
}
pubd := keypairs.NewPublicKey(key)
// TODO bring this back
switch p := pubd.(type) {
case *keypairs.ECPublicKey:
p.KID = url
case *keypairs.RSAPublicKey:
p.KID = url
default:
return nil, nil, errors.New("impossible key type")
}
// put in a map, just for caching
maps := map[string]map[string]string{}
maps[keypairs.Thumbprint(key)] = m
maps[url] = m
keys := uncached.PublicKeysMap{} // map[string]keypairs.PublicKeyDeprecated{}
keys[keypairs.Thumbprint(key)] = pubd
keys[url] = pubd
return maps, keys, nil
})
}
// Fetch returns a key from cache, falling back to an exact url as the "issuer"
func Fetch(url string) (keypairs.PublicKey, error) {
// url is kid in this case
return immediateOneOrFetch(url, url, func(string) (map[string]map[string]string, map[string]keypairs.PublicKey, error) {
return immediateOneOrFetch(url, url,
func(string) (map[string]map[string]string, map[string]keypairs.PublicKeyDeprecated, error) {
m, key, err := uncached.Fetch(url)
if nil != err {
return nil, nil, err
@ -115,10 +177,10 @@ func Fetch(url string) (keypairs.PublicKey, error) {
// put in a map, just for caching
maps := map[string]map[string]string{}
maps[key.Thumbprint()] = m
maps[keypairs.Thumbprint(key.Key())] = m
keys := map[string]keypairs.PublicKey{}
keys[key.Thumbprint()] = key
keys := map[string]keypairs.PublicKeyDeprecated{}
keys[keypairs.Thumbprint(key.Key())] = key
return maps, keys, nil
})
@ -128,7 +190,7 @@ func Fetch(url string) (keypairs.PublicKey, error) {
// The issuer string may be empty if using a thumbprint rather than a kid.
func Get(kidOrThumb, iss string) keypairs.PublicKey {
if pub := get(kidOrThumb, iss); nil != pub {
return pub.Key
return pub.Key.Key()
}
return nil
}
@ -162,21 +224,21 @@ func get(kidOrThumb, iss string) *CachableKey {
func immediateOneOrFetch(kidOrThumb, iss string, fetcher myfetcher) (keypairs.PublicKey, error) {
now := time.Now()
key := get(kidOrThumb, iss)
hit := get(kidOrThumb, iss)
if nil == key {
if nil == hit {
return fetchAndSelect(kidOrThumb, iss, fetcher)
}
// Fetch just a little before the key actually expires
if key.Expiry.Sub(now) <= StaleTime {
if hit.Expiry.Sub(now) <= StaleTime {
go fetchAndSelect(kidOrThumb, iss, fetcher)
}
return key.Key, nil
return hit.Key.Key(), nil
}
type myfetcher func(string) (map[string]map[string]string, map[string]keypairs.PublicKey, error)
type myfetcher func(string) (map[string]map[string]string, map[string]keypairs.PublicKeyDeprecated, error)
func fetchAndSelect(id, baseURL string, fetcher myfetcher) (keypairs.PublicKey, error) {
maps, keys, err := fetcher(baseURL)
@ -187,20 +249,21 @@ func fetchAndSelect(id, baseURL string, fetcher myfetcher) (keypairs.PublicKey,
for i := range keys {
key := keys[i]
pub := key.Key()
if id == key.Thumbprint() {
return key, nil
if id == keypairs.Thumbprint(pub) {
return pub, nil
}
if id == key.KeyID() {
return key, nil
return pub, nil
}
}
return nil, fmt.Errorf("Key identified by '%s' was not found at %s", id, baseURL)
}
func cacheKeys(maps map[string]map[string]string, keys map[string]keypairs.PublicKey, issuer string) {
func cacheKeys(maps map[string]map[string]string, keys PublicKeysMap, issuer string) {
for i := range keys {
key := keys[i]
m := maps[i]
@ -210,10 +273,13 @@ func cacheKeys(maps map[string]map[string]string, keys map[string]keypairs.Publi
}
iss = normalizeIssuer(iss)
cacheKey(m["kid"], iss, m["exp"], key)
if 0 == len(m[uncached.URLishKey]) {
cacheKey(m[uncached.URLishKey], iss, m["exp"], key)
}
}
}
func cacheKey(kid, iss, expstr string, pub keypairs.PublicKey) error {
func cacheKey(kid, iss, expstr string, pub keypairs.PublicKeyDeprecated) error {
var expiry time.Time
iss = normalizeIssuer(iss)
@ -237,7 +303,7 @@ func cacheKey(kid, iss, expstr string, pub keypairs.PublicKey) error {
Expiry: expiry,
}
// Since thumbprints are crypto secure, iss isn't needed
thumb := pub.Thumbprint()
thumb := keypairs.Thumbprint(pub.Key())
KeyCache[thumb] = CachableKey{
Key: pub,
Expiry: expiry,
@ -256,36 +322,11 @@ func normalizeIssuer(iss string) string {
return strings.TrimRight(iss, "/")
}
/*
IsTrustedIssuer returns true when the `iss` (i.e. from a token) matches one
in the provided whitelist (also matches wildcard domains).
func isTrustedIssuer(iss string, whitelist Whitelist, rs ...*http.Request) bool {
if "" == iss {
return false
}
You may explicitly allow insecure http (i.e. for automated testing) by
including http:// Otherwise the scheme in each item of the whitelist should
include the "https://" prefix.
SECURITY CONSIDERATIONS (Please Read)
You'll notice that *http.Request is optional. It should only be used under these
three circumstances:
1) Something else guarantees http -> https redirection happens before the
connection gets here AND this server directly handles TLS/SSL.
2) If you're using a load balancer or web server, and this doesn't handle
TLS/SSL directly, that server is _explicitly_ configured to protect
against Domain Fronting attacks. As of 2019, most web servers and load
balancers do not protect against that by default.
3) If you only use it to make your automated integration testing more
and it isn't enabled in production.
Otherwise, DO NOT pass in *http.Request as you will introduce a 0-day
vulnerability allowing an attacker to spoof any token issuer of their choice.
The only reason I allowed this in a public library where non-experts would
encounter it is to make testing easier.
*/
func IsTrustedIssuer(iss string, whitelist Whitelist, rs ...*http.Request) bool {
// Normalize the http:// and https:// and parse
iss = strings.TrimRight(iss, "/") + "/"
if strings.HasPrefix(iss, "http://") {
@ -380,23 +421,47 @@ 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
// a parsed array of *url.URLs that can be used by the IsTrustedIssuer function
func NewWhitelist(issuers []string, insecures ...bool) (Whitelist, error) {
func NewWhitelist(issuers []string, privateList ...[]string) (Whitelist, error) {
var err error
list := []*url.URL{}
if 0 != len(issuers) {
insecure := false
if 0 != len(insecures) && insecures[0] {
insecure = true
list, err = newWhitelist(list, issuers, insecure)
if nil != err {
return nil, err
}
}
if 0 != len(privateList) && 0 != len(privateList[0]) {
insecure := true
list, err = newWhitelist(list, privateList[0], insecure)
if nil != err {
return nil, err
}
}
return Whitelist(list), nil
}
func newWhitelist(list []*url.URL, issuers []string, insecure bool) (Whitelist, error) {
for i := range issuers {
iss := issuers[i]
if "" == strings.TrimSpace(iss) {
fmt.Println("[Warning] You have an empty string in your keyfetch whitelist.")
continue
}
// Should have a valid http or https prefix
// TODO support custom prefixes (i.e. app://) ?
if strings.HasPrefix(iss, "http://") {
if !insecure {
return nil, errors.New("Oops! You have an insecure domain in your whitelist: " + iss)
log.Println("Oops! You have an insecure domain in your whitelist: ", iss)
return nil, ErrInsecureDomain
}
} else if strings.HasPrefix(iss, "//") {
// TODO
@ -404,17 +469,64 @@ func NewWhitelist(issuers []string, insecures ...bool) (Whitelist, error) {
} else if !strings.HasPrefix(iss, "https://") {
iss = "https://" + iss
}
// trailing slash as a boundary character, which may or may not denote a directory
iss = strings.TrimRight(iss, "/") + "/"
u, err := url.Parse(iss)
if nil != err {
return nil, err
}
// Strip any * prefix, for easier comparison later
// *.example.com => .example.com
if strings.HasPrefix(u.Host, "*.") {
u.Host = u.Host[1:]
}
list = append(list, u)
}
return Whitelist(list), nil
return list, nil
}
/*
IsTrustedIssuer returns true when the `iss` (i.e. from a token) matches one
in the provided whitelist (also matches wildcard domains).
You may explicitly allow insecure http (i.e. for automated testing) by
including http:// Otherwise the scheme in each item of the whitelist should
include the "https://" prefix.
SECURITY CONSIDERATIONS (Please Read)
You'll notice that *http.Request is optional. It should only be used under these
three circumstances:
1) Something else guarantees http -> https redirection happens before the
connection gets here AND this server directly handles TLS/SSL.
2) If you're using a load balancer or web server, and this doesn't handle
TLS/SSL directly, that server is _explicitly_ configured to protect
against Domain Fronting attacks. As of 2019, most web servers and load
balancers do not protect against that by default.
3) If you only use it to make your automated integration testing more
and it isn't enabled in production.
Otherwise, DO NOT pass in *http.Request as you will introduce a 0-day
vulnerability allowing an attacker to spoof any token issuer of their choice.
The only reason I allowed this in a public library where non-experts would
encounter it is to make testing easier.
*/
func (w Whitelist) IsTrustedIssuer(iss string, rs ...*http.Request) bool {
return isTrustedIssuer(iss, w, rs...)
}
// String will generate a space-delimited list of whitelisted URLs
func (w Whitelist) String() string {
s := []string{}
for i := range w {
s = append(s, w[i].String())
}
return strings.Join(s, " ")
}

View File

@ -4,14 +4,38 @@ import (
"testing"
"time"
keypairs "github.com/big-squid/go-keypairs"
"github.com/big-squid/go-keypairs/keyfetch/uncached"
"git.rootprojects.org/root/keypairs"
"git.rootprojects.org/root/keypairs/keyfetch/uncached"
)
var pubkey keypairs.PublicKey
func TestCachesKey(t *testing.T) {
// TODO set KeyID() in cache
testCachesKey(t, "https://bigsquid.auth0.com/")
clear()
testCachesKey(t, "https://bigsquid.auth0.com")
// Get PEM
pubk3, err := PEM("https://bigsquid.auth0.com/pem")
if nil != err {
t.Fatal("[0] Error fetching and caching key:", err)
}
thumb3 := keypairs.Thumbprint(pubk3)
thumb := keypairs.Thumbprint(pubkey)
if thumb3 != thumb {
t.Fatalf("Error got different thumbprint for different versions of the same key %q != %q: %v", thumb3, thumb, err)
}
clear()
testCachesKey(t, "https://big-squid.github.io/")
}
func TestKnownKID(t *testing.T) {
url := "https://kraken-dev.auth0.com"
kid := "RkVGNTM5NDc4NkM4NjA5OEMxMTNCMTNBQ0RGRDA0MEQ0RDNDMkM3Qw"
_, err := OIDCJWK(kid, url)
if nil != err {
t.Fatal(url, err)
}
}
func testCachesKey(t *testing.T, url string) {
@ -26,10 +50,10 @@ func testCachesKey(t *testing.T, url string) {
var key keypairs.PublicKey
for i := range keys {
key = keys[i]
key = keys[i].Key()
break
}
thumb := key.Thumbprint()
thumb := keypairs.Thumbprint(key)
// Look in cache for each (and fail)
if pub := Get(thumb, ""); nil != pub {
@ -37,22 +61,27 @@ func testCachesKey(t *testing.T, url string) {
}
// Get with caching
k2, err := OIDCJWK(thumb, url)
pubkey, err = OIDCJWK(thumb, url)
if nil != err {
t.Fatal("Error fetching and caching key:", err)
t.Fatal("[1] Error fetching and caching key:", err)
}
// Look in cache for each (and succeed)
if pub := Get(thumb, ""); nil == pub {
t.Fatal("key was not properly cached by thumbprint", thumb)
}
if "" != k2.KeyID() {
if pub := Get(k2.KeyID(), url); nil == pub {
t.Fatal("key was not properly cached by kid", k2.KeyID())
// TODO thumb / id mapping
thumb = keypairs.Thumbprint(pubkey)
if pub := Get(thumb, url); nil == pub {
t.Fatal("key was not properly cached by kid", pubkey)
}
} else {
t.Log("Key did not have an explicit KeyID")
// TODO
/*
if 0 == len(keyfetch.GetID(thumb)) {
t.Log("Key did not have an explicit KeyID", thumb)
}
*/
// Get again (should be sub-ms instant)
now := time.Now()
@ -65,7 +94,15 @@ func testCachesKey(t *testing.T, url string) {
}
// Sanity check that the kid and thumb match
if key.KeyID() != k2.KeyID() || key.Thumbprint() != k2.Thumbprint() {
t.Fatal("SANITY: KeyIDs or Thumbprints do not match:", key.KeyID(), k2.KeyID(), key.Thumbprint(), k2.Thumbprint())
if !key.Equal(pubkey) || keypairs.Thumbprint(key) != keypairs.Thumbprint(pubkey) {
t.Fatalf("SANITY: [todo: KeyIDs or] Thumbprints do not match:\n%q != %q\n%q != %q",
keypairs.Thumbprint(key), keypairs.Thumbprint(pubkey),
keypairs.Thumbprint(key), keypairs.Thumbprint(pubkey))
}
// Get 404
_, err = PEM(url + "/will-not-be-found.xyz")
if nil == err {
t.Fatal("Should have an error when retrieving a 404 or index.html:", err)
}
}

View File

@ -20,117 +20,127 @@ func TestInvalidIssuer(t *testing.T) {
}
func TestIssuerMatches(t *testing.T) {
// because [""] = strings.Split(os.Getenv("DOESNTEXIST"), ",")
trusted := []string{
"",
"https://example.com/",
"http://happy.xyz/abc",
"foobar.net/def/",
"https://*.wild.org",
"https://*.west.mali/verde",
}
_, err := NewWhitelist(trusted)
if nil == err {
t.Fatal(errors.New("An insecure domain got through!"))
privates := []string{
"http://happy.xyz/abc",
}
list, err := NewWhitelist(trusted, true)
_, err := NewWhitelist(append(trusted, privates...))
if nil == err {
t.Fatal(errors.New("an insecure domain got through"))
}
// Empty list is allowed... I guess?
list, err := NewWhitelist(nil)
if nil != err {
t.Fatal(err)
}
// Combo list
list, err = NewWhitelist(trusted[1:], privates)
if nil != err {
t.Fatal(err)
}
var iss string
iss = "https://example.com"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good domain didn't make it:", iss)
}
iss = "https://example.com/"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good domain didn't make it:", iss)
}
iss = "http://example.com"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://example.com/foo"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "http://happy.xyz/abc"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "http://happy.xyz/abc/"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "http://happy.xyz/abc/d"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "http://happy.xyz/abcd"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://foobar.net/def"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "https://foobar.net/def/"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "http://foobar.net/def/"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://foobar.net/def/e"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://foobar.net/defe"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://wild.org"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://foo.wild.org"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "https://sub.foo.wild.org"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "https://foo.wild.org/cherries"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
iss = "https://sub.west.mali/verde/"
if !IsTrustedIssuer(iss, list) {
if !list.IsTrustedIssuer(iss) {
t.Fatal("A good URL didn't make it:", iss)
}
iss = "https://sub.west.mali"
if IsTrustedIssuer(iss, list) {
if list.IsTrustedIssuer(iss) {
t.Fatal("A bad URL slipped past", iss)
}
}
@ -147,7 +157,7 @@ func TestImplicitIssuer(t *testing.T) {
}),
}
iss = "https://example.com/foo"
if !IsTrustedIssuer(iss, nil, r) {
if !isTrustedIssuer(iss, nil, r) {
t.Fatal("A good URL didn't make it:", iss)
}
@ -160,7 +170,7 @@ func TestImplicitIssuer(t *testing.T) {
}),
}
iss = "http://example.com/foo"
if IsTrustedIssuer(iss, nil, r) {
if isTrustedIssuer(iss, nil, r) {
t.Fatal("A bad URL slipped past:", iss)
}
@ -172,7 +182,7 @@ func TestImplicitIssuer(t *testing.T) {
}),
}
iss = "https://example.com/foo/bar/baz"
if IsTrustedIssuer(iss, nil, r) {
if isTrustedIssuer(iss, nil, r) {
t.Fatal("A bad URL slipped past:", iss)
}
@ -184,7 +194,7 @@ func TestImplicitIssuer(t *testing.T) {
}),
}
iss = "https://example.com/"
if !IsTrustedIssuer(iss, nil, r) {
if !isTrustedIssuer(iss, nil, r) {
t.Fatal("A good URL didn't make it:", iss)
}
}

View File

@ -2,18 +2,32 @@
package uncached
import (
"bytes"
"encoding/json"
"crypto/rsa"
"crypto/ecdsa"
"errors"
"io"
"io/ioutil"
"net"
"net/http"
"strings"
"time"
keypairs "github.com/big-squid/go-keypairs"
"git.rootprojects.org/root/keypairs"
)
// URLishKey is TODO
var URLishKey = "_kid_url"
// JWKMapByID is TODO
type JWKMapByID = map[string]map[string]string
// PublicKeysMap is TODO
type PublicKeysMap = map[string]keypairs.PublicKeyDeprecated
// OIDCJWKs gets the OpenID Connect configuration from the baseURL and then calls JWKs with the specified jwks_uri
func OIDCJWKs(baseURL string) (map[string]map[string]string, map[string]keypairs.PublicKey, error) {
func OIDCJWKs(baseURL string) (JWKMapByID, PublicKeysMap, error) {
baseURL = normalizeBaseURL(baseURL)
oidcConf := struct {
JWKSURI string `json:"jwks_uri"`
@ -34,16 +48,17 @@ func OIDCJWKs(baseURL string) (map[string]map[string]string, map[string]keypairs
}
// WellKnownJWKs calls JWKs with baseURL + /.well-known/jwks.json as constructs the jwks_uri
func WellKnownJWKs(baseURL string) (map[string]map[string]string, map[string]keypairs.PublicKey, error) {
func WellKnownJWKs(baseURL string) (JWKMapByID, PublicKeysMap, error) {
baseURL = normalizeBaseURL(baseURL)
url := baseURL + ".well-known/jwks.json"
return JWKs(baseURL + ".well-known/jwks.json")
return JWKs(url)
}
// JWKs fetches and parses a jwks.json (assuming well-known format)
func JWKs(jwksurl string) (map[string]map[string]string, map[string]keypairs.PublicKey, error) {
keys := map[string]keypairs.PublicKey{}
maps := map[string]map[string]string{}
func JWKs(jwksurl string) (JWKMapByID, PublicKeysMap, error) {
keys := PublicKeysMap{}
maps := JWKMapByID{}
resp := struct {
Keys []map[string]interface{} `json:"keys"`
}{
@ -62,19 +77,61 @@ 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[keypairs.Thumbprint(key.Key())] = key
maps[keypairs.Thumbprint(key.Key())] = m
}
return maps, keys, nil
}
// PEM fetches and parses a PEM (assuming well-known format)
func PEM(pemurl string) (map[string]string, keypairs.PublicKey, error) {
var pubd keypairs.PublicKeyDeprecated
if err := safeFetch(pemurl, func(body io.Reader) error {
pem, err := ioutil.ReadAll(body)
if nil != err {
return err
}
pubd, err = keypairs.ParsePublicKey(pem)
if nil != err {
return err
}
return nil
}); nil != err {
return nil, nil, err
}
jwk := map[string]interface{}{}
pub := pubd.Key()
body := bytes.NewBuffer(keypairs.MarshalJWKPublicKey(pub))
decoder := json.NewDecoder(body)
decoder.UseNumber()
_ = decoder.Decode(&jwk)
m := getStringMap(jwk)
m["kid"] = keypairs.Thumbprint(pub)
// TODO is this just junk?
m[URLishKey] = pemurl
switch pub.(type) {
case *ecdsa.PublicKey:
//p.KID = pemurl
case *rsa.PublicKey:
//p.KID = pemurl
default:
return nil, nil, errors.New("impossible key type")
}
return m, pub, nil
}
// Fetch retrieves a single JWK (plain, bare jwk) from a URL (off-spec)
func Fetch(url string) (map[string]string, keypairs.PublicKey, error) {
func Fetch(url string) (map[string]string, keypairs.PublicKeyDeprecated, error) {
var m map[string]interface{}
if err := safeFetch(url, func(body io.Reader) error {
decoder := json.NewDecoder(body)
@ -121,12 +178,15 @@ func safeFetch(url string, decoder decodeFunc) error {
}).Dial,
TLSHandshakeTimeout: 5 * time.Second,
}
var netClient = &http.Client{
var client = &http.Client{
Timeout: time.Second * 10,
Transport: netTransport,
}
res, err := netClient.Get(url)
req, err := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "go-keypairs/keyfetch")
req.Header.Set("Accept", "application/json;q=0.9,*/*;q=0.8")
res, err := client.Do(req)
if nil != err {
return err
}

View File

@ -6,7 +6,7 @@ import (
"errors"
"testing"
keypairs "github.com/big-squid/go-keypairs"
"git.rootprojects.org/root/keypairs"
)
func TestJWKs(t *testing.T) {

View File

@ -1,8 +1,8 @@
package keypairs
import (
"bytes"
"crypto"
"crypto/dsa"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
@ -16,32 +16,61 @@ import (
"io"
"log"
"math/big"
"strings"
"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
type PrivateKey interface {
Public() crypto.PublicKey
Equal(x crypto.PrivateKey) bool
}
// PublicKey thinly veils crypto.PublicKey for type safety
// PublicKey is so that v0.7.x can use golang v1.15 keys
type PublicKey interface {
Equal(x crypto.PublicKey) bool
}
// PublicKeyDeprecated thinly veils crypto.PublicKey for type safety
type PublicKeyDeprecated interface {
crypto.PublicKey
Thumbprint() string
//Equal(x crypto.PublicKey) bool
//Thumbprint() string
KeyID() string
Key() crypto.PublicKey
Key() PublicKey
ExpiresAt() time.Time
}
@ -59,41 +88,74 @@ 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)
}
// Equal returns true if the public key is equal.
func (p *ECPublicKey) Equal(x crypto.PublicKey) bool {
return p.PublicKey.Equal(x)
}
// KeyID returns the JWK `kid`, which will be the Thumbprint for keys generated with this library
func (p *ECPublicKey) KeyID() string {
return p.KID
}
func (p *ECPublicKey) Key() crypto.PublicKey {
// Key returns the PublicKey
func (p *ECPublicKey) Key() 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)
}
// Equal returns true if the public key is equal.
func (p *RSAPublicKey) Equal(x crypto.PublicKey) bool {
return p.PublicKey.Equal(x)
}
// KeyID returns the JWK `kid`, which will be the Thumbprint for keys generated with this library
func (p *RSAPublicKey) KeyID() string {
return p.KID
}
func (p *RSAPublicKey) Key() crypto.PublicKey {
// Key returns the PublicKey
func (p *RSAPublicKey) Key() 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
}
// NewPublicKey wraps a crypto.PublicKey to make it typesafe.
func NewPublicKey(pub crypto.PublicKey, kid ...string) PublicKey {
var k PublicKey
func NewPublicKey(pub crypto.PublicKey, kid ...string) PublicKeyDeprecated {
_, ok := pub.(PublicKey)
if !ok {
panic("Developer Error: not a crypto.PublicKey")
}
var k PublicKeyDeprecated
switch p := pub.(type) {
case *ecdsa.PublicKey:
eckey := &ECPublicKey{
@ -115,16 +177,8 @@ func NewPublicKey(pub crypto.PublicKey, kid ...string) PublicKey {
rsakey.KID = ThumbprintRSAPublicKey(p)
}
k = rsakey
case *ecdsa.PrivateKey:
panic(errors.New(ErrDevSwapPrivatePublic))
case *rsa.PrivateKey:
panic(errors.New(ErrDevSwapPrivatePublic))
case *dsa.PublicKey:
panic(ErrInvalidPublicKey)
case *dsa.PrivateKey:
panic(ErrInvalidPublicKey)
default:
panic(errors.New(fmt.Sprintf(ErrDevBadKeyType, pub)))
panic(fmt.Errorf(ErrDevBadKeyType, pub))
}
return k
@ -134,13 +188,11 @@ func NewPublicKey(pub crypto.PublicKey, kid ...string) PublicKey {
// making it suitable for use as an OIDC public key.
func MarshalJWKPublicKey(key PublicKey, exp ...time.Time) []byte {
// thumbprint keys are alphabetically sorted and only include the necessary public parts
switch k := key.Key().(type) {
switch k := key.(type) {
case *rsa.PublicKey:
return MarshalRSAPublicKey(k, exp...)
case *ecdsa.PublicKey:
return MarshalECPublicKey(k, exp...)
case *dsa.PublicKey:
panic(ErrInvalidPublicKey)
default:
// this is unreachable because we know the types that we pass in
log.Printf("keytype: %t, %+v\n", key, key)
@ -148,8 +200,13 @@ func MarshalJWKPublicKey(key PublicKey, exp ...time.Time) []byte {
}
}
// Thumbprint returns the SHA256 RFC-spec JWK thumbprint
func Thumbprint(pub PublicKey) string {
return ThumbprintUntypedPublicKey(pub)
}
// ThumbprintPublicKey returns the SHA256 RFC-spec JWK thumbprint
func ThumbprintPublicKey(pub PublicKey) string {
func ThumbprintPublicKey(pub PublicKeyDeprecated) string {
return ThumbprintUntypedPublicKey(pub.Key())
}
@ -157,7 +214,7 @@ func ThumbprintPublicKey(pub PublicKey) string {
// (but will still panic, to help you discover bugs in development rather than production).
func ThumbprintUntypedPublicKey(pub crypto.PublicKey) string {
switch p := pub.(type) {
case PublicKey:
case PublicKeyDeprecated:
return ThumbprintUntypedPublicKey(p.Key())
case *ecdsa.PublicKey:
return ThumbprintECPublicKey(p)
@ -232,13 +289,20 @@ 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
}
}
for i := range blocks {
block = blocks[i]
if _, err := parsePublicKey(block); nil == err {
return nil, ErrUnexpectedPublicKey
}
}
// If we didn't parse a key arleady, we failed
return nil, ErrParsePrivateKey
}
@ -309,14 +373,13 @@ 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")
}
// ParsePrivateKey will try to parse the bytes you give it
// ParsePublicKey will try to parse the bytes you give it
// in any of the supported formats: PEM, DER, PKIX/SPKI, PKCS1, x509 Certificate, and JWK
func ParsePublicKey(block []byte) (PublicKey, error) {
func ParsePublicKey(block []byte) (PublicKeyDeprecated, error) {
blocks, err := getPEMBytes(block)
if nil != err {
return nil, ErrParsePublicKey
@ -324,25 +387,30 @@ func ParsePublicKey(block []byte) (PublicKey, 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 := parsePublicKey(block); nil == err {
return key, nil
}
}
for i := range blocks {
block = blocks[i]
if _, err := parsePrivateKey(block); nil == err {
return nil, ErrUnexpectedPrivateKey
}
}
// If we didn't parse a key arleady, we failed
return nil, ErrParsePublicKey
}
// ParsePublicKeyString calls ParsePublicKey([]byte(key)) for all you lazy folk.
func ParsePublicKeyString(block string) (PublicKey, error) {
func ParsePublicKeyString(block string) (PublicKeyDeprecated, error) {
return ParsePublicKey([]byte(block))
}
func parsePublicKey(der []byte) (PublicKey, error) {
var key PublicKey
func parsePublicKey(der []byte) (PublicKeyDeprecated, error) {
cert, err := x509.ParseCertificate(der)
if nil == err {
switch k := cert.PublicKey.(type) {
@ -351,7 +419,7 @@ func parsePublicKey(der []byte) (PublicKey, error) {
case *ecdsa.PublicKey:
return NewPublicKey(k), nil
default:
err = errors.New("Only RSA and ECDSA (EC) Public Keys are supported")
return nil, errors.New("Only RSA and ECDSA (EC) Public Keys are supported")
}
}
@ -364,32 +432,31 @@ func parsePublicKey(der []byte) (PublicKey, error) {
case *ecdsa.PublicKey:
return NewPublicKey(k), nil
default:
err = errors.New("Only RSA and ECDSA (EC) Public Keys are supported")
return nil, errors.New("Only RSA and ECDSA (EC) Public Keys are supported")
}
}
if nil != err {
//fmt.Println("3. ParsePKCS1PrublicKey")
keyx, err := x509.ParsePKCS1PublicKey(der)
key = NewPublicKey(keyx)
if nil != err {
rkey, err := x509.ParsePKCS1PublicKey(der)
if nil == err {
//fmt.Println("4. ParseJWKPublicKey")
key, err = ParseJWKPublicKey(der)
}
return NewPublicKey(rkey), nil
}
return ParseJWKPublicKey(der)
/*
// But did you know?
// You must return nil explicitly for interfaces
// https://golang.org/doc/faq#nil_error
if nil != err {
return nil, err
}
return key, nil
*/
}
// NewJWKPublicKey contstructs a PublicKey from the relevant pieces a map[string]string (generic JSON)
func NewJWKPublicKey(m map[string]string) (PublicKey, error) {
func NewJWKPublicKey(m map[string]string) (PublicKeyDeprecated, error) {
switch m["kty"] {
case "RSA":
return parseRSAPublicKey(m)
@ -401,32 +468,41 @@ func NewJWKPublicKey(m map[string]string) (PublicKey, error) {
}
// ParseJWKPublicKey parses a JSON-encoded JWK and returns a PublicKey, or a (hopefully) helpful error message
func ParseJWKPublicKey(b []byte) (PublicKey, error) {
func ParseJWKPublicKey(b []byte) (PublicKeyDeprecated, error) {
// RSA and EC have "d" as a private part
if bytes.Contains(b, []byte(`"d"`)) {
return nil, ErrUnexpectedPrivateKey
}
return newJWKPublicKey(b)
}
// ParseJWKPublicKeyString calls ParseJWKPublicKey([]byte(key)) for all you lazy folk.
func ParseJWKPublicKeyString(s string) (PublicKey, error) {
func ParseJWKPublicKeyString(s string) (PublicKeyDeprecated, error) {
if strings.Contains(s, `"d"`) {
return nil, ErrUnexpectedPrivateKey
}
return newJWKPublicKey(s)
}
// DecodeJWKPublicKey stream-decodes a JSON-encoded JWK and returns a PublicKey, or a (hopefully) helpful error message
func DecodeJWKPublicKey(r io.Reader) (PublicKey, error) {
return newJWKPublicKey(r)
func DecodeJWKPublicKey(r io.Reader) (PublicKeyDeprecated, error) {
m := make(map[string]string)
if err := json.NewDecoder(r).Decode(&m); nil != err {
return nil, err
}
if d := m["d"]; "" != d {
return nil, ErrUnexpectedPrivateKey
}
return newJWKPublicKey(m)
}
// the underpinnings of the parser as used by the typesafe wrappers
func newJWKPublicKey(data interface{}) (PublicKey, error) {
func newJWKPublicKey(data interface{}) (PublicKeyDeprecated, error) {
var m map[string]string
switch d := data.(type) {
case map[string]string:
m = d
case io.Reader:
m = make(map[string]string)
if err := json.NewDecoder(d).Decode(&m); nil != err {
return nil, err
}
case string:
if err := json.Unmarshal([]byte(d), &m); nil != err {
return nil, err

View File

@ -43,6 +43,53 @@ func TestParsePrivateKeyEC(t *testing.T) {
}
}
func TestParseUnexpectedPrivateKey(t *testing.T) {
keypaths := []string{
"fixtures/privkey-ec-p256.jwk.json",
"fixtures/privkey-ec-p256.sec1.pem",
"fixtures/privkey-ec-p256.pkcs8.pem",
"fixtures/privkey-rsa-2048.jwk.json",
"fixtures/privkey-rsa-2048.pkcs1.pem",
"fixtures/privkey-rsa-2048.pkcs8.pem",
}
for i := range keypaths {
path := keypaths[i]
b, err := ioutil.ReadFile(path)
if nil != err {
t.Fatal(path, err)
}
_, err = ParsePublicKey(b)
switch err {
case ErrUnexpectedPrivateKey:
continue
default:
t.Fatal(path, err)
}
}
}
func TestParseUnexpectedPublicKey(t *testing.T) {
keypaths := []string{
"fixtures/pub-ec-p256.jwk.json",
}
for i := range keypaths {
path := keypaths[i]
b, err := ioutil.ReadFile(path)
if nil != err {
t.Fatal(path, err)
}
_, err = ParsePrivateKey(b)
switch err {
case ErrUnexpectedPublicKey:
continue
default:
t.Fatal(path, err)
}
}
}
func TestParsePrivateKeyRSA(t *testing.T) {
keypaths := []string{
"fixtures/privkey-rsa-2048.jwk.json",
@ -71,7 +118,7 @@ func TestParsePrivateKeyRSA(t *testing.T) {
}
func TestParseCertificate(t *testing.T) {
resp, err := http.Get("http://bigsquid.auth0.com/pem")
resp, err := http.Get("https://example.auth0.com/pem")
if nil != err {
log.Fatal(err)
}

View File

@ -5,26 +5,30 @@ via OIDC-style (https://example.com/.well-known/openid-configuration)
and Auth0-style (https://example.com/.well-known/jwks.json)
URLs. It uses the keypairs package to encode to JWK format.
Basic usage:
Basic Usage
import (
"crypto/ecdsa"
"crypto/rand"
"time"
"github.com/big-squid/go-keypairs/keyserve"
"git.rootprojects.org/root/keypairs/keyserve"
)
key, _ := ecdsa.GenerateKey(elliptic.P256, rand.Reader)
pub := key.Public()
handlers := &keyserve.Middleware{
// the self-reference used for building the openid-configuration url
BaseURL: "https://example.com/",
// public keys used to verify token signatures
Keys: []keypairs.PublicKey{ keypairs.NewPublicKey(pub) }
// how long clients should cache your public key
ExpiresIn: 72 * time.Hour
}
You can then use the handlers anywhere http.HandleFunc is allowed:

View File

@ -11,7 +11,7 @@ import (
"strings"
"time"
keypairs "github.com/big-squid/go-keypairs"
"git.rootprojects.org/root/keypairs"
)
// DefaultExpiresIn is 3 days
@ -39,7 +39,7 @@ var never = time.Time{}
// Middleware holds your public keys and has http handler methods for OIDC and Auth0 JWKs
type Middleware struct {
BaseURL *url.URL
Keys []keypairs.PublicKey
Keys []keypairs.PublicKeyDeprecated
ExpiresIn time.Duration
}
@ -84,7 +84,7 @@ func (m *Middleware) Handler(w http.ResponseWriter, r *http.Request) bool {
// others to receive a cached version of the malicious response rather than
// hitting the server and getting the correct response. Unlikely that that's
// you (and if it is you have much bigger problems), but I feel the need to
// warn you all the same.
// warn you all the same - so just be sure to specify BaseURL.
func (m *Middleware) WellKnownOIDC(w http.ResponseWriter, r *http.Request) {
var baseURL url.URL
@ -148,7 +148,7 @@ func (m *Middleware) Auth0PEM(w http.ResponseWriter, r *http.Request) {
}
}
func marshalJWKs(keys []keypairs.PublicKey, exp2 time.Time) []string {
func marshalJWKs(keys []keypairs.PublicKeyDeprecated, exp2 time.Time) []string {
jwks := make([]string, 0, 1)
for i := range keys {
@ -163,7 +163,8 @@ func marshalJWKs(keys []keypairs.PublicKey, exp2 time.Time) []string {
// Note that you don't have to embed `iss` in the JWK because the client
// already has that info by virtue of getting to it in the first place.
jwk := string(keypairs.MarshalJWKPublicKey(key, exp))
pub := key.Key()
jwk := string(keypairs.MarshalJWKPublicKey(pub, exp))
jwks = append(jwks, jwk)
}

View File

@ -13,12 +13,12 @@ import (
"testing"
"time"
keypairs "github.com/big-squid/go-keypairs"
"git.rootprojects.org/root/keypairs"
)
func TestServeKeys(t *testing.T) {
eckey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
pubs := []keypairs.PublicKey{
pubs := []keypairs.PublicKeyDeprecated{
keypairs.NewPublicKey(eckey.Public()),
}
@ -42,8 +42,9 @@ func TestServeKeys(t *testing.T) {
go func() {
time.Sleep(15 * time.Second)
ctx, _ := context.WithTimeout(context.Background(), 15*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
h.Shutdown(ctx)
cancel()
}()
m := map[string]string{}

170
marshal.go Normal file
View File

@ -0,0 +1,170 @@
package keypairs
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"log"
"math/big"
mathrand "math/rand"
)
// MarshalPEMPublicKey outputs the given public key as JWK
func MarshalPEMPublicKey(pubkey PublicKey) ([]byte, error) {
block, err := marshalDERPublicKey(pubkey)
if nil != err {
return nil, err
}
return pem.EncodeToMemory(block), nil
}
// MarshalDERPublicKey outputs the given public key as JWK
func MarshalDERPublicKey(pubkey PublicKey) ([]byte, error) {
block, err := marshalDERPublicKey(pubkey)
if nil != err {
return nil, err
}
return block.Bytes, nil
}
// marshalDERPublicKey outputs the given public key as JWK
func marshalDERPublicKey(pubkey PublicKey) (*pem.Block, error) {
var der []byte
var typ string
var err error
switch k := pubkey.(type) {
case *rsa.PublicKey:
der = x509.MarshalPKCS1PublicKey(k)
typ = "RSA PUBLIC KEY"
case *ecdsa.PublicKey:
typ = "PUBLIC KEY"
der, err = x509.MarshalPKIXPublicKey(k)
if nil != err {
return nil, err
}
default:
panic("Developer Error: impossible key type")
}
return &pem.Block{
Bytes: der,
Type: typ,
}, nil
}
// MarshalJWKPrivateKey outputs the given private key as JWK
func MarshalJWKPrivateKey(privkey PrivateKey) []byte {
// thumbprint keys are alphabetically sorted and only include the necessary public parts
switch k := privkey.(type) {
case *rsa.PrivateKey:
return MarshalRSAPrivateKey(k)
case *ecdsa.PrivateKey:
return MarshalECPrivateKey(k)
default:
// this is unreachable because we know the types that we pass in
log.Printf("keytype: %t, %+v\n", privkey, privkey)
panic(ErrInvalidPublicKey)
//return nil
}
}
// MarshalDERPrivateKey outputs the given private key as ASN.1 DER
func MarshalDERPrivateKey(privkey PrivateKey) ([]byte, error) {
// thumbprint keys are alphabetically sorted and only include the necessary public parts
switch k := privkey.(type) {
case *rsa.PrivateKey:
return x509.MarshalPKCS1PrivateKey(k), nil
case *ecdsa.PrivateKey:
return x509.MarshalECPrivateKey(k)
default:
// this is unreachable because we know the types that we pass in
log.Printf("keytype: %t, %+v\n", privkey, privkey)
panic(ErrInvalidPublicKey)
//return nil, nil
}
}
func marshalDERPrivateKey(privkey PrivateKey) (*pem.Block, error) {
var typ string
var bytes []byte
var err error
switch k := privkey.(type) {
case *rsa.PrivateKey:
if 0 == mathrand.Intn(2) {
typ = "PRIVATE KEY"
bytes, err = x509.MarshalPKCS8PrivateKey(k)
if nil != err {
return nil, err
}
} else {
typ = "RSA PRIVATE KEY"
bytes = x509.MarshalPKCS1PrivateKey(k)
}
return &pem.Block{
Type: typ,
Bytes: bytes,
}, nil
case *ecdsa.PrivateKey:
if 0 == mathrand.Intn(2) {
typ = "PRIVATE KEY"
bytes, err = x509.MarshalPKCS8PrivateKey(k)
} else {
typ = "EC PRIVATE KEY"
bytes, err = x509.MarshalECPrivateKey(k)
}
if nil != err {
return nil, err
}
return &pem.Block{
Type: typ,
Bytes: bytes,
}, nil
default:
// this is unreachable because we know the types that we pass in
log.Printf("keytype: %t, %+v\n", privkey, privkey)
panic(ErrInvalidPublicKey)
//return nil, nil
}
}
// MarshalPEMPrivateKey outputs the given private key as ASN.1 PEM
func MarshalPEMPrivateKey(privkey PrivateKey) ([]byte, error) {
block, err := marshalDERPrivateKey(privkey)
if nil != err {
return nil, err
}
return pem.EncodeToMemory(block), nil
}
// MarshalECPrivateKey will output the given private key as JWK
func MarshalECPrivateKey(k *ecdsa.PrivateKey) []byte {
crv := k.Curve.Params().Name
d := base64.RawURLEncoding.EncodeToString(k.D.Bytes())
x := base64.RawURLEncoding.EncodeToString(k.X.Bytes())
y := base64.RawURLEncoding.EncodeToString(k.Y.Bytes())
return []byte(fmt.Sprintf(
`{"crv":%q,"d":%q,"kty":"EC","x":%q,"y":%q}`,
crv, d, x, y,
))
}
// MarshalRSAPrivateKey will output the given private key as JWK
func MarshalRSAPrivateKey(pk *rsa.PrivateKey) []byte {
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pk.E)).Bytes())
n := base64.RawURLEncoding.EncodeToString(pk.N.Bytes())
d := base64.RawURLEncoding.EncodeToString(pk.D.Bytes())
p := base64.RawURLEncoding.EncodeToString(pk.Primes[0].Bytes())
q := base64.RawURLEncoding.EncodeToString(pk.Primes[1].Bytes())
dp := base64.RawURLEncoding.EncodeToString(pk.Precomputed.Dp.Bytes())
dq := base64.RawURLEncoding.EncodeToString(pk.Precomputed.Dq.Bytes())
qi := base64.RawURLEncoding.EncodeToString(pk.Precomputed.Qinv.Bytes())
return []byte(fmt.Sprintf(
`{"d":%q,"dp":%q,"dq":%q,"e":%q,"kty":"RSA","n":%q,"p":%q,"q":%q,"qi":%q}`,
d, dp, dq, e, n, p, q, qi,
))
}

46
mock.go Normal file
View File

@ -0,0 +1,46 @@
package keypairs
import (
"crypto/rsa"
"io"
"log"
mathrand "math/rand"
)
// this shananigans is only for testing and debug API stuff
func (o *keyOptions) maybeMockReader() io.Reader {
if !allowMocking {
panic("mock method called when mocking is not allowed")
}
if 0 == o.mockSeed {
return randReader
}
log.Println("WARNING: MOCK: using insecure reader")
return mathrand.New(mathrand.NewSource(o.mockSeed))
}
const maxRetry = 16
func maybeDerandomizeMockKey(privkey PrivateKey, keylen int, opts *keyOptions) PrivateKey {
if 0 != opts.mockSeed {
for i := 0; i < maxRetry; i++ {
otherkey, _ := rsa.GenerateKey(opts.nextReader(), keylen)
otherCmp := otherkey.D.Cmp(privkey.(*rsa.PrivateKey).D)
if 0 != otherCmp {
// There are two possible keys, choose the lesser D value
// See https://github.com/square/go-jose/issues/189
if otherCmp < 0 {
privkey = otherkey
}
break
}
if maxRetry == i-1 {
log.Printf("error: coinflip landed on heads %d times", maxRetry)
}
}
}
return privkey
}

165
sign.go Normal file
View File

@ -0,0 +1,165 @@
package keypairs
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
mathrand "math/rand" // to be used for good, not evil
"time"
)
// Object is a type alias representing generic JSON data
type Object = map[string]interface{}
// SignClaims adds `typ`, `kid` (or `jwk`), and `alg` in the header and expects claims for `jti`, `exp`, `iss`, and `iat`
func SignClaims(privkey PrivateKey, header Object, claims Object) (*JWS, error) {
var randsrc io.Reader = randReader
seed, _ := header["_seed"].(int64)
if 0 != seed {
randsrc = mathrand.New(mathrand.NewSource(seed))
//delete(header, "_seed")
}
protected, header, err := headerToProtected(privkey.Public().(PublicKey), header)
if nil != err {
return nil, err
}
protected64 := base64.RawURLEncoding.EncodeToString(protected)
payload, err := claimsToPayload(claims)
if nil != err {
return nil, err
}
payload64 := base64.RawURLEncoding.EncodeToString(payload)
signable := fmt.Sprintf(`%s.%s`, protected64, payload64)
hash := sha256.Sum256([]byte(signable))
sig := Sign(privkey, hash[:], randsrc)
sig64 := base64.RawURLEncoding.EncodeToString(sig)
//log.Printf("\n(Sign)\nSignable: %s", signable)
//log.Printf("Hash: %s", hash)
//log.Printf("Sig: %s", sig64)
return &JWS{
Header: header,
Claims: claims,
Protected: protected64,
Payload: payload64,
Signature: sig64,
}, nil
}
func headerToProtected(pub PublicKey, header Object) ([]byte, Object, error) {
if nil == header {
header = Object{}
}
// Only supporting 2048-bit and P256 keys right now
// because that's all that's practical and well-supported.
// No security theatre here.
alg := "ES256"
switch pub.(type) {
case *rsa.PublicKey:
alg = "RS256"
}
if selfSign, _ := header["_jwk"].(bool); selfSign {
delete(header, "_jwk")
any := Object{}
_ = json.Unmarshal(MarshalJWKPublicKey(pub), &any)
header["jwk"] = any
}
// TODO what are the acceptable values? JWT. JWS? others?
header["typ"] = "JWT"
if _, ok := header["jwk"]; !ok {
thumbprint := ThumbprintPublicKey(NewPublicKey(pub))
kid, _ := header["kid"].(string)
if "" != kid && thumbprint != kid {
return nil, nil, errors.New("'kid' should be the key's thumbprint")
}
header["kid"] = thumbprint
}
header["alg"] = alg
protected, err := json.Marshal(header)
if nil != err {
return nil, nil, err
}
return protected, header, nil
}
func claimsToPayload(claims Object) ([]byte, error) {
if nil == claims {
claims = Object{}
}
var dur time.Duration
jti, _ := claims["jti"].(string)
insecure, _ := claims["insecure"].(bool)
switch exp := claims["exp"].(type) {
case time.Duration:
// TODO: MUST this go first?
// int64(time.Duration) vs time.Duration(int64)
dur = exp
case string:
var err error
dur, err = time.ParseDuration(exp)
// TODO s, err := time.ParseDuration(dur)
if nil != err {
return nil, err
}
case int:
dur = time.Second * time.Duration(exp)
case int64:
dur = time.Second * time.Duration(exp)
case float64:
dur = time.Second * time.Duration(exp)
default:
dur = 0
}
if "" == jti && 0 == dur && !insecure {
return nil, errors.New("token must have jti or exp as to be expirable / cancellable")
}
claims["exp"] = time.Now().Add(dur).Unix()
return json.Marshal(claims)
}
// Sign signs both RSA and ECDSA. Use `nil` or `crypto/rand.Reader` except for debugging.
func Sign(privkey PrivateKey, hash []byte, rand io.Reader) []byte {
if nil == rand {
rand = randReader
}
var sig []byte
if len(hash) != 32 {
panic("only 256-bit hashes for 2048-bit and 256-bit keys are supported")
}
switch k := privkey.(type) {
case *rsa.PrivateKey:
sig, _ = rsa.SignPKCS1v15(rand, k, crypto.SHA256, hash)
case *ecdsa.PrivateKey:
r, s, _ := ecdsa.Sign(rand, k, hash[:])
rb := r.Bytes()
for len(rb) < 32 {
rb = append([]byte{0}, rb...)
}
sb := s.Bytes()
for len(rb) < 32 {
sb = append([]byte{0}, sb...)
}
sig = append(rb, sb...)
}
return sig
}

174
verify.go Normal file
View File

@ -0,0 +1,174 @@
package keypairs
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log"
"math/big"
"time"
)
// VerifyClaims will check the signature of a parsed JWT
func VerifyClaims(pubkey PublicKey, jws *JWS) (errs []error) {
kid, _ := jws.Header["kid"].(string)
jwkmap, hasJWK := jws.Header["jwk"].(Object)
//var jwk JWK = nil
seed, _ := jws.Header["_seed"].(int64)
seedf64, _ := jws.Header["_seed"].(float64)
kty, _ := jws.Header["_kty"].(string)
if 0 == seed {
seed = int64(seedf64)
}
var pub PublicKey = nil
if hasJWK {
pub, errs = selfsignCheck(jwkmap, errs)
} else {
opts := &keyOptions{mockSeed: seed, KeyType: kty}
pub, errs = pubkeyCheck(pubkey, kid, opts, errs)
}
jti, _ := jws.Claims["jti"].(string)
expf64, _ := jws.Claims["exp"].(float64)
exp := int64(expf64)
if 0 == exp {
if "" == jti {
err := errors.New("one of 'jti' or 'exp' must exist for token expiry")
errs = append(errs, err)
}
} else {
if time.Now().Unix() > exp {
err := fmt.Errorf("token expired at %d (%s)", exp, time.Unix(exp, 0))
errs = append(errs, err)
}
}
signable := fmt.Sprintf("%s.%s", jws.Protected, jws.Payload)
hash := sha256.Sum256([]byte(signable))
sig, err := base64.RawURLEncoding.DecodeString(jws.Signature)
if nil != err {
err := fmt.Errorf("could not decode signature: %w", err)
errs = append(errs, err)
return errs
}
//log.Printf("\n(Verify)\nSignable: %s", signable)
//log.Printf("Hash: %s", hash)
//log.Printf("Sig: %s", jws.Signature)
if nil == pub {
err := fmt.Errorf("token signature could not be verified")
errs = append(errs, err)
} else if !Verify(pub, hash[:], sig) {
err := fmt.Errorf("token signature is not valid")
errs = append(errs, err)
}
return errs
}
func selfsignCheck(jwkmap Object, errs []error) (PublicKey, []error) {
var pub PublicKeyDeprecated = nil
log.Println("Security TODO: did not check jws.Claims[\"sub\"] against 'jwk'")
log.Println("Security TODO: did not check jws.Claims[\"iss\"]")
kty := jwkmap["kty"]
var err error
if "RSA" == kty {
e, _ := jwkmap["e"].(string)
n, _ := jwkmap["n"].(string)
k, _ := (&RSAJWK{
Exp: e,
N: n,
}).marshalJWK()
pub, err = ParseJWKPublicKey(k)
if nil != err {
return nil, append(errs, err)
}
} else {
crv, _ := jwkmap["crv"].(string)
x, _ := jwkmap["x"].(string)
y, _ := jwkmap["y"].(string)
k, _ := (&ECJWK{
Curve: crv,
X: x,
Y: y,
}).marshalJWK()
pub, err = ParseJWKPublicKey(k)
if nil != err {
return nil, append(errs, err)
}
}
return pub.Key(), errs
}
func pubkeyCheck(pubkey PublicKey, kid string, opts *keyOptions, errs []error) (PublicKey, []error) {
var pub PublicKey = nil
if "" == kid {
err := errors.New("token should have 'kid' or 'jwk' in header to identify the public key")
errs = append(errs, err)
}
if nil == pubkey {
if allowMocking {
if 0 == opts.mockSeed {
err := errors.New("the debug API requires '_seed' to accompany 'kid'")
errs = append(errs, err)
}
if "" == opts.KeyType {
err := errors.New("the debug API requires '_kty' to accompany '_seed'")
errs = append(errs, err)
}
if 0 == opts.mockSeed || "" == opts.KeyType {
return nil, errs
}
privkey := newPrivateKey(opts)
pub = privkey.Public().(PublicKey)
return pub, errs
}
err := errors.New("no matching public key")
errs = append(errs, err)
} else {
pub = pubkey
}
if nil != pub && "" != kid {
if 1 != subtle.ConstantTimeCompare([]byte(kid), []byte(Thumbprint(pub))) {
err := errors.New("'kid' does not match the public key thumbprint")
errs = append(errs, err)
}
}
return pub, errs
}
// Verify will check the signature of a hash
func Verify(pubkey PublicKey, hash []byte, sig []byte) bool {
switch pub := pubkey.(type) {
case *rsa.PublicKey:
//log.Printf("RSA VERIFY")
// TODO Size(key) to detect key size ?
//alg := "SHA256"
// TODO: this hasn't been tested yet
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash, sig); nil != err {
return false
}
return true
case *ecdsa.PublicKey:
r := &big.Int{}
r.SetBytes(sig[0:32])
s := &big.Int{}
s.SetBytes(sig[32:])
return ecdsa.Verify(pub, hash, r, s)
default:
panic("impossible condition: non-rsa/non-ecdsa key")
//return false
}
}