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()
+}