Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cmds): add cleartext PEM/PKCS8 for key import/export #8616

Merged
143 changes: 132 additions & 11 deletions core/commands/keystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package commands

import (
"bytes"
"crypto/ed25519"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"io/ioutil"
Expand Down Expand Up @@ -135,6 +138,13 @@ var keyGenCmd = &cmds.Command{
Type: KeyOutput{},
}

const (
// Key format options used both for importing and exporting.
keyFormatOptionName = "format"
keyFormatPemCleartextOption = "pem-pkcs8-cleartext"
keyFormatLibp2pCleartextOption = "libp2p-protobuf-cleartext"
)

var keyExportCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Export a keypair",
Expand All @@ -143,13 +153,21 @@ Exports a named libp2p key to disk.

By default, the output will be stored at './<key-name>.key', but an alternate
path can be specified with '--output=<path>' or '-o=<path>'.

It is possible to export a private key to interoperable PEM PKCS8 format by explicitly
passing '--format=pem-pkcs8-cleartext'. The resulting PEM file can then be consumed
elsewhere. For example, using openssl to get a PEM with public key:

$ ipfs key export testkey --format=pem-pkcs8-cleartext -o privkey.pem
$ openssl pkey -in privkey.pem -pubout > pubkey.pem
`,
},
Arguments: []cmds.Argument{
cmds.StringArg("name", true, false, "name of key to export").EnableStdin(),
},
Options: []cmds.Option{
cmds.StringOption(outputOptionName, "o", "The path where the output should be stored."),
cmds.StringOption(keyFormatOptionName, "f", "The format of the exported private key, libp2p-protobuf-cleartext or pem-pkcs8-cleartext.").WithDefault(keyFormatLibp2pCleartextOption),
},
NoRemote: true,
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
Expand Down Expand Up @@ -186,12 +204,38 @@ path can be specified with '--output=<path>' or '-o=<path>'.
return fmt.Errorf("key with name '%s' doesn't exist", name)
}

encoded, err := crypto.MarshalPrivateKey(sk)
if err != nil {
return err
exportFormat, _ := req.Options[keyFormatOptionName].(string)
var formattedKey []byte
switch exportFormat {
case keyFormatPemCleartextOption:
stdKey, err := crypto.PrivKeyToStdKey(sk)
if err != nil {
return fmt.Errorf("converting libp2p private key to std Go key: %w", err)

}
// For some reason the ed25519.PrivateKey does not use pointer
// receivers, so we need to convert it for MarshalPKCS8PrivateKey.
Comment on lines +216 to +217
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, very weird that MarshalPKCS8PrivateKey has docs calling out ed25519 pointers as special:

The following key types are currently supported: *rsa.PrivateKey, *ecdsa.PrivateKey and ed25519.PrivateKey. Unsupported key types result in an error.

// (We should probably change this upstream in PrivKeyToStdKey).
if ed25519KeyPointer, ok := stdKey.(*ed25519.PrivateKey); ok {
stdKey = *ed25519KeyPointer
}
// This function supports a restricted list of public key algorithms,
// but we generate and use only the RSA and ed25519 types that are on that list.
formattedKey, err = x509.MarshalPKCS8PrivateKey(stdKey)
if err != nil {
return fmt.Errorf("marshalling key to PKCS8 format: %w", err)
}

case keyFormatLibp2pCleartextOption:
formattedKey, err = crypto.MarshalPrivateKey(sk)
if err != nil {
return err
}
default:
return fmt.Errorf("unrecognized export format: %s", exportFormat)
}

return res.Emit(bytes.NewReader(encoded))
return res.Emit(bytes.NewReader(formattedKey))
},
PostRun: cmds.PostRunMap{
cmds.CLI: func(res cmds.Response, re cmds.ResponseEmitter) error {
Expand All @@ -208,8 +252,16 @@ path can be specified with '--output=<path>' or '-o=<path>'.
}

outPath, _ := req.Options[outputOptionName].(string)
exportFormat, _ := req.Options[keyFormatOptionName].(string)
if outPath == "" {
trimmed := strings.TrimRight(fmt.Sprintf("%s.key", req.Arguments[0]), "/")
var fileExtension string
switch exportFormat {
case keyFormatPemCleartextOption:
fileExtension = "pem"
case keyFormatLibp2pCleartextOption:
fileExtension = "key"
}
trimmed := strings.TrimRight(fmt.Sprintf("%s.%s", req.Arguments[0], fileExtension), "/")
_, outPath = filepath.Split(trimmed)
outPath = filepath.Clean(outPath)
}
Expand All @@ -221,9 +273,26 @@ path can be specified with '--output=<path>' or '-o=<path>'.
}
defer file.Close()

_, err = io.Copy(file, outReader)
if err != nil {
return err
switch exportFormat {
case keyFormatPemCleartextOption:
privKeyBytes, err := ioutil.ReadAll(outReader)
if err != nil {
return err
}

err = pem.Encode(file, &pem.Block{
Type: "PRIVATE KEY",
Bytes: privKeyBytes,
})
if err != nil {
return fmt.Errorf("encoding PEM block: %w", err)
}

case keyFormatLibp2pCleartextOption:
_, err = io.Copy(file, outReader)
if err != nil {
return err
}
}

return nil
Expand All @@ -234,9 +303,22 @@ path can be specified with '--output=<path>' or '-o=<path>'.
var keyImportCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Import a key and prints imported key id",
ShortDescription: `
Imports a key and stores it under the provided name.

By default, the key is assumed to be in 'libp2p-protobuf-cleartext' format,
however it is possible to import private keys wrapped in interoperable PEM PKCS8
by passing '--format=pem-pkcs8-cleartext'.

The PEM format allows for key generation outside of the IPFS node:

$ openssl genpkey -algorithm ED25519 > ed25519.pem
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have to add openssl as a dependency, but we could add a test fixture here that verifies we're compatible by dumping a private key into the sharness test folder (like we have for CAR import/export tests). We could then do some round-tripping tests to make sure everything is as expected (e.g. openssl -> import -> export as libp2p -> import -> export as pem -> check equality).

Hopefully should be relatively straightforward, but if not it's not strictly necessary.

$ ipfs key import test-openssl -f pem-pkcs8-cleartext ed25519.pem
Comment on lines +315 to +316
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is the best remainder of why we're doing this. It's pretty cool to be able to easily interoperate with openssl commands 👍

`,
},
Options: []cmds.Option{
ke.OptionIPNSBase,
cmds.StringOption(keyFormatOptionName, "f", "The format of the private key to import, libp2p-protobuf-cleartext or pem-pkcs8-cleartext.").WithDefault(keyFormatLibp2pCleartextOption),
},
Arguments: []cmds.Argument{
cmds.StringArg("name", true, false, "name to associate with key in keychain"),
Expand Down Expand Up @@ -265,9 +347,48 @@ var keyImportCmd = &cmds.Command{
return err
}

sk, err := crypto.UnmarshalPrivateKey(data)
if err != nil {
return err
importFormat, _ := req.Options[keyFormatOptionName].(string)
var sk crypto.PrivKey
switch importFormat {
case keyFormatPemCleartextOption:
pemBlock, rest := pem.Decode(data)
if pemBlock == nil {
return fmt.Errorf("PEM block not found in input data:\n%s", rest)
}

if pemBlock.Type != "PRIVATE KEY" {
return fmt.Errorf("expected PRIVATE KEY type in PEM block but got: %s", pemBlock.Type)
}

stdKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
if err != nil {
return fmt.Errorf("parsing PKCS8 format: %w", err)
}

// In case ed25519.PrivateKey is returned we need the pointer for
// conversion to libp2p (see export command for more details).
if ed25519KeyPointer, ok := stdKey.(ed25519.PrivateKey); ok {
stdKey = &ed25519KeyPointer
}

sk, _, err = crypto.KeyPairFromStdKey(stdKey)
if err != nil {
return fmt.Errorf("converting std Go key to libp2p key: %w", err)

}
case keyFormatLibp2pCleartextOption:
sk, err = crypto.UnmarshalPrivateKey(data)
if err != nil {
// check if data is PEM, if so, provide user with hint
pemBlock, _ := pem.Decode(data)
if pemBlock != nil {
return fmt.Errorf("unexpected PEM block for format=%s: try again with format=%s", keyFormatLibp2pCleartextOption, keyFormatPemCleartextOption)
}
return fmt.Errorf("unable to unmarshall format=%s: %w", keyFormatLibp2pCleartextOption, err)
}

default:
return fmt.Errorf("unrecognized import format: %s", importFormat)
}

cfgRoot, err := cmdenv.GetConfigRoot(env)
Expand Down
8 changes: 8 additions & 0 deletions test/sharness/t0165-keystore-data/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# OpenSSL generated keys for import/export tests

Created with commands:

```bash
openssl genpkey -algorithm ED25519 > openssl_ed25519.pem
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 > openssl_rsa.pem
```
3 changes: 3 additions & 0 deletions test/sharness/t0165-keystore-data/openssl_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJ2M1na2f3dRm4b1FcAQvsn7q08+XfBZcr4MgH4yiBdz
-----END PRIVATE KEY-----
28 changes: 28 additions & 0 deletions test/sharness/t0165-keystore-data/openssl_rsa.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDSaJB9EKnShOs6
sbGkB40crn72yNKXj5OBPS2wBDTHWwxyhTB0qJirOT2QYW2DmR/4lPfVk5/f4CJ7
xIHUBJRoC+NTwqHit24DQBd00tNG4EnKn2Dad/arZ/nEVshkKiGXn0qXxiHHsaCn
X/pnVPU4+O7fdfUlz2EKf3Og/ocRCFrdMsULR2QwDc0YWsY8ngrcKegyFCbKjXjo
zvfbGevCDPlhKaZLxRy0PHnON00YC4KO6d77XpbECFvsE1aG1RxYQX0Zjr+i8UvD
UJp/YCoRNEX54/wKpGebMUrFse5K9hBsFen/wCsPnOsYPSb9g8qyoYRDBnr9sIe1
9MxFTMy/AgMBAAECggEAKXu2KQI1CS1tlzfbdySJ/MKmg49afckv4sYmENLzeO6J
iLabtBRdbTyu151t0wlIlWEBb9lYJvJwuggnNJ7mh5D4c9YmxqU1imyDc2PxhcLI
qas8lDYcqvSn+L7HaYAo+VTNhxjoJg/uRbGVk/PbGS1zIxmFiLvXPROdv3sPNBsf
EYMDH9q7/8DI6dNBQPxtTKlTDLDsTezbkNFQ74znlXgQYcfY1mXljcRtbJqhQJT3
uppktESPwLRmqtT9H+v9nCtQR6OLmAmLWNgMrSdGKBsSsgJwv2xfpNMffwd84dtT
uGrS2K+BY0TH2q+Xx04r18GLCst3U5MBSklyHQ/mwQKBgQDqnxNOnK41/n/Q8X4a
/TUnZBx/JHiCoQoa06AsMxFgOvV3ycR+Z9lwb5I5BsicH1GUcHIxSY3mCyd4fLwE
FC0QIyNhPJ5oFKh0Oynjm+79VE8v7kK2qqRL4zUpaCXEsSOrhRsCY0/WQdMUPVsh
okXDUIv37G9KUcjdrhNVpGK3oQKBgQDllK7augIhmlQZTdSLTgmuzhYsXdSGDML/
Bx48q7OvPhvZIIOsygLGhtcBk2xG6PN1yP44cx9dvcTnzxU6TEblO5P8TWY0BSNj
ZuC5wdxLwc3KUdLd9JLR7qcbjqndDruE01rQFVQ3MDbyB1+VrJgiVHIEomJJrKGm
FQ+314moXwKBgQDL90sDlnZk/kED1k15DRN+kSus5HnXpkRwmfWvNx4t+FOZtdCa
y5Fei8Akz17rStbTIwZDDtzLVnsT5exV52xdkQ6a4+YaOYtQsHZ0JwWXOgo1cv6Q
ary2NGns+1uKKS0HWYnng4rOix8Dg2uMS9Q2PfnQqLz/cSYcgc7RLz2awQKBgQDd
HSaLYztKQeldtahPwwlwYuzYLkbSFNh559EnfffBgIAxzy8C7E1gB95sliBi61oQ
x1SR6c776hoLaVd4np5picgt6B3XXFuJETy/rAcQr8gUZFpDi5sctk4cLHtNfTL9
6tI8N061GKrS0GcvMNwVtF9cN0mSy8GkxAQvfFgI4QKBgQC4NVimIPptfFckulAL
/t0vkdLhCRr1+UFNhgsQJhCZpfWZK4x8If6Jru/eiU7ywEsL6fHE2ENvyoTjV33g
b9yJ7SV4zkz4VhBxc3p26SIvBgLqtHwH8IkIonlbfQFoEAg1iOneLvimPy0YGHsG
+bTwwlAJJhctILkFtAbooeAQVQ==
-----END PRIVATE KEY-----
86 changes: 68 additions & 18 deletions test/sharness/t0165-keystore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,24 +63,16 @@ ipfs key rm key_ed25519
echo $rsahash > rsa_key_id
'

test_key_import_export_all_formats rsa_key

test_expect_success "create a new ed25519 key" '
edhash=$(ipfs key gen generated_ed25519_key --type=ed25519)
echo $edhash > ed25519_key_id
'

test_expect_success "export and import rsa key" '
ipfs key export generated_rsa_key &&
ipfs key rm generated_rsa_key &&
ipfs key import generated_rsa_key generated_rsa_key.key > roundtrip_rsa_key_id &&
test_cmp rsa_key_id roundtrip_rsa_key_id
'
test_key_import_export_all_formats ed25519_key

test_expect_success "export and import ed25519 key" '
ipfs key export generated_ed25519_key &&
ipfs key rm generated_ed25519_key &&
ipfs key import generated_ed25519_key generated_ed25519_key.key > roundtrip_ed25519_key_id &&
test_cmp ed25519_key_id roundtrip_ed25519_key_id
'
test_openssl_compatibility_all_types

test_expect_success "test export file option" '
ipfs key export generated_rsa_key -o=named_rsa_export_file &&
Expand Down Expand Up @@ -176,15 +168,15 @@ ipfs key rm key_ed25519
'

# export works directly on the keystore present in IPFS_PATH
test_expect_success "export and import ed25519 key while daemon is running" '
edhash=$(ipfs key gen exported_ed25519_key --type=ed25519)
test_expect_success "prepare ed25519 key while daemon is running" '
edhash=$(ipfs key gen generated_ed25519_key --type=ed25519)
echo $edhash > ed25519_key_id
ipfs key export exported_ed25519_key &&
ipfs key rm exported_ed25519_key &&
ipfs key import exported_ed25519_key exported_ed25519_key.key > roundtrip_ed25519_key_id &&
test_cmp ed25519_key_id roundtrip_ed25519_key_id
'

test_key_import_export_all_formats ed25519_key

test_openssl_compatibility_all_types

test_expect_success "key export over HTTP /api/v0/key/export is not possible" '
ipfs key gen nohttpexporttest_key --type=ed25519 &&
curl -X POST -sI "http://$API_ADDR/api/v0/key/export&arg=nohttpexporttest_key" | grep -q "^HTTP/1.1 404 Not Found"
Expand Down Expand Up @@ -214,6 +206,64 @@ test_check_ed25519_sk() {
}
}

test_key_import_export_all_formats() {
KEY_NAME=$1
test_key_import_export $KEY_NAME pem-pkcs8-cleartext
test_key_import_export $KEY_NAME libp2p-protobuf-cleartext
}

test_key_import_export() {
local KEY_NAME FORMAT
KEY_NAME=$1
FORMAT=$2
ORIG_KEY="generated_$KEY_NAME"
if [ $FORMAT == "pem-pkcs8-cleartext" ]; then
FILE_EXT="pem"
else
FILE_EXT="key"
fi

test_expect_success "export and import $KEY_NAME with format $FORMAT" '
ipfs key export $ORIG_KEY --format=$FORMAT &&
ipfs key rm $ORIG_KEY &&
ipfs key import $ORIG_KEY $ORIG_KEY.$FILE_EXT --format=$FORMAT > imported_key_id &&
test_cmp ${KEY_NAME}_id imported_key_id
'
}

# Test the entire import/export cycle with a openssl-generated key.
# 1. Import openssl key with PEM format.
# 2. Export key with libp2p format.
# 3. Reimport key.
# 4. Now exported with PEM format.
# 5. Compare with original openssl key.
# 6. Clean up.
test_openssl_compatibility() {
local KEY_NAME FORMAT
KEY_NAME=$1

test_expect_success "import and export $KEY_NAME with all formats" '
ipfs key import test-openssl -f pem-pkcs8-cleartext $KEY_NAME > /dev/null &&
ipfs key export test-openssl -f libp2p-protobuf-cleartext -o $KEY_NAME.libp2p.key &&
ipfs key rm test-openssl &&

ipfs key import test-openssl -f libp2p-protobuf-cleartext $KEY_NAME.libp2p.key > /dev/null &&
ipfs key export test-openssl -f pem-pkcs8-cleartext -o $KEY_NAME.ipfs-exported.pem &&
ipfs key rm test-openssl &&

test_cmp $KEY_NAME $KEY_NAME.ipfs-exported.pem &&

rm $KEY_NAME.libp2p.key &&
rm $KEY_NAME.ipfs-exported.pem
'
}

test_openssl_compatibility_all_types() {
test_openssl_compatibility ../t0165-keystore-data/openssl_ed25519.pem
test_openssl_compatibility ../t0165-keystore-data/openssl_rsa.pem
}


test_key_cmd

test_done