From 3fff9fba8b9f82d0402085955ee90d2d8629d0b8 Mon Sep 17 00:00:00 2001 From: Lucas Molas Date: Thu, 16 Dec 2021 12:03:12 -0300 Subject: [PATCH] feat(cmds): add PEM/PKCS8 for key import/export --- core/commands/keystore.go | 163 +++++++++++++++++++++++++++++--- test/sharness/t0165-keystore.sh | 55 +++++++---- 2 files changed, 188 insertions(+), 30 deletions(-) diff --git a/core/commands/keystore.go b/core/commands/keystore.go index bd3146ca57c..3729292222f 100644 --- a/core/commands/keystore.go +++ b/core/commands/keystore.go @@ -2,6 +2,10 @@ package commands import ( "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" "fmt" "io" "io/ioutil" @@ -135,6 +139,15 @@ var keyGenCmd = &cmds.Command{ Type: KeyOutput{}, } +const ( + // Key format options used both for importing and exporting. + keyFormatOptionName = "format" + keyFormatPemEncryptedOption = "pem-pkcs8-encrypted" + keyFormatPemCleartextOption = "pem-pkcs8-cleartext" + keyFormatLibp2pCleartextOption = "libp2p-protobuf-cleartext" + keyEncryptionPasswordOptionName = "password" +) + var keyExportCmd = &cmds.Command{ Helptext: cmds.HelpText{ Tagline: "Export a keypair", @@ -150,6 +163,10 @@ path can be specified with '--output=' or '-o='. }, 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.").WithDefault(keyFormatLibp2pCleartextOption), + cmds.StringOption(keyEncryptionPasswordOptionName, "p", "The password to encrypt the exported key with (for the encrypted variant only)."), + // FIXME(BLOCKING): change default to keyFormatPemEncryptedOption once it + // is implemented and the sharness tests (if any) are adapted. }, NoRemote: true, Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { @@ -186,12 +203,38 @@ path can be specified with '--output=' or '-o='. 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 keyFormatPemEncryptedOption, 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. + // (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 { @@ -208,8 +251,16 @@ path can be specified with '--output=' or '-o='. } 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 keyFormatPemEncryptedOption, 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) } @@ -221,9 +272,46 @@ path can be specified with '--output=' or '-o='. } defer file.Close() - _, err = io.Copy(file, outReader) - if err != nil { - return err + switch exportFormat { + case keyFormatPemEncryptedOption, keyFormatPemCleartextOption: + privKeyBytes, err := ioutil.ReadAll(outReader) + if err != nil { + return err + } + + var pemBlock *pem.Block + if exportFormat == keyFormatPemEncryptedOption { + keyEncPassword, ok := req.Options[keyEncryptionPasswordOptionName].(string) + if !ok { + return fmt.Errorf("missing password to encrypt the key with, set it with --%s", + keyEncryptionPasswordOptionName) + } + // FIXME(BLOCKING): Using deprecated security function. + pemBlock, err = x509.EncryptPEMBlock(rand.Reader, + "ENCRYPTED PRIVATE KEY", + privKeyBytes, + []byte(keyEncPassword), + x509.PEMCipherAES256) + if err != nil { + return fmt.Errorf("encrypting PEM block: %w", err) + } + } else { // cleartext + pemBlock = &pem.Block{ + Type: "PRIVATE KEY", + Bytes: privKeyBytes, + } + } + + err = pem.Encode(file, pemBlock) + 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 @@ -237,6 +325,9 @@ var keyImportCmd = &cmds.Command{ }, Options: []cmds.Option{ ke.OptionIPNSBase, + cmds.StringOption(keyFormatOptionName, "f", "The format of the private key to import.").WithDefault(keyFormatLibp2pCleartextOption), + // FIXME: Attempt to figure out the import format. + cmds.StringOption(keyEncryptionPasswordOptionName, "p", "The password to decrypt the imported key with (for the encrypted variant only)."), }, Arguments: []cmds.Argument{ cmds.StringArg("name", true, false, "name to associate with key in keychain"), @@ -265,9 +356,59 @@ 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 keyFormatPemEncryptedOption, 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" && pemBlock.Type != "ENCRYPTED PRIVATE KEY" { + return fmt.Errorf("expected [ENCRYPTED] PRIVATE KEY type in PEM block but got: %s", pemBlock.Type) + } + + var privKeyBytes []byte + if importFormat == keyFormatPemEncryptedOption { + keyDecPassword, ok := req.Options[keyEncryptionPasswordOptionName].(string) + if !ok { + return fmt.Errorf("missing password to decrypt the key with, set it with --%s", + keyEncryptionPasswordOptionName) + } + privKeyBytes, err = x509.DecryptPEMBlock(pemBlock, + []byte(keyDecPassword)) + if err != nil { + return fmt.Errorf("decrypting PEM block: %w", err) + } + } else { // cleartext + privKeyBytes = pemBlock.Bytes + } + + stdKey, err := x509.ParsePKCS8PrivateKey(privKeyBytes) + 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 { + return err + } + + default: + return fmt.Errorf("unrecognized import format: %s", importFormat) } cfgRoot, err := cmdenv.GetConfigRoot(env) diff --git a/test/sharness/t0165-keystore.sh b/test/sharness/t0165-keystore.sh index ad4b6a6c7c7..dad93dd2913 100755 --- a/test/sharness/t0165-keystore.sh +++ b/test/sharness/t0165-keystore.sh @@ -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 && @@ -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" @@ -214,6 +202,35 @@ 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 pem-pkcs8-encrypted + 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-encrypted" ]; then + KEY_PASSWORD="--password=fake-test-password" + fi + if [ $FORMAT == "libp2p-protobuf-cleartext" ]; then + FILE_EXT="key" + else + FILE_EXT="pem" + fi + + test_expect_success "export and import $KEY_NAME with format $FORMAT" ' + ipfs key export $ORIG_KEY --format=$FORMAT $KEY_PASSWORD && + ipfs key rm $ORIG_KEY && + ipfs key import $ORIG_KEY $ORIG_KEY.$FILE_EXT --format=$FORMAT $KEY_PASSWORD > imported_key_id && + test_cmp ${KEY_NAME}_id imported_key_id + ' +} + test_key_cmd test_done