auth: Support all JWT algorithms

This change adds support to etcd for all of the JWT algorithms included
in the underlying JWT library.
release-3.4
Joe LeGasse 2018-06-22 12:21:40 -04:00
parent 8f6348a97d
commit a6ddb51c8a
9 changed files with 472 additions and 165 deletions

View File

@ -372,6 +372,7 @@ Follow the instructions when using these flags.
### --auth-token
+ Specify a token type and token specific options, especially for JWT. Its format is "type,var1=val1,var2=val2,...". Possible type is 'simple' or 'jwt'. Possible variables are 'sign-method' for specifying a sign method of jwt (its possible values are 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', or 'PS512'), 'pub-key' for specifying a path to a public key for verifying jwt, 'priv-key' for specifying a path to a private key for signing jwt, and 'ttl' for specifying TTL of jwt tokens.
+ For asymmetric algorithms ('RS', 'PS', 'ES'), the public key is optional, as the private key contains enough information to both sign and verify tokens.
+ Example option of JWT: '--auth-token jwt,pub-key=app.rsa.pub,priv-key=app.rsa,sign-method=RS512,ttl=10m'
+ default: "simple"

View File

@ -16,8 +16,9 @@ package auth
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"io/ioutil"
"errors"
"time"
jwt "github.com/dgrijalva/jwt-go"
@ -26,10 +27,10 @@ import (
type tokenJWT struct {
lg *zap.Logger
signMethod string
signKey *rsa.PrivateKey
verifyKey *rsa.PublicKey
signMethod jwt.SigningMethod
key interface{}
ttl time.Duration
verifyOnly bool
}
func (t *tokenJWT) enable() {}
@ -45,25 +46,20 @@ func (t *tokenJWT) info(ctx context.Context, token string, rev uint64) (*AuthInf
)
parsed, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
return t.verifyKey, nil
if token.Method.Alg() != t.signMethod.Alg() {
return nil, errors.New("invalid signing method")
}
switch k := t.key.(type) {
case *rsa.PrivateKey:
return &k.PublicKey, nil
case *ecdsa.PrivateKey:
return &k.PublicKey, nil
default:
return t.key, nil
}
})
switch err.(type) {
case nil:
if !parsed.Valid {
if t.lg != nil {
t.lg.Warn("invalid JWT token", zap.String("token", token))
} else {
plog.Warningf("invalid jwt token: %s", token)
}
return nil, false
}
claims := parsed.Claims.(jwt.MapClaims)
username = claims["username"].(string)
revision = uint64(claims["revision"].(float64))
default:
if err != nil {
if t.lg != nil {
t.lg.Warn(
"failed to parse a JWT token",
@ -76,20 +72,37 @@ func (t *tokenJWT) info(ctx context.Context, token string, rev uint64) (*AuthInf
return nil, false
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !parsed.Valid || !ok {
if t.lg != nil {
t.lg.Warn("invalid JWT token", zap.String("token", token))
} else {
plog.Warningf("invalid jwt token: %s", token)
}
return nil, false
}
username = claims["username"].(string)
revision = uint64(claims["revision"].(float64))
return &AuthInfo{Username: username, Revision: revision}, true
}
func (t *tokenJWT) assign(ctx context.Context, username string, revision uint64) (string, error) {
if t.verifyOnly {
return "", ErrVerifyOnly
}
// Future work: let a jwt token include permission information would be useful for
// permission checking in proxy side.
tk := jwt.NewWithClaims(jwt.GetSigningMethod(t.signMethod),
tk := jwt.NewWithClaims(t.signMethod,
jwt.MapClaims{
"username": username,
"revision": revision,
"exp": time.Now().Add(t.ttl).Unix(),
})
token, err := tk.SignedString(t.signKey)
token, err := tk.SignedString(t.key)
if err != nil {
if t.lg != nil {
t.lg.Warn(
@ -117,113 +130,54 @@ func (t *tokenJWT) assign(ctx context.Context, username string, revision uint64)
return token, err
}
func prepareOpts(lg *zap.Logger, opts map[string]string) (jwtSignMethod, jwtPubKeyPath, jwtPrivKeyPath string, ttl time.Duration, err error) {
for k, v := range opts {
switch k {
case "sign-method":
jwtSignMethod = v
case "pub-key":
jwtPubKeyPath = v
case "priv-key":
jwtPrivKeyPath = v
case "ttl":
ttl, err = time.ParseDuration(v)
if err != nil {
if lg != nil {
lg.Warn(
"failed to parse JWT TTL option",
zap.String("ttl-value", v),
zap.Error(err),
)
} else {
plog.Errorf("failed to parse ttl option (%s)", err)
}
return "", "", "", 0, ErrInvalidAuthOpts
}
default:
if lg != nil {
lg.Warn("unknown JWT token option", zap.String("option", k))
} else {
plog.Errorf("unknown token specific option: %s", k)
}
return "", "", "", 0, ErrInvalidAuthOpts
}
}
if len(jwtSignMethod) == 0 {
return "", "", "", 0, ErrInvalidAuthOpts
}
return jwtSignMethod, jwtPubKeyPath, jwtPrivKeyPath, ttl, nil
}
func newTokenProviderJWT(lg *zap.Logger, opts map[string]string) (*tokenJWT, error) {
jwtSignMethod, jwtPubKeyPath, jwtPrivKeyPath, ttl, err := prepareOpts(lg, opts)
func newTokenProviderJWT(lg *zap.Logger, optMap map[string]string) (*tokenJWT, error) {
var err error
var opts jwtOptions
err = opts.ParseWithDefaults(optMap)
if err != nil {
if lg != nil {
lg.Warn("problem loading JWT options", zap.Error(err))
} else {
plog.Errorf("problem loading JWT options: %s", err)
}
return nil, ErrInvalidAuthOpts
}
if ttl == 0 {
ttl = 5 * time.Minute
var keys = make([]string, 0, len(optMap))
for k := range optMap {
if !knownOptions[k] {
keys = append(keys, k)
}
}
if len(keys) > 0 {
if lg != nil {
lg.Warn("unknown JWT options", zap.Strings("keys", keys))
} else {
plog.Warningf("unknown JWT options: %v", keys)
}
}
key, err := opts.Key()
if err != nil {
return nil, err
}
t := &tokenJWT{
lg: lg,
ttl: ttl,
lg: lg,
ttl: opts.TTL,
signMethod: opts.SignMethod,
key: key,
}
t.signMethod = jwtSignMethod
verifyBytes, err := ioutil.ReadFile(jwtPubKeyPath)
if err != nil {
if lg != nil {
lg.Warn(
"failed to read JWT public key",
zap.String("public-key-path", jwtPubKeyPath),
zap.Error(err),
)
} else {
plog.Errorf("failed to read public key (%s) for jwt: %s", jwtPubKeyPath, err)
switch t.signMethod.(type) {
case *jwt.SigningMethodECDSA:
if _, ok := t.key.(*ecdsa.PublicKey); ok {
t.verifyOnly = true
}
return nil, err
}
t.verifyKey, err = jwt.ParseRSAPublicKeyFromPEM(verifyBytes)
if err != nil {
if lg != nil {
lg.Warn(
"failed to parse JWT public key",
zap.String("public-key-path", jwtPubKeyPath),
zap.Error(err),
)
} else {
plog.Errorf("failed to parse public key (%s): %s", jwtPubKeyPath, err)
case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS:
if _, ok := t.key.(*rsa.PublicKey); ok {
t.verifyOnly = true
}
return nil, err
}
signBytes, err := ioutil.ReadFile(jwtPrivKeyPath)
if err != nil {
if lg != nil {
lg.Warn(
"failed to read JWT private key",
zap.String("private-key-path", jwtPrivKeyPath),
zap.Error(err),
)
} else {
plog.Errorf("failed to read private key (%s) for jwt: %s", jwtPrivKeyPath, err)
}
return nil, err
}
t.signKey, err = jwt.ParseRSAPrivateKeyFromPEM(signBytes)
if err != nil {
if lg != nil {
lg.Warn(
"failed to parse JWT private key",
zap.String("private-key-path", jwtPrivKeyPath),
zap.Error(err),
)
} else {
plog.Errorf("failed to parse private key (%s): %s", jwtPrivKeyPath, err)
}
return nil, err
}
return t, nil

View File

@ -23,80 +23,182 @@ import (
)
const (
jwtPubKey = "../integration/fixtures/server.crt"
jwtPrivKey = "../integration/fixtures/server.key.insecure"
jwtRSAPubKey = "../integration/fixtures/server.crt"
jwtRSAPrivKey = "../integration/fixtures/server.key.insecure"
jwtECPubKey = "../integration/fixtures/server-ecdsa.crt"
jwtECPrivKey = "../integration/fixtures/server-ecdsa.key.insecure"
)
func TestJWTInfo(t *testing.T) {
opts := map[string]string{
"pub-key": jwtPubKey,
"priv-key": jwtPrivKey,
"sign-method": "RS256",
optsMap := map[string]map[string]string{
"RSA-priv": {
"priv-key": jwtRSAPrivKey,
"sign-method": "RS256",
"ttl": "1h",
},
"RSA": {
"pub-key": jwtRSAPubKey,
"priv-key": jwtRSAPrivKey,
"sign-method": "RS256",
},
"RSAPSS-priv": {
"priv-key": jwtRSAPrivKey,
"sign-method": "PS256",
},
"RSAPSS": {
"pub-key": jwtRSAPubKey,
"priv-key": jwtRSAPrivKey,
"sign-method": "PS256",
},
"ECDSA-priv": {
"priv-key": jwtECPrivKey,
"sign-method": "ES256",
},
"ECDSA": {
"pub-key": jwtECPubKey,
"priv-key": jwtECPrivKey,
"sign-method": "ES256",
},
"HMAC": {
"priv-key": jwtECPrivKey, // any file, raw bytes used as shared secret
"sign-method": "HS256",
},
}
jwt, err := newTokenProviderJWT(zap.NewExample(), opts)
for k, opts := range optsMap {
t.Run(k, func(tt *testing.T) {
testJWTInfo(tt, opts)
})
}
}
func testJWTInfo(t *testing.T, opts map[string]string) {
lg := zap.NewNop()
jwt, err := newTokenProviderJWT(lg, opts)
if err != nil {
t.Fatal(err)
}
token, aerr := jwt.assign(context.TODO(), "abc", 123)
ctx := context.TODO()
token, aerr := jwt.assign(ctx, "abc", 123)
if aerr != nil {
t.Fatal(err)
t.Fatalf("%#v", aerr)
}
ai, ok := jwt.info(context.TODO(), token, 123)
ai, ok := jwt.info(ctx, token, 123)
if !ok {
t.Fatalf("failed to authenticate with token %s", token)
}
if ai.Revision != 123 {
t.Fatalf("expected revision 123, got %d", ai.Revision)
}
ai, ok = jwt.info(context.TODO(), "aaa", 120)
ai, ok = jwt.info(ctx, "aaa", 120)
if ok || ai != nil {
t.Fatalf("expected aaa to fail to authenticate, got %+v", ai)
}
// test verify-only provider
if opts["pub-key"] != "" && opts["priv-key"] != "" {
t.Run("verify-only", func(t *testing.T) {
newOpts := make(map[string]string, len(opts))
for k, v := range opts {
newOpts[k] = v
}
delete(newOpts, "priv-key")
verify, err := newTokenProviderJWT(lg, newOpts)
if err != nil {
t.Fatal(err)
}
ai, ok := verify.info(ctx, token, 123)
if !ok {
t.Fatalf("failed to authenticate with token %s", token)
}
if ai.Revision != 123 {
t.Fatalf("expected revision 123, got %d", ai.Revision)
}
ai, ok = verify.info(ctx, "aaa", 120)
if ok || ai != nil {
t.Fatalf("expected aaa to fail to authenticate, got %+v", ai)
}
_, aerr := verify.assign(ctx, "abc", 123)
if aerr != ErrVerifyOnly {
t.Fatalf("unexpected error when attempting to sign with public key: %v", aerr)
}
})
}
}
func TestJWTBad(t *testing.T) {
opts := map[string]string{
"pub-key": jwtPubKey,
"priv-key": jwtPrivKey,
"sign-method": "RS256",
}
// private key instead of public key
opts["pub-key"] = jwtPrivKey
if _, err := newTokenProviderJWT(zap.NewExample(), opts); err == nil {
t.Fatalf("expected failure on missing public key")
}
opts["pub-key"] = jwtPubKey
// public key instead of private key
opts["priv-key"] = jwtPubKey
if _, err := newTokenProviderJWT(zap.NewExample(), opts); err == nil {
t.Fatalf("expected failure on missing public key")
var badCases = map[string]map[string]string{
"no options": {},
"invalid method": {
"sign-method": "invalid",
},
"rsa no key": {
"sign-method": "RS256",
},
"invalid ttl": {
"sign-method": "RS256",
"ttl": "forever",
},
"rsa invalid public key": {
"sign-method": "RS256",
"pub-key": jwtRSAPrivKey,
"priv-key": jwtRSAPrivKey,
},
"rsa invalid private key": {
"sign-method": "RS256",
"pub-key": jwtRSAPubKey,
"priv-key": jwtRSAPubKey,
},
"hmac no key": {
"sign-method": "HS256",
},
"hmac pub key": {
"sign-method": "HS256",
"pub-key": jwtRSAPubKey,
},
"missing public key file": {
"sign-method": "HS256",
"pub-key": "missing-file",
},
"missing private key file": {
"sign-method": "HS256",
"priv-key": "missing-file",
},
"ecdsa no key": {
"sign-method": "ES256",
},
"ecdsa invalid public key": {
"sign-method": "ES256",
"pub-key": jwtECPrivKey,
"priv-key": jwtECPrivKey,
},
"ecdsa invalid private key": {
"sign-method": "ES256",
"pub-key": jwtECPubKey,
"priv-key": jwtECPubKey,
},
}
opts["priv-key"] = jwtPrivKey
// missing signing option
delete(opts, "sign-method")
if _, err := newTokenProviderJWT(zap.NewExample(), opts); err == nil {
t.Fatal("expected error on missing option")
}
opts["sign-method"] = "RS256"
lg := zap.NewNop()
// bad file for pubkey
opts["pub-key"] = "whatever"
if _, err := newTokenProviderJWT(zap.NewExample(), opts); err == nil {
t.Fatalf("expected failure on missing public key")
for k, v := range badCases {
t.Run(k, func(t *testing.T) {
_, err := newTokenProviderJWT(lg, v)
if err == nil {
t.Errorf("expected error for options %v", v)
}
})
}
opts["pub-key"] = jwtPubKey
// bad file for private key
opts["priv-key"] = "whatever"
if _, err := newTokenProviderJWT(zap.NewExample(), opts); err == nil {
t.Fatalf("expeceted failure on missing private key")
}
opts["priv-key"] = jwtPrivKey
}
// testJWTOpts is useful for passing to NewTokenProvider which requires a string.
func testJWTOpts() string {
return fmt.Sprintf("%s,pub-key=%s,priv-key=%s,sign-method=RS256", tokenTypeJWT, jwtPubKey, jwtPrivKey)
return fmt.Sprintf("%s,pub-key=%s,priv-key=%s,sign-method=RS256", tokenTypeJWT, jwtRSAPubKey, jwtRSAPrivKey)
}

192
auth/options.go Normal file
View File

@ -0,0 +1,192 @@
// Copyright 2018 The etcd Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"crypto/ecdsa"
"crypto/rsa"
"fmt"
"io/ioutil"
"time"
jwt "github.com/dgrijalva/jwt-go"
)
const (
optSignMethod = "sign-method"
optPublicKey = "pub-key"
optPrivateKey = "priv-key"
optTTL = "ttl"
)
var knownOptions = map[string]bool{
optSignMethod: true,
optPublicKey: true,
optPrivateKey: true,
optTTL: true,
}
var (
// DefaultTTL will be used when a 'ttl' is not specified
DefaultTTL = 5 * time.Minute
)
type jwtOptions struct {
SignMethod jwt.SigningMethod
PublicKey []byte
PrivateKey []byte
TTL time.Duration
}
// ParseWithDefaults will load options from the specified map or set defaults where appropriate
func (opts *jwtOptions) ParseWithDefaults(optMap map[string]string) error {
if opts.TTL == 0 && optMap[optTTL] == "" {
opts.TTL = DefaultTTL
}
return opts.Parse(optMap)
}
// Parse will load options from the specified map
func (opts *jwtOptions) Parse(optMap map[string]string) error {
var err error
if ttl := optMap[optTTL]; ttl != "" {
opts.TTL, err = time.ParseDuration(ttl)
if err != nil {
return err
}
}
if file := optMap[optPublicKey]; file != "" {
opts.PublicKey, err = ioutil.ReadFile(file)
if err != nil {
return err
}
}
if file := optMap[optPrivateKey]; file != "" {
opts.PrivateKey, err = ioutil.ReadFile(file)
if err != nil {
return err
}
}
// signing method is a required field
method := optMap[optSignMethod]
opts.SignMethod = jwt.GetSigningMethod(method)
if opts.SignMethod == nil {
return ErrInvalidAuthMethod
}
return nil
}
// Key will parse and return the appropriately typed key for the selected signature method
func (opts *jwtOptions) Key() (interface{}, error) {
switch opts.SignMethod.(type) {
case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS:
return opts.rsaKey()
case *jwt.SigningMethodECDSA:
return opts.ecKey()
case *jwt.SigningMethodHMAC:
return opts.hmacKey()
default:
return nil, fmt.Errorf("unsupported signing method: %T", opts.SignMethod)
}
}
func (opts *jwtOptions) hmacKey() (interface{}, error) {
if len(opts.PrivateKey) == 0 {
return nil, ErrMissingKey
}
return opts.PrivateKey, nil
}
func (opts *jwtOptions) rsaKey() (interface{}, error) {
var (
priv *rsa.PrivateKey
pub *rsa.PublicKey
err error
)
if len(opts.PrivateKey) > 0 {
priv, err = jwt.ParseRSAPrivateKeyFromPEM(opts.PrivateKey)
if err != nil {
return nil, err
}
}
if len(opts.PublicKey) > 0 {
pub, err = jwt.ParseRSAPublicKeyFromPEM(opts.PublicKey)
if err != nil {
return nil, err
}
}
if priv == nil {
if pub == nil {
// Neither key given
return nil, ErrMissingKey
}
// Public key only, can verify tokens
return pub, nil
}
// both keys provided, make sure they match
if pub != nil && pub.E != priv.E && pub.N.Cmp(priv.N) != 0 {
return nil, ErrKeyMismatch
}
return priv, nil
}
func (opts *jwtOptions) ecKey() (interface{}, error) {
var (
priv *ecdsa.PrivateKey
pub *ecdsa.PublicKey
err error
)
if len(opts.PrivateKey) > 0 {
priv, err = jwt.ParseECPrivateKeyFromPEM(opts.PrivateKey)
if err != nil {
return nil, err
}
}
if len(opts.PublicKey) > 0 {
pub, err = jwt.ParseECPublicKeyFromPEM(opts.PublicKey)
if err != nil {
return nil, err
}
}
if priv == nil {
if pub == nil {
// Neither key given
return nil, ErrMissingKey
}
// Public key only, can verify tokens
return pub, nil
}
// both keys provided, make sure they match
if pub != nil && pub.Curve != priv.Curve &&
pub.X.Cmp(priv.X) != 0 && pub.Y.Cmp(priv.Y) != 0 {
return nil, ErrKeyMismatch
}
return priv, nil
}

View File

@ -66,6 +66,10 @@ var (
ErrInvalidAuthToken = errors.New("auth: invalid auth token")
ErrInvalidAuthOpts = errors.New("auth: invalid auth options")
ErrInvalidAuthMgmt = errors.New("auth: invalid auth management")
ErrInvalidAuthMethod = errors.New("auth: invalid auth signature method")
ErrMissingKey = errors.New("auth: missing key data")
ErrKeyMismatch = errors.New("auth: public and private keys don't match")
ErrVerifyOnly = errors.New("auth: token signing attempted with verify-only key")
)
const (

View File

@ -25,6 +25,15 @@ cfssl gencert \
mv server.pem server.crt
mv server-key.pem server.key.insecure
# generate DNS: localhost, IP: 127.0.0.1, CN: example.com certificates (ECDSA)
cfssl gencert \
--ca ./ca.crt \
--ca-key ./ca-key.pem \
--config ./gencert.json \
./server-ca-csr-ecdsa.json | cfssljson --bare ./server-ecdsa
mv server-ecdsa.pem server-ecdsa.crt
mv server-ecdsa-key.pem server-ecdsa.key.insecure
# generate IP: 127.0.0.1, CN: example.com certificates
cfssl gencert \
--ca ./ca.crt \

View File

@ -0,0 +1,20 @@
{
"key": {
"algo": "ecdsa",
"size": 256
},
"names": [
{
"O": "etcd",
"OU": "etcd Security",
"L": "San Francisco",
"ST": "California",
"C": "USA"
}
],
"CN": "example.com",
"hosts": [
"127.0.0.1",
"localhost"
]
}

View File

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDRzCCAi+gAwIBAgIUK5XUt/HZQ3IpLbDFI1EIU4jiAxIwDQYJKoZIhvcNAQEL
BQAwbzEMMAoGA1UEBhMDVVNBMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQH
Ew1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQKEwRldGNkMRYwFAYDVQQLEw1ldGNkIFNl
Y3VyaXR5MQswCQYDVQQDEwJjYTAeFw0xODA2MTkxNjIwMDBaFw0yODA2MTYxNjIw
MDBaMHgxDDAKBgNVBAYTA1VTQTETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UE
BxMNU2FuIEZyYW5jaXNjbzENMAsGA1UEChMEZXRjZDEWMBQGA1UECxMNZXRjZCBT
ZWN1cml0eTEUMBIGA1UEAxMLZXhhbXBsZS5jb20wWTATBgcqhkjOPQIBBggqhkjO
PQMBBwNCAARDiiEQNXiH6eYz5Tws31IeU/OZ0sf7gHIJNvbST/cpXtjo4oFGcu0t
TY4+FAMk0ku07s/kX9r55TgKr1VljG31o4GcMIGZMA4GA1UdDwEB/wQEAwIFoDAd
BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNV
HQ4EFgQUzo0YV8GX/aN/WRsyygA8QVZaMQQwHwYDVR0jBBgwFoAURt/EV2KWh7I1
N8NXXowk6J1QtvgwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3
DQEBCwUAA4IBAQCbUYjMwKuHQjNEFTvx4jQB/LZTr1Mn53C1etR0qLd50v9TXVzb
FeZoo0g4mXln0BrLVMLatw0CTlGBCw+yJQ+5iJB5z3bKEl4ADwzRFDxwCMXXG8lV
wQOS/eaTBcAkzf/BWITLB1mIIp3kKZwXM6IW53yDkPFDpnExPY+ycoNp58U1JxOJ
ySM3/zyr0Ac8qCNqAakT2WacJ+AdB7pgoupbVF2WKT6qYbF1yvYY8x/zr8ePHznS
fvuO+80wYPbyw13s6rpNv4d0L1k7GDcXVs3lHC47hSNn7OBhf4Xkku101MtP3DhO
gFqW7p7vigK20tZKy4NYF6+nW3xJmOlw3gJF
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIZcM3NsBY+ZjW2t+AqdvW1lqYhD5l4zT6xr/eBIoh1aoAoGCCqGSM49
AwEHoUQDQgAEQ4ohEDV4h+nmM+U8LN9SHlPzmdLH+4ByCTb20k/3KV7Y6OKBRnLt
LU2OPhQDJNJLtO7P5F/a+eU4Cq9VZYxt9Q==
-----END EC PRIVATE KEY-----