Skip to content

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

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

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
51 changes: 32 additions & 19 deletions test/sharness/t0165-keystore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,24 +63,14 @@ 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_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_key_import_export_all_formats ed25519_key

test_expect_success "test export file option" '
ipfs key export generated_rsa_key -o=named_rsa_export_file &&
Expand Down Expand Up @@ -176,15 +166,13 @@ 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_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 +202,31 @@ 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_key_cmd

test_done