Skip to content

Commit d38421f

Browse files
committed
feat(cmds): add PEM/PKCS8 for key import/export
1 parent 8cfc889 commit d38421f

File tree

2 files changed

+210
-30
lines changed

2 files changed

+210
-30
lines changed

core/commands/keystore.go

+174-11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package commands
22

33
import (
44
"bytes"
5+
"crypto/ed25519"
6+
"crypto/rand"
7+
"crypto/x509"
8+
"encoding/pem"
59
"fmt"
610
"io"
711
"io/ioutil"
@@ -135,6 +139,15 @@ var keyGenCmd = &cmds.Command{
135139
Type: KeyOutput{},
136140
}
137141

142+
const (
143+
// Key format options used both for importing and exporting.
144+
keyFormatOptionName = "format"
145+
keyFormatPemEncryptedOption = "pem-pkcs8-encrypted"
146+
keyFormatPemCleartextOption = "pem-pkcs8-cleartext"
147+
keyFormatLibp2pCleartextOption = "libp2p-protobuf-cleartext"
148+
keyEncryptionPasswordOptionName = "password"
149+
)
150+
138151
var keyExportCmd = &cmds.Command{
139152
Helptext: cmds.HelpText{
140153
Tagline: "Export a keypair",
@@ -150,6 +163,10 @@ path can be specified with '--output=<path>' or '-o=<path>'.
150163
},
151164
Options: []cmds.Option{
152165
cmds.StringOption(outputOptionName, "o", "The path where the output should be stored."),
166+
cmds.StringOption(keyFormatOptionName, "f", "The format of the exported private key.").WithDefault(keyFormatLibp2pCleartextOption),
167+
cmds.StringOption(keyEncryptionPasswordOptionName, "p", "The password to encrypt the exported key with (for the encrypted variant only)."),
168+
// FIXME(BLOCKING): change default to keyFormatPemEncryptedOption once it
169+
// is implemented and the sharness tests (if any) are adapted.
153170
},
154171
NoRemote: true,
155172
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
@@ -186,12 +203,38 @@ path can be specified with '--output=<path>' or '-o=<path>'.
186203
return fmt.Errorf("key with name '%s' doesn't exist", name)
187204
}
188205

189-
encoded, err := crypto.MarshalPrivateKey(sk)
190-
if err != nil {
191-
return err
206+
exportFormat, _ := req.Options[keyFormatOptionName].(string)
207+
var formattedKey []byte
208+
switch exportFormat {
209+
case keyFormatPemEncryptedOption, keyFormatPemCleartextOption:
210+
stdKey, err := crypto.PrivKeyToStdKey(sk)
211+
if err != nil {
212+
return fmt.Errorf("converting libp2p private key to std Go key: %w", err)
213+
214+
}
215+
// For some reason the ed25519.PrivateKey does not use pointer
216+
// receivers, so we need to convert it for MarshalPKCS8PrivateKey.
217+
// (We should probably change this upstream in PrivKeyToStdKey).
218+
if ed25519KeyPointer, ok := stdKey.(*ed25519.PrivateKey); ok {
219+
stdKey = *ed25519KeyPointer
220+
}
221+
// This function supports a restricted list of public key algorithms,
222+
// but we generate and use only the RSA and ed25519 types that are on that list.
223+
formattedKey, err = x509.MarshalPKCS8PrivateKey(stdKey)
224+
if err != nil {
225+
return fmt.Errorf("marshalling key to PKCS8 format: %w", err)
226+
}
227+
228+
case keyFormatLibp2pCleartextOption:
229+
formattedKey, err = crypto.MarshalPrivateKey(sk)
230+
if err != nil {
231+
return err
232+
}
233+
default:
234+
return fmt.Errorf("unrecognized export format: %s", exportFormat)
192235
}
193236

194-
return res.Emit(bytes.NewReader(encoded))
237+
return res.Emit(bytes.NewReader(formattedKey))
195238
},
196239
PostRun: cmds.PostRunMap{
197240
cmds.CLI: func(res cmds.Response, re cmds.ResponseEmitter) error {
@@ -208,8 +251,16 @@ path can be specified with '--output=<path>' or '-o=<path>'.
208251
}
209252

210253
outPath, _ := req.Options[outputOptionName].(string)
254+
exportFormat, _ := req.Options[keyFormatOptionName].(string)
211255
if outPath == "" {
212-
trimmed := strings.TrimRight(fmt.Sprintf("%s.key", req.Arguments[0]), "/")
256+
var fileExtension string
257+
switch exportFormat {
258+
case keyFormatPemEncryptedOption, keyFormatPemCleartextOption:
259+
fileExtension = "pem"
260+
case keyFormatLibp2pCleartextOption:
261+
fileExtension = "key"
262+
}
263+
trimmed := strings.TrimRight(fmt.Sprintf("%s.%s", req.Arguments[0], fileExtension), "/")
213264
_, outPath = filepath.Split(trimmed)
214265
outPath = filepath.Clean(outPath)
215266
}
@@ -221,9 +272,68 @@ path can be specified with '--output=<path>' or '-o=<path>'.
221272
}
222273
defer file.Close()
223274

224-
_, err = io.Copy(file, outReader)
225-
if err != nil {
226-
return err
275+
switch exportFormat {
276+
case keyFormatPemEncryptedOption, keyFormatPemCleartextOption:
277+
privKeyBytes, err := ioutil.ReadAll(outReader)
278+
if err != nil {
279+
return err
280+
}
281+
282+
var pemBlock *pem.Block
283+
if exportFormat == keyFormatPemEncryptedOption {
284+
keyEncPassword, ok := req.Options[keyEncryptionPasswordOptionName].(string)
285+
if !ok {
286+
return fmt.Errorf("missing password to encrypt the key with, set it with --%s",
287+
keyEncryptionPasswordOptionName)
288+
}
289+
// FIXME(BLOCKING): Using deprecated security function.
290+
pemBlock, err = x509.EncryptPEMBlock(rand.Reader,
291+
"ENCRYPTED PRIVATE KEY",
292+
privKeyBytes,
293+
[]byte(keyEncPassword),
294+
x509.PEMCipherAES256)
295+
if err != nil {
296+
return fmt.Errorf("encrypting PEM block: %w", err)
297+
}
298+
} else { // cleartext
299+
pemBlock = &pem.Block{
300+
Type: "PRIVATE KEY",
301+
Bytes: privKeyBytes,
302+
}
303+
}
304+
305+
err = pem.Encode(file, pemBlock)
306+
if err != nil {
307+
return fmt.Errorf("encoding PEM block: %w", err)
308+
}
309+
// FIXME(BLOCKING): At least verify some interoperability
310+
// manually with something like `openssl pkcs8 -in private_key.pem`
311+
// failing with:
312+
// ```
313+
// Error reading key
314+
// error:0D0680A8:asn1 encoding routines:asn1_check_tlen:wrong tag:../crypto/asn1/tasn_dec.c:1130:
315+
// error:0D07803A:asn1 encoding routines:asn1_item_embed_d2i:nested asn1 error:../crypto/asn1/tasn_dec.c:290:Type=X509_ALGOR
316+
// error:0D08303A:asn1 encoding routines:asn1_template_noexp_d2i:nested asn1 error:../crypto/asn1/tasn_dec.c:627:Field=algor, Type=X509_SIG
317+
// error:0906700D:PEM routines:PEM_ASN1_read_bio:ASN1 lib:../crypto/pem/pem_oth.c:33:
318+
// ```
319+
//
320+
// For the moment checked with https://8gwifi.org/PemParserFunctions.jsp,
321+
// which is working for RSA but failing for the ed25519 case with:
322+
// ```
323+
// java.lang.Exception: Error Performing Parsing java.lang.Exception:
324+
// org.bouncycastle.openssl.PEMException:
325+
// unable to convert key pair:
326+
// no such algorithm: 1.3.101.112 for provider BC
327+
// ```
328+
// which is indeed the code per RFC:
329+
// https://datatracker.ietf.org/doc/html/rfc8410#section-3
330+
// (so this is probably a problem of the decoding package of the web page).
331+
332+
case keyFormatLibp2pCleartextOption:
333+
_, err = io.Copy(file, outReader)
334+
if err != nil {
335+
return err
336+
}
227337
}
228338

229339
return nil
@@ -237,6 +347,9 @@ var keyImportCmd = &cmds.Command{
237347
},
238348
Options: []cmds.Option{
239349
ke.OptionIPNSBase,
350+
cmds.StringOption(keyFormatOptionName, "f", "The format of the private key to import.").WithDefault(keyFormatLibp2pCleartextOption),
351+
// FIXME: Attempt to figure out the import format.
352+
cmds.StringOption(keyEncryptionPasswordOptionName, "p", "The password to decrypt the imported key with (for the encrypted variant only)."),
240353
},
241354
Arguments: []cmds.Argument{
242355
cmds.StringArg("name", true, false, "name to associate with key in keychain"),
@@ -265,9 +378,59 @@ var keyImportCmd = &cmds.Command{
265378
return err
266379
}
267380

268-
sk, err := crypto.UnmarshalPrivateKey(data)
269-
if err != nil {
270-
return err
381+
importFormat, _ := req.Options[keyFormatOptionName].(string)
382+
var sk crypto.PrivKey
383+
switch importFormat {
384+
case keyFormatPemEncryptedOption, keyFormatPemCleartextOption:
385+
pemBlock, rest := pem.Decode(data)
386+
if pemBlock == nil {
387+
return fmt.Errorf("PEM block not found in input data:\n%s", rest)
388+
}
389+
390+
if pemBlock.Type != "PRIVATE KEY" && pemBlock.Type != "ENCRYPTED PRIVATE KEY" {
391+
return fmt.Errorf("expected [ENCRYPTED] PRIVATE KEY type in PEM block but got: %s", pemBlock.Type)
392+
}
393+
394+
var privKeyBytes []byte
395+
if importFormat == keyFormatPemEncryptedOption {
396+
keyDecPassword, ok := req.Options[keyEncryptionPasswordOptionName].(string)
397+
if !ok {
398+
return fmt.Errorf("missing password to decrypt the key with, set it with --%s",
399+
keyEncryptionPasswordOptionName)
400+
}
401+
privKeyBytes, err = x509.DecryptPEMBlock(pemBlock,
402+
[]byte(keyDecPassword))
403+
if err != nil {
404+
return fmt.Errorf("decrypting PEM block: %w", err)
405+
}
406+
} else { // cleartext
407+
privKeyBytes = pemBlock.Bytes
408+
}
409+
410+
stdKey, err := x509.ParsePKCS8PrivateKey(privKeyBytes)
411+
if err != nil {
412+
return fmt.Errorf("parsing PKCS8 format: %w", err)
413+
}
414+
415+
// In case ed25519.PrivateKey is returned we need the pointer for
416+
// conversion to libp2p (see export command for more details).
417+
if ed25519KeyPointer, ok := stdKey.(ed25519.PrivateKey); ok {
418+
stdKey = &ed25519KeyPointer
419+
}
420+
421+
sk, _, err = crypto.KeyPairFromStdKey(stdKey)
422+
if err != nil {
423+
return fmt.Errorf("converting std Go key to libp2p key : %w", err)
424+
425+
}
426+
case keyFormatLibp2pCleartextOption:
427+
sk, err = crypto.UnmarshalPrivateKey(data)
428+
if err != nil {
429+
return err
430+
}
431+
432+
default:
433+
return fmt.Errorf("unrecognized import format: %s", importFormat)
271434
}
272435

273436
cfgRoot, err := cmdenv.GetConfigRoot(env)

test/sharness/t0165-keystore.sh

+36-19
Original file line numberDiff line numberDiff line change
@@ -63,24 +63,14 @@ ipfs key rm key_ed25519
6363
echo $rsahash > rsa_key_id
6464
'
6565

66+
test_key_import_export_all_formats rsa_key
67+
6668
test_expect_success "create a new ed25519 key" '
6769
edhash=$(ipfs key gen generated_ed25519_key --type=ed25519)
6870
echo $edhash > ed25519_key_id
6971
'
7072

71-
test_expect_success "export and import rsa key" '
72-
ipfs key export generated_rsa_key &&
73-
ipfs key rm generated_rsa_key &&
74-
ipfs key import generated_rsa_key generated_rsa_key.key > roundtrip_rsa_key_id &&
75-
test_cmp rsa_key_id roundtrip_rsa_key_id
76-
'
77-
78-
test_expect_success "export and import ed25519 key" '
79-
ipfs key export generated_ed25519_key &&
80-
ipfs key rm generated_ed25519_key &&
81-
ipfs key import generated_ed25519_key generated_ed25519_key.key > roundtrip_ed25519_key_id &&
82-
test_cmp ed25519_key_id roundtrip_ed25519_key_id
83-
'
73+
test_key_import_export_all_formats ed25519_key
8474

8575
test_expect_success "test export file option" '
8676
ipfs key export generated_rsa_key -o=named_rsa_export_file &&
@@ -176,15 +166,13 @@ ipfs key rm key_ed25519
176166
'
177167

178168
# export works directly on the keystore present in IPFS_PATH
179-
test_expect_success "export and import ed25519 key while daemon is running" '
180-
edhash=$(ipfs key gen exported_ed25519_key --type=ed25519)
169+
test_expect_success "prepare ed25519 key while daemon is running" '
170+
edhash=$(ipfs key gen generated_ed25519_key --type=ed25519)
181171
echo $edhash > ed25519_key_id
182-
ipfs key export exported_ed25519_key &&
183-
ipfs key rm exported_ed25519_key &&
184-
ipfs key import exported_ed25519_key exported_ed25519_key.key > roundtrip_ed25519_key_id &&
185-
test_cmp ed25519_key_id roundtrip_ed25519_key_id
186172
'
187173

174+
test_key_import_export_all_formats ed25519_key
175+
188176
test_expect_success "key export over HTTP /api/v0/key/export is not possible" '
189177
ipfs key gen nohttpexporttest_key --type=ed25519 &&
190178
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() {
214202
}
215203
}
216204

205+
test_key_import_export_all_formats() {
206+
KEY_NAME=$1
207+
test_key_import_export $KEY_NAME pem-pkcs8-cleartext
208+
test_key_import_export $KEY_NAME pem-pkcs8-encrypted
209+
test_key_import_export $KEY_NAME libp2p-protobuf-cleartext
210+
}
211+
212+
test_key_import_export() {
213+
local KEY_NAME FORMAT
214+
KEY_NAME=$1
215+
FORMAT=$2
216+
ORIG_KEY="generated_$KEY_NAME"
217+
if [ $FORMAT == "pem-pkcs8-encrypted" ]; then
218+
KEY_PASSWORD="--password=fake-test-password"
219+
fi
220+
if [ $FORMAT == "libp2p-protobuf-cleartext" ]; then
221+
FILE_EXT="key"
222+
else
223+
FILE_EXT="pem"
224+
fi
225+
226+
test_expect_success "export and import $KEY_NAME with format $FORMAT" '
227+
ipfs key export $ORIG_KEY --format=$FORMAT $KEY_PASSWORD &&
228+
ipfs key rm $ORIG_KEY &&
229+
ipfs key import $ORIG_KEY $ORIG_KEY.$FILE_EXT --format=$FORMAT $KEY_PASSWORD > imported_key_id &&
230+
test_cmp ${KEY_NAME}_id imported_key_id
231+
'
232+
}
233+
217234
test_key_cmd
218235

219236
test_done

0 commit comments

Comments
 (0)