From d42763516fa480ae764df75dbe7d48fcdc3ef0a0 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 2 Oct 2020 02:44:26 -0600 Subject: [PATCH] add verify subcommand --- cli_test.sh | 19 ++++ cmd/keypairs/keypairs.go | 196 +++++++++++++++++++++++++++++++++------ generate.go | 50 ++++------ jwk.go | 69 ++++++++++++++ jws.go | 63 +++++++++++++ mock.go | 46 +++++++++ sign.go | 26 +----- verify.go | 174 ++++++++++++++++++++++++++++++++++ 8 files changed, 559 insertions(+), 84 deletions(-) create mode 100644 cli_test.sh create mode 100644 jwk.go create mode 100644 jws.go create mode 100644 mock.go create mode 100644 verify.go diff --git a/cli_test.sh b/cli_test.sh new file mode 100644 index 0000000..6420e26 --- /dev/null +++ b/cli_test.sh @@ -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 diff --git a/cmd/keypairs/keypairs.go b/cmd/keypairs/keypairs.go index 20e38ce..6954bae 100644 --- a/cmd/keypairs/keypairs.go +++ b/cmd/keypairs/keypairs.go @@ -30,13 +30,17 @@ func usage() { fmt.Println(" version") fmt.Println(" gen") fmt.Println(" sign") + fmt.Println(" verify") fmt.Println("") fmt.Println("Examples:") fmt.Println(" keypairs gen -o key.jwk.json [--pub ]") + 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(" verify") + 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() { @@ -65,9 +69,11 @@ func main() { os.Exit(0) return case "gen": - gen(args) + gen(args[2:]) case "sign": - sign(args) + sign(args[2:]) + case "verify": + verify(args[2:]) default: usage() os.Exit(1) @@ -94,42 +100,27 @@ func sign(args []string) { flags := flag.NewFlagSet("sign", flag.ExitOnError) flags.DurationVar(&exp, "exp", 0, "duration until token expires (Default 15m)") flags.Parse(args) - if len(flags.Args()) <= 3 { + if len(flags.Args()) <= 1 { fmt.Fprintf(os.Stderr, "Usage: keypairs sign --exp 1h ./payload.json\n") os.Exit(1) } - keyname := flags.Args()[2] - payload := flags.Args()[3] + keyname := flags.Args()[0] + payload := flags.Args()[1] - var key keypairs.PrivateKey = nil - b, err := ioutil.ReadFile(keyname) + key, err := readKey(keyname) if nil != err { - var err2 error - key, err2 = keypairs.ParsePrivateKey([]byte(keyname)) - if nil != err2 { - fmt.Fprintf(os.Stderr, - "could not read private key as file (or parse as string) %q: %s\n", keyname, err) - } + fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) return } - if nil == key { - var err3 error - key, err3 = keypairs.ParsePrivateKey(b) - if nil != err3 { - fmt.Fprintf(os.Stderr, - "could not parse private key from file %q: %s\n", keyname, err3) - os.Exit(1) - return - } - } if "" == payload { + // TODO should this be null? I forget payload = "{}" } - b, err = ioutil.ReadFile(payload) + b, err := ioutil.ReadFile(payload) claims := map[string]interface{}{} if nil != err { var err2 error @@ -167,8 +158,159 @@ func sign(args []string) { } b, _ = json.Marshal(&jws) - fmt.Printf("JWS:\n%s\n\n", indentJSON(b)) - fmt.Printf("JWT:\n%s\n\n", keypairs.JWSToJWT(jws)) + fmt.Fprintf(os.Stderr, "%s\n", indentJSON(b)) + fmt.Fprintf(os.Stdout, "%s\n", keypairs.JWSToJWT(jws)) +} + +func verify(args []string) { + flags := flag.NewFlagSet("verify", flag.ExitOnError) + flags.Usage = func() { + fmt.Println("Usage: keypairs verify ") + fmt.Println("") + fmt.Println(" : a File or String of an EC or RSA key in JWK or PEM format") + fmt.Println(" : 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) + } + + 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! + var err2 error + pub, 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, + ) + } + } + + // Oh, it was a file. + if nil == pub { + var err3 error + pub, err3 = keypairs.ParsePublicKey(b) + if nil != err3 { + return nil, fmt.Errorf( + "could not parse public key from file %q:\n%w", + pubname, err3, + ) + } + } + + 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) { diff --git a/generate.go b/generate.go index a3412a7..13f99ec 100644 --- a/generate.go +++ b/generate.go @@ -11,34 +11,36 @@ import ( ) var randReader io.Reader = rand.Reader -var maxRetry = 1 +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"` - //Seed int64 `json:"-"` + KeyType string `json:"kty"` + mockSeed int64 //`json:"-"` //SeedStr string `json:"seed"` //Claims Object `json:"claims"` //Header Object `json:"header"` } -// this shananigans is only for testing and debug API stuff -func (o *keyOptions) myFooNextReader() io.Reader { +func (o *keyOptions) nextReader() io.Reader { + if allowMocking { + return o.maybeMockReader() + } return randReader - /* - if 0 == o.Seed { - return randReader - } - return mathrand.New(mathrand.NewSource(o.Seed)) - */ } // 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", @@ -55,29 +57,13 @@ func newPrivateKey(opts *keyOptions) PrivateKey { if "RSA" == opts.KeyType { keylen := 2048 - privkey, _ = rsa.GenerateKey(opts.myFooNextReader(), keylen) - /* - if 0 != opts.Seed { - for i := 0; i < maxRetry; i++ { - otherkey, _ := rsa.GenerateKey(opts.myFooNextReader(), 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) - } - } - } - */ + 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.myFooNextReader()) + privkey, _ = ecdsa.GenerateKey(elliptic.P256(), opts.nextReader()) } return privkey } diff --git a/jwk.go b/jwk.go new file mode 100644 index 0000000..2149fa6 --- /dev/null +++ b/jwk.go @@ -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()), + } +} +*/ diff --git a/jws.go b/jws.go new file mode 100644 index 0000000..9d27c39 --- /dev/null +++ b/jws.go @@ -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 +} diff --git a/mock.go b/mock.go new file mode 100644 index 0000000..2ca2a18 --- /dev/null +++ b/mock.go @@ -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 +} diff --git a/sign.go b/sign.go index 7b7aae0..59117ef 100644 --- a/sign.go +++ b/sign.go @@ -10,24 +10,10 @@ import ( "errors" "fmt" "io" - mathrand "math/rand" + mathrand "math/rand" // to be used for good, not evil "time" ) -// randReader may be overwritten for testing -//var randReader io.Reader = rand.Reader - -//var randReader = rand.Reader - -// 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 -} - // Object is a type alias representing generic JSON data type Object = map[string]interface{} @@ -149,16 +135,6 @@ func claimsToPayload(claims Object) ([]byte, error) { return json.Marshal(claims) } -// 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, - ) -} - // 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 { diff --git a/verify.go b/verify.go new file mode 100644 index 0000000..f6dfae9 --- /dev/null +++ b/verify.go @@ -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 PublicKey = 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, 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 = NewPublicKey(privkey.Public()) + 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(pub.Thumbprint())) { + 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.Key().(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 + } +}