Skip to content

Commit

Permalink
feat: allow to encrypt pvt key on restore (#11)
Browse files Browse the repository at this point in the history
* feat: allow to encrypt pvt key on restore

Signed-off-by: Carlos A Becker <[email protected]>

* fix: lint

Signed-off-by: Carlos A Becker <[email protected]>

* undo

Signed-off-by: Carlos A Becker <[email protected]>

* fix: dont ask passphrase when seed comes from stdin

* fix: open new tty

Signed-off-by: Carlos A Becker <[email protected]>

* fix: lint issues

* fix: lint issues

* fix: lint

Signed-off-by: Carlos A Becker <[email protected]>

* fix: test on windows
  • Loading branch information
caarlos0 authored Apr 6, 2022
1 parent 42fe36a commit 7381eda
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 61 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ go build ./cmd/melt/
[releases]: https://github.com/charmbracelet/melt/releases


## Usage
## Usage

The CLI usage looks like the following:

Expand Down Expand Up @@ -73,7 +73,7 @@ To restore, we:

- get the __entropy__ from the __mnemonic__
- the __entropy__ is effectively the key __seed__, so we use it to create a SSH key pair
- the key is effectively the same that was backup up, as the key is the same.
- the key is effectively the same that was backed up, as the key is the same.
You can verify the keys by checking the public key fingerprint, which should be
the same in the original and _restored_ key.

Expand Down
101 changes: 81 additions & 20 deletions cmd/melt/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"crypto/ed25519"
"encoding/pem"
"errors"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/melt"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty"
"github.com/muesli/coral"
mcoral "github.com/muesli/mango-coral"
"github.com/muesli/reflow/wordwrap"
Expand All @@ -31,7 +33,7 @@ const (
)

var (
baseStyle = lipgloss.NewStyle().Margin(0, 0, 1, 2)
baseStyle = lipgloss.NewStyle().Margin(0, 0, 1, 2) // nolint: gomnd
violet = lipgloss.Color(completeColor("#6B50FF", "63", "12"))
cmdStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#FF5E8E", Dark: "#FF5E8E"}).
Expand All @@ -40,7 +42,7 @@ var (
mnemonicStyle = baseStyle.Copy().
Foreground(violet).
Background(lipgloss.AdaptiveColor{Light: completeColor("#EEEBFF", "255", "7"), Dark: completeColor("#1B1731", "235", "8")}).
Padding(1, 2)
Padding(1, 2) // nolint: gomnd
keyPathStyle = lipgloss.NewStyle().Foreground(violet)

mnemonic string
Expand Down Expand Up @@ -118,7 +120,7 @@ be used to rebuild your public and private keys.`,
return err
}

if err := restore(maybeFile(mnemonic), args[0]); err != nil {
if err := restore(maybeFile(mnemonic), args[0], askNewPassphrase); err != nil {
return err
}

Expand All @@ -138,6 +140,7 @@ be used to rebuild your public and private keys.`,
RunE: func(cmd *coral.Command, args []string) error {
manPage, err := mcoral.NewManPage(1, rootCmd)
if err != nil {
// nolint: wrapcheck
return err
}
manPage = manPage.WithSection("Copyright", "(C) 2022 Charmbracelet, Inc.\n"+
Expand Down Expand Up @@ -176,60 +179,79 @@ func maybeFile(s string) string {
return string(bts)
}

func backup(path string, pwd []byte) (string, error) {
func parsePrivateKey(bts, pass []byte) (interface{}, error) {
if len(pass) == 0 {
// nolint: wrapcheck
return ssh.ParseRawPrivateKey(bts)
}
// nolint: wrapcheck
return ssh.ParseRawPrivateKeyWithPassphrase(bts, pass)
}

func backup(path string, pass []byte) (string, error) {
bts, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("could not read key: %w", err)
}

var key interface{}
if pwd == nil {
key, err = ssh.ParseRawPrivateKey(bts)
} else {
key, err = ssh.ParseRawPrivateKeyWithPassphrase(bts, pwd)
}
key, err := parsePrivateKey(bts, pass)
if err != nil {
pwderr := &ssh.PassphraseMissingError{}
if errors.As(err, &pwderr) {
fmt.Fprintf(os.Stderr, "Enter the password to decrypt %q: ", path)
pwd, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Printf("\n\n")
pass, err := askKeyPassphrase(path)
if err != nil {
return "", fmt.Errorf("could not read password for key: %w", err)
return "", err
}
return backup(path, pwd)
return backup(path, pass)
}
return "", fmt.Errorf("could not parse key: %w", err)
}

switch key := key.(type) {
case *ed25519.PrivateKey:
// nolint: wrapcheck
return melt.ToMnemonic(key)
default:
return "", fmt.Errorf("unknown key type: %v", key)
}
}

func restore(mnemonic, path string) error {
func marshallPrivateKey(key ed25519.PrivateKey, pass []byte) (*pem.Block, error) {
if len(pass) == 0 {
// nolint: wrapcheck
return sshmarshal.MarshalPrivateKey(key, "")
}
// nolint: wrapcheck
return sshmarshal.MarshalPrivateKeyWithPassphrase(key, "", pass)
}

func restore(mnemonic, path string, passFn func() ([]byte, error)) error {
pvtKey, err := melt.FromMnemonic(mnemonic)
if err != nil {
// nolint: wrapcheck
return err
}

pass, err := passFn()
if err != nil {
return err
}
block, err := sshmarshal.MarshalPrivateKey(pvtKey, "")

block, err := marshallPrivateKey(pvtKey, pass)
if err != nil {
return fmt.Errorf("could not marshal private key: %w", err)
}
bts := pem.EncodeToMemory(block)

pubkey, err := ssh.NewPublicKey(pvtKey.Public())
if err != nil {
return fmt.Errorf("could not prepare public key: %w", err)
}

if err := os.WriteFile(path, bts, 0o600); err != nil {
if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil { // nolint: gomnd
return fmt.Errorf("failed to write private key: %w", err)
}

if err := os.WriteFile(path+".pub", ssh.MarshalAuthorizedKey(pubkey), 0o600); err != nil {
if err := os.WriteFile(path+".pub", ssh.MarshalAuthorizedKey(pubkey), 0o600); err != nil { // nolint: gomnd
return fmt.Errorf("failed to write public key: %w", err)
}
return nil
Expand All @@ -249,6 +271,7 @@ func renderBlock(w io.Writer, s lipgloss.Style, width int, str string) {
}

func completeColor(truecolor, ansi256, ansi string) string {
// nolint: exhaustive
switch lipgloss.ColorProfile() {
case termenv.TrueColor:
return truecolor
Expand Down Expand Up @@ -310,3 +333,41 @@ func getWordlist(language string) []string {
}
return wl
}

func readPassword(msg string) ([]byte, error) {
fmt.Fprint(os.Stderr, msg)
t, err := tty.Open()
if err != nil {
return nil, fmt.Errorf("could not open tty")
}
defer t.Close() // nolint: errcheck
pass, err := term.ReadPassword(int(t.Input().Fd()))
if err != nil {
return nil, fmt.Errorf("could not read passphrase: %w", err)
}
return pass, nil
}

func askKeyPassphrase(path string) ([]byte, error) {
defer fmt.Fprintf(os.Stderr, "\n")
return readPassword(fmt.Sprintf("Enter the passphrase to unlock %q: ", path))
}

func askNewPassphrase() ([]byte, error) {
defer fmt.Fprintf(os.Stderr, "\n")
pass, err := readPassword("Enter passphrase (empty for no passphrase): ")
if err != nil {
return nil, err
}

confirm, err := readPassword("\nEnter same passphrase again: ")
if err != nil {
return nil, fmt.Errorf("could not read password confirmation for key: %w", err)
}

if !bytes.Equal(pass, confirm) {
return nil, fmt.Errorf("Passphareses do not match")
}

return pass, nil
}
103 changes: 64 additions & 39 deletions cmd/melt/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -45,6 +46,9 @@ func TestBackupRestoreKnownKey(t *testing.T) {
})

t.Run("backup key without password", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skipf("it keeps waiting on a tty for the password")
}
_, err := backup("testdata/pwd_id_ed25519", nil)
is := is.New(t)
is.True(err != nil)
Expand All @@ -64,7 +68,7 @@ func TestBackupRestoreKnownKey(t *testing.T) {
t.Run("restore", func(t *testing.T) {
is := is.New(t)
path := filepath.Join(t.TempDir(), "key")
is.NoErr(restore(expectedMnemonic, path))
is.NoErr(restore(expectedMnemonic, path, staticPass(nil)))
is.Equal(expectedSum, sha256sum(t, path+".pub"))

bts, err := os.ReadFile(path)
Expand All @@ -75,45 +79,60 @@ func TestBackupRestoreKnownKey(t *testing.T) {

is.Equal(expectedFingerprint, ssh.FingerprintSHA256(k.PublicKey()))
})

t.Run("restore key with password", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "key")
is := is.New(t)
pass := staticPass([]byte("asd"))
is.NoErr(restore(expectedMnemonic, path, pass))

bts, err := os.ReadFile(path)
is.NoErr(err)

k, err := ssh.ParsePrivateKeyWithPassphrase(bts, []byte("asd"))
is.NoErr(err)

is.Equal(expectedFingerprint, ssh.FingerprintSHA256(k.PublicKey()))
})
}

func TestGetWordlist(t *testing.T) {
for lang, wordlist := range map[string][]string{
"cHinese": wordlists.ChineseSimplified,
"simplified-cHinese": wordlists.ChineseSimplified,
"zH": wordlists.ChineseSimplified,
"zH_haNs": wordlists.ChineseSimplified,
"tradITIONAL-cHinese": wordlists.ChineseTraditional,
"zH_hanT": wordlists.ChineseTraditional,
"cZech": wordlists.Czech,
"cS": wordlists.Czech,
"eN": wordlists.English,
"eN-gb": wordlists.English,
"eNglish": wordlists.English,
"american-eNglish": wordlists.English,
"british-eNglish": wordlists.English,
"fRench": wordlists.French,
"fR": wordlists.French,
"iTaliaN": wordlists.Italian,
"iT": wordlists.Italian,
"jApanesE": wordlists.Japanese,
"jA": wordlists.Japanese,
"kORean": wordlists.Korean,
"kO": wordlists.Korean,
"sPanish": wordlists.Spanish,
"eS": wordlists.Spanish,
"eS-ER": wordlists.Spanish,
"european-spanish": wordlists.Spanish,
"ES": wordlists.Spanish,
"zz": nil,
"sOmething": nil,
} {
t.Run(lang, func(t *testing.T) {
is := is.New(t)
is.Equal(wordlist, getWordlist(lang))
})
}
}
for lang, wordlist := range map[string][]string{
"cHinese": wordlists.ChineseSimplified,
"simplified-cHinese": wordlists.ChineseSimplified,
"zH": wordlists.ChineseSimplified,
"zH_haNs": wordlists.ChineseSimplified,
"tradITIONAL-cHinese": wordlists.ChineseTraditional,
"zH_hanT": wordlists.ChineseTraditional,
"cZech": wordlists.Czech,
"cS": wordlists.Czech,
"eN": wordlists.English,
"eN-gb": wordlists.English,
"eNglish": wordlists.English,
"american-eNglish": wordlists.English,
"british-eNglish": wordlists.English,
"fRench": wordlists.French,
"fR": wordlists.French,
"iTaliaN": wordlists.Italian,
"iT": wordlists.Italian,
"jApanesE": wordlists.Japanese,
"jA": wordlists.Japanese,
"kORean": wordlists.Korean,
"kO": wordlists.Korean,
"sPanish": wordlists.Spanish,
"eS": wordlists.Spanish,
"eS-ER": wordlists.Spanish,
"european-spanish": wordlists.Spanish,
"ES": wordlists.Spanish,
"zz": nil,
"sOmething": nil,
} {
t.Run(lang, func(t *testing.T) {
is := is.New(t)
is.Equal(wordlist, getWordlist(lang))
})
}
}

func TestBackupRestoreKnownKeyInJapanse(t *testing.T) {
const expectedMnemonic = `
Expand Down Expand Up @@ -143,7 +162,7 @@ func TestBackupRestoreKnownKeyInJapanse(t *testing.T) {
t.Run("restore", func(t *testing.T) {
is := is.New(t)
path := filepath.Join(t.TempDir(), "key")
is.NoErr(restore(expectedMnemonic, path))
is.NoErr(restore(expectedMnemonic, path, staticPass(nil)))
is.Equal(expectedSum, sha256sum(t, path+".pub"))

bts, err := os.ReadFile(path)
Expand All @@ -161,7 +180,7 @@ func TestMaybeFile(t *testing.T) {
is := is.New(t)
path := filepath.Join(t.TempDir(), "f")
content := "test content"
is.NoErr(os.WriteFile(path, []byte(content), 0o644))
is.NoErr(os.WriteFile(path, []byte(content), 0o644)) // nolint: gomnd
is.Equal(content, maybeFile(path))
})

Expand Down Expand Up @@ -189,3 +208,9 @@ func sha256sum(tb testing.TB, path string) string {

return hex.EncodeToString(digest.Sum(nil))
}

func staticPass(b []byte) func() ([]byte, error) {
return func() ([]byte, error) {
return b, nil
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/charmbracelet/lipgloss v0.5.0
github.com/matryer/is v1.4.0
github.com/mattn/go-isatty v0.0.14
github.com/mattn/go-tty v0.0.4
github.com/muesli/coral v1.0.0
github.com/muesli/mango-coral v1.0.1
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
Expand Down
Loading

0 comments on commit 7381eda

Please sign in to comment.