diff --git a/cmd/userenckey/main.go b/cmd/userenckey/main.go new file mode 100644 index 0000000000000000000000000000000000000000..c24a2c69ab0bbfd37fea0fd57ad441bb4d3008fc --- /dev/null +++ b/cmd/userenckey/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/base64" + "flag" + "fmt" + "log" + + "git.autistici.org/ai3/go-common/userenckey" +) + +var ( + doGenKeys = flag.Bool("gen-keys", false, "generate user encryption keys with the specified curve") + doDecrypt = flag.Bool("decrypt", false, "decrypt the private key given on the command line") + password = flag.String("password", "", "password") +) + +func genKeys() ([]byte, []byte, error) { + pub, priv, err := userenckey.GenerateKey() + if err != nil { + return nil, nil, err + } + + enc, err := userenckey.Encrypt(priv, []byte(*password)) + if err != nil { + return nil, nil, err + } + + return enc, pub, err +} + +func main() { + log.SetFlags(0) + flag.Parse() + + switch { + case *doGenKeys: + if *password == "" { + log.Fatal("must specify --password") + } + + priv, pub, err := genKeys() + if err != nil { + log.Fatal(err) + } + fmt.Printf( + "public key:\n%s\nprivate key (encrypted,base64): %s\n", + pub, + base64.StdEncoding.EncodeToString(priv), + ) + + case *doDecrypt: + if *password == "" { + log.Fatal("must specify --password") + } + if flag.NArg() < 1 { + log.Fatal("not enough arguments") + } + + var encKeys [][]byte + for _, arg := range flag.Args() { + encKey, err := base64.StdEncoding.DecodeString(arg) + if err != nil { + log.Fatalf("bad base64-encoded argument: %v", err) + } + encKeys = append(encKeys, encKey) + } + + dec, err := userenckey.Decrypt(encKeys, []byte(*password)) + if err != nil { + log.Fatal(err) + } + pem, err := dec.PEM() + if err != nil { + log.Fatalf("invalid private key: %v", err) + } + fmt.Printf("private key:\n%s\n", pem) + + default: + log.Fatal("no actions specified") + } +} diff --git a/userenckey/container.go b/userenckey/container.go new file mode 100644 index 0000000000000000000000000000000000000000..5da01a946a5c634ede438071a93f5c61b12711da --- /dev/null +++ b/userenckey/container.go @@ -0,0 +1,125 @@ +package userenckey + +import ( + "bytes" + "crypto/rand" + "errors" + "io" + + "github.com/lunixbochs/struc" + "github.com/miscreant/miscreant/go" + "golang.org/x/crypto/argon2" +) + +// Current algorithm: Argon2 KDF + AES-SIV key encryption. +const algoArgon2AESSIV = 1 + +const aeadAlgo = "AES-SIV" + +// Struct members have stupid names to reduce the size of the resulting gob! +type argon2Params struct { + Time uint32 `struc:"uint32,little"` + Memory uint32 `struc:"uint32,little"` + Threads uint8 `struc:"uint8"` +} + +// Default Argon2 parameters are tuned for a high-traffic +// authentication service (<1ms per operation). +var defaultArgon2Params = argon2Params{ + Time: 1, + Memory: 4 * 1024, + Threads: 4, +} + +const ( + keyLen = 64 + saltLen = 32 +) + +func argon2KDF(params argon2Params, salt, pw []byte) []byte { + return argon2.Key(pw, salt, params.Time, params.Memory, params.Threads, keyLen) +} + +// An encrypted container stores an opaque blob of binary data along +// with metadata about the encryption itself, to allow for a +// controlled amount of algorithm malleability accounting for future +// updates. The structure is binary-packed (as opposed to using higher +// level serializations such as encoding/gob) because we'd like to be +// able to read it from other languages if necessary. +type container struct { // nolint: maligned + Algo uint8 `struc:"uint8"` + Params argon2Params + SaltLen uint8 `struc:"uint8,sizeof=Salt"` + Salt []byte + DataLen uint16 `struc:"uint16,little,sizeof=Data"` + Data []byte +} + +// Convert to an opaque encoded ("wire") representation. +func (c *container) Marshal() ([]byte, error) { + var buf bytes.Buffer + err := struc.Pack(&buf, c) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// Parse a key object from the wire representation. +func unmarshalContainer(b []byte) (c container, err error) { + err = struc.Unpack(bytes.NewReader(b), &c) + return +} + +func newContainer(data, pw []byte) (container, error) { + return encryptArgon2AESSIV(data, pw) +} + +func (c container) decrypt(pw []byte) ([]byte, error) { + // Only one supported kdf/algo combination right now. + if c.Algo == algoArgon2AESSIV { + return c.decryptArgon2AESSIV(pw) + } + return nil, errors.New("unsupported algo") +} + +func (c container) decryptArgon2AESSIV(pw []byte) ([]byte, error) { + // Run the KDF and create the AEAD cipher. + dk := argon2KDF(c.Params, c.Salt, pw) + cipher, err := miscreant.NewAEAD(aeadAlgo, dk, 0) + if err != nil { + return nil, err + } + + // Decrypt the data and obtain the DER-encoded private key. + dec, err := cipher.Open(nil, nil, c.Data, nil) + return dec, err +} + +func encryptArgon2AESSIV(data, pw []byte) (container, error) { + c := container{ + Algo: algoArgon2AESSIV, + Params: defaultArgon2Params, + Salt: genRandomSalt(), + } + + // Run the KDF and create the AEAD cipher. + dk := argon2KDF(c.Params, c.Salt, pw) + cipher, err := miscreant.NewAEAD(aeadAlgo, dk, 0) + if err != nil { + return container{}, err + } + + // Encrypt the data (a DER-encoded ECDSA private key). + c.Data = cipher.Seal(nil, nil, data, nil) + return c, nil +} + +func genRandomSalt() []byte { + var b [saltLen]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + panic(err) + } + return b[:] +} diff --git a/userenckey/container_test.go b/userenckey/container_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b65aa951c2615b04ad61083c3ce30b79a412d8c2 --- /dev/null +++ b/userenckey/container_test.go @@ -0,0 +1,25 @@ +package userenckey + +import ( + "bytes" + "testing" +) + +func TestArgon2AESSIV(t *testing.T) { + pw := []byte("secret pw") + encKey := []byte("secret encryption key") + + key, err := encryptArgon2AESSIV(encKey, pw) + if err != nil { + t.Fatal("encryptArgon2AESSIV", err) + } + + out, err := key.decryptArgon2AESSIV(pw) + if err != nil { + t.Fatal("decryptArgon2AESSIV", err) + } + + if !bytes.Equal(out, encKey) { + t.Fatal("decryption failed") + } +} diff --git a/userenckey/key.go b/userenckey/key.go new file mode 100644 index 0000000000000000000000000000000000000000..d10c4df0080a64c361dc0be62cb68fb7ae4f41ee --- /dev/null +++ b/userenckey/key.go @@ -0,0 +1,90 @@ +package userenckey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" +) + +// ErrBadPassword is returned on decryption failure. +var ErrBadPassword = errors.New("could not decrypt key with password") + +func encodePublicKeyToPEM(pub *ecdsa.PublicKey) ([]byte, error) { + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}), nil +} + +// Key (unencrypted). +type Key struct { + rawBytes []byte +} + +// GenerateKey generates a new ECDSA key pair, and returns the +// PEM-encoded public and private key (in order). +func GenerateKey() ([]byte, *Key, error) { + priv, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + if err != nil { + return nil, nil, err + } + + //privBytes, err := encodePrivateKeyToPEM(priv) + privBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, nil, err + } + pubBytes, err := encodePublicKeyToPEM(&priv.PublicKey) + if err != nil { + return nil, nil, err + } + + return pubBytes, &Key{privBytes}, nil +} + +// PEM returns the key in PEM-encoded format. +func (k *Key) PEM() ([]byte, error) { + // Parse the ASN.1 data and encode it with PKCS8 (in PEM format). + priv, err := k.PrivateKey() + if err != nil { + return nil, err + } + + return encodePrivateKeyToPEM(priv) +} + +// PrivateKey parses the DER-encoded ASN.1 data in Key and returns the +// private key object. +func (k *Key) PrivateKey() (*ecdsa.PrivateKey, error) { + return x509.ParseECPrivateKey(k.rawBytes) +} + +// Encrypt a key with a password and a random salt. +func Encrypt(key *Key, pw []byte) ([]byte, error) { + c, err := newContainer(key.rawBytes, pw) + if err != nil { + return nil, err + } + return c.Marshal() +} + +// Decrypt one out of multiple keys with the specified password. The +// keys share the same cleartext, but have been encrypted with +// different passwords. +func Decrypt(encKeys [][]byte, pw []byte) (*Key, error) { + for _, encKey := range encKeys { + c, err := unmarshalContainer(encKey) + if err != nil { + //log.Printf("parse error: %v", err) + continue + } + if dec, err := c.decrypt(pw); err == nil { + return &Key{dec}, nil + } + } + return nil, ErrBadPassword +} diff --git a/userenckey/key_test.go b/userenckey/key_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7e58801c3ad9349758773bb488e90cdcbd9188b4 --- /dev/null +++ b/userenckey/key_test.go @@ -0,0 +1,55 @@ +package userenckey + +import ( + "bytes" + "log" + "testing" +) + +func TestGenerateKey(t *testing.T) { + pub, priv, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + + if !bytes.HasPrefix(pub, []byte("-----BEGIN PUBLIC KEY-----")) { + t.Errorf("bad public key: %s", string(pub)) + } + if priv == nil { + t.Fatalf("no private key returned") + } + if len(priv.rawBytes) == 0 { + t.Fatalf("private key is empty") + } + + // Parse the key now, check PKCS8 PEM header. + pem, err := priv.PEM() + if err != nil { + t.Fatalf("error parsing private key: %v", err) + } + if !bytes.HasPrefix(pem, []byte("-----BEGIN PRIVATE KEY-----")) { + t.Fatalf("bad PEM private key: %s", string(pem)) + } +} + +func TestEncryptDecrypt(t *testing.T) { + pw := []byte("stracchino") + // Don't need to use a real key as Encrypt/Decrypt are + // agnostic with respect to the container content. + key := &Key{[]byte("this is a very secret key")} + + enc, err := Encrypt(key, pw) + if err != nil { + t.Fatal("Encrypt():", err) + } + + log.Printf("encrypted key: %q (%d bytes)", enc, len(enc)) + + dec, err := Decrypt([][]byte{enc}, pw) + if err != nil { + t.Fatal("Decrypt():", err) + } + if !bytes.Equal(key.rawBytes, dec.rawBytes) { + t.Fatalf("bad decrypted ciphertext: %v", dec) + } +} diff --git a/userenckey/pkcs8.go b/userenckey/pkcs8.go new file mode 100644 index 0000000000000000000000000000000000000000..1e483e6e18d4b35dcffa82894a9a2ba5e3200d97 --- /dev/null +++ b/userenckey/pkcs8.go @@ -0,0 +1,18 @@ +// +build go1.10 + +package userenckey + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" +) + +// Encode a private key to PEM-encoded PKCS8. +func encodePrivateKeyToPEM(priv *ecdsa.PrivateKey) ([]byte, error) { + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} diff --git a/userenckey/pkcs8_compat.go b/userenckey/pkcs8_compat.go new file mode 100644 index 0000000000000000000000000000000000000000..7f36efbad3fe773e862defe6f3e2508bc397c9cf --- /dev/null +++ b/userenckey/pkcs8_compat.go @@ -0,0 +1,28 @@ +// +build !go1.10 + +package userenckey + +import ( + "bytes" + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "os/exec" +) + +// Encode a private key to PEM-encoded PKCS8. +// +// In Go versions prior to 1.10, we must shell out to openssl to +// convert the private key to PKCS8 format. +func encodePrivateKeyToPEM(priv *ecdsa.PrivateKey) ([]byte, error) { + der, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, err + } + pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + + /* #nosec */ + cmd := exec.Command("/usr/bin/openssl", "pkey") + cmd.Stdin = bytes.NewReader(pkcs1) + return cmd.Output() +}