diff --git a/core/commands/keystore.go b/core/commands/keystore.go index bd3146ca57c..11f62fe5046 100644 --- a/core/commands/keystore.go +++ b/core/commands/keystore.go @@ -2,6 +2,9 @@ package commands import ( "bytes" + "crypto/ed25519" + "crypto/x509" + "encoding/pem" "fmt" "io" "io/ioutil" @@ -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", @@ -143,6 +153,13 @@ Exports a named libp2p key to disk. By default, the output will be stored at './.key', but an alternate path can be specified with '--output=' or '-o='. + +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{ @@ -150,6 +167,7 @@ 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, libp2p-protobuf-cleartext or pem-pkcs8-cleartext.").WithDefault(keyFormatLibp2pCleartextOption), }, NoRemote: true, Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { @@ -186,12 +204,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 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 +252,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 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 +273,26 @@ path can be specified with '--output=' or '-o='. } 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 @@ -234,9 +303,22 @@ path can be specified with '--output=' or '-o='. 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 + $ ipfs key import test-openssl -f pem-pkcs8-cleartext ed25519.pem +`, }, 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"), @@ -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) diff --git a/test/sharness/t0165-keystore-data/README.md b/test/sharness/t0165-keystore-data/README.md new file mode 100644 index 00000000000..33c77fbd376 --- /dev/null +++ b/test/sharness/t0165-keystore-data/README.md @@ -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 +``` diff --git a/test/sharness/t0165-keystore-data/openssl_ed25519.pem b/test/sharness/t0165-keystore-data/openssl_ed25519.pem new file mode 100644 index 00000000000..387972c5265 --- /dev/null +++ b/test/sharness/t0165-keystore-data/openssl_ed25519.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ2M1na2f3dRm4b1FcAQvsn7q08+XfBZcr4MgH4yiBdz +-----END PRIVATE KEY----- diff --git a/test/sharness/t0165-keystore-data/openssl_rsa.pem b/test/sharness/t0165-keystore-data/openssl_rsa.pem new file mode 100644 index 00000000000..34d365bed2e --- /dev/null +++ b/test/sharness/t0165-keystore-data/openssl_rsa.pem @@ -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----- diff --git a/test/sharness/t0165-keystore.sh b/test/sharness/t0165-keystore.sh index ad4b6a6c7c7..4729a7e2e20 100755 --- a/test/sharness/t0165-keystore.sh +++ b/test/sharness/t0165-keystore.sh @@ -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 && @@ -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" @@ -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