diff --git a/CHANGELOG.md b/CHANGELOG.md index f07284f..d9369e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ All notable changes to Lantern. lotus). TRUST-MODEL.md section 2.7 documents what it does and does NOT guarantee. +- **Wallet import/export + import-from-Lotus** ([#93](https://github.com/Reiers/lantern/issues/93)). + `lantern wallet export ` / `wallet import [hex|-]` speak Lotus's + hex-KeyInfo wire format, so keys round-trip between Lotus and Lantern + in either direction (pipe `lotus wallet export` straight in). `lantern + wallet import-lotus ` bulk-imports every `wallet-*` key from a + Lotus repo keystore (base32 filenames, KeyInfo JSON). Everything lands + in Lantern's passphrase-protected keystore; nothing is written to disk + unencrypted. + ### Security / head-path hardening - **Head-source corroboration** ([#80](https://github.com/Reiers/lantern/issues/80) part 2). diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index 2f1ffe9..6831dc4 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -10,6 +10,9 @@ // lantern wallet list // lantern wallet balance // lantern wallet send +// lantern wallet export — Lotus-hex KeyInfo +// lantern wallet import [hex|-] +// lantern wallet import-lotus ~/.lotus // lantern chain head // lantern state get-actor // lantern info — print FULLNODE_API_INFO + status @@ -161,6 +164,9 @@ COMMANDS wallet list wallet balance wallet send (DRY-RUN — message preview) + wallet export (Lotus-hex KeyInfo to stdout) + wallet import [hex|-] (Lotus-hex KeyInfo from arg or stdin) + wallet import-lotus (bulk import from a Lotus repo keystore) chain head state get-actor info Show daemon status + FULLNODE_API_INFO @@ -1307,7 +1313,7 @@ func logSyncStats(ctx context.Context, s *hstore.Sync, d *headnotify.Distributor func cmdWallet(args []string) error { if len(args) == 0 { - return fmt.Errorf("wallet: subcommand required (new|list|balance|send)") + return fmt.Errorf("wallet: subcommand required (new|list|balance|send|export|import|import-lotus)") } sub := args[0] rest := args[1:] @@ -1322,6 +1328,15 @@ func cmdWallet(args []string) error { return walletSend(rest) case "default": return walletDefault(rest) + case "export": + // #93: Lotus-hex KeyInfo to stdout. + return walletExport(rest) + case "import": + // #93: Lotus-hex KeyInfo from arg or stdin. + return walletImport(rest) + case "import-lotus": + // #93: bulk import wallet keys from a Lotus repo keystore. + return walletImportLotus(rest) } return fmt.Errorf("wallet: unknown subcommand %q", sub) } diff --git a/cmd/lantern/wallet_import.go b/cmd/lantern/wallet_import.go new file mode 100644 index 0000000..c8499b1 --- /dev/null +++ b/cmd/lantern/wallet_import.go @@ -0,0 +1,170 @@ +// Wallet import/export commands (#93). +// +// `lantern wallet export ` - Lotus-hex KeyInfo to stdout +// `lantern wallet import [hex|-]` - Lotus-hex KeyInfo from arg/stdin +// `lantern wallet import-lotus ` - bulk import from a Lotus repo +// +// Lotus's on-disk keystore is `/keystore/` with +// RawStdEncoding filenames; wallet keys are named `wallet-` and the +// file content is the KeyInfo JSON ({"Type","PrivateKey"} with base64 +// key bytes). `lotus wallet export` prints hex(JSON(KeyInfo)) - the same +// wire shape both commands here speak, so keys round-trip between Lotus +// and Lantern in either direction. +// +// Everything imported lands in Lantern's passphrase-protected keystore +// (the Envelope encryption the wallet already uses); nothing is ever +// written to disk unencrypted. + +package main + +import ( + "bufio" + "context" + "encoding/hex" + "encoding/json" + "fmt" + base32 "github.com/multiformats/go-base32" + "os" + "path/filepath" + "strings" + + faddr "github.com/filecoin-project/go-address" + + "github.com/Reiers/lantern/wallet" +) + +// walletExport prints the Lotus-compatible hex KeyInfo for an address. +func walletExport(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: lantern wallet export
") + } + w, err := openWalletDefault() + if err != nil { + return err + } + addr, err := faddr.NewFromString(args[0]) + if err != nil { + return fmt.Errorf("parse address: %w", err) + } + ki, err := w.Export(context.Background(), addr) + if err != nil { + return fmt.Errorf("export %s: %w", addr, err) + } + blob, err := json.Marshal(ki) + if err != nil { + return err + } + fmt.Println(hex.EncodeToString(blob)) + return nil +} + +// walletImport imports one Lotus-hex KeyInfo from the argument or stdin. +func walletImport(args []string) error { + var raw string + switch { + case len(args) == 1 && args[0] != "-": + raw = args[0] + default: + // Read one line from stdin (piped `lotus wallet export` output). + fmt.Fprintln(os.Stderr, "reading hex KeyInfo from stdin...") + sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 1<<20), 1<<20) + if !sc.Scan() { + return fmt.Errorf("no input on stdin") + } + raw = sc.Text() + } + raw = strings.TrimSpace(raw) + blob, err := hex.DecodeString(raw) + if err != nil { + return fmt.Errorf("hex-decode KeyInfo: %w", err) + } + var ki wallet.KeyInfo + if err := json.Unmarshal(blob, &ki); err != nil { + return fmt.Errorf("parse KeyInfo JSON: %w", err) + } + w, err := openWalletDefault() + if err != nil { + return err + } + addr, err := w.Import(context.Background(), &ki) + if err != nil { + return fmt.Errorf("import: %w", err) + } + fmt.Println(addr.String()) + return nil +} + +// walletImportLotus bulk-imports wallet keys from a Lotus repo keystore. +func walletImportLotus(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: lantern wallet import-lotus (e.g. ~/.lotus)") + } + repo := expandHome(args[0]) + ksDir := filepath.Join(repo, "keystore") + if st, err := os.Stat(ksDir); err != nil || !st.IsDir() { + return fmt.Errorf("%s is not a Lotus repo (no keystore/ dir)", repo) + } + + entries, err := os.ReadDir(ksDir) + if err != nil { + return fmt.Errorf("read keystore dir: %w", err) + } + + w, err := openWalletDefault() + if err != nil { + return err + } + + imported, skipped := 0, 0 + for _, e := range entries { + if e.IsDir() { + continue + } + // Lotus encodes keystore entry names with base32 RawStdEncoding. + nameBytes, err := base32.RawStdEncoding.DecodeString(e.Name()) + if err != nil { + skipped++ + continue // not a Lotus keystore entry + } + name := string(nameBytes) + if !strings.HasPrefix(name, "wallet-") { + continue // libp2p host key, jwt secret, etc: not a wallet + } + blob, err := os.ReadFile(filepath.Join(ksDir, e.Name())) + if err != nil { + fmt.Fprintf(os.Stderr, " skip %s: %v\n", name, err) + skipped++ + continue + } + var ki wallet.KeyInfo + if err := json.Unmarshal(blob, &ki); err != nil { + fmt.Fprintf(os.Stderr, " skip %s: parse KeyInfo: %v\n", name, err) + skipped++ + continue + } + addr, err := w.Import(context.Background(), &ki) + if err != nil { + fmt.Fprintf(os.Stderr, " skip %s: import: %v\n", name, err) + skipped++ + continue + } + fmt.Printf(" imported %s (%s)\n", addr.String(), ki.Type) + imported++ + } + fmt.Printf("done: %d imported, %d skipped (keys are stored encrypted in Lantern's keystore)\n", imported, skipped) + if imported == 0 && skipped == 0 { + fmt.Println("no wallet-* entries found in the Lotus keystore") + } + return nil +} + +// expandHome expands a leading ~/ to the user home directory. +func expandHome(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/")) + } + } + return p +} diff --git a/cmd/lantern/wallet_import_test.go b/cmd/lantern/wallet_import_test.go new file mode 100644 index 0000000..08e4f37 --- /dev/null +++ b/cmd/lantern/wallet_import_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + base32 "github.com/multiformats/go-base32" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Reiers/lantern/wallet" +) + +// tWallet opens a throwaway passphrase-protected wallet in a temp dir. +func tWallet(t *testing.T) *wallet.Wallet { + t.Helper() + dir := t.TempDir() + w, err := wallet.New(context.Background(), filepath.Join(dir, "keystore"), "test-passphrase") + require.NoError(t, err) + return w +} + +// writeLotusKeystoreEntry writes a Lotus-shaped keystore file: +// filename = base32.RawStdEncoding("wallet-"), content = KeyInfo JSON. +func writeLotusKeystoreEntry(t *testing.T, ksDir, name string, ki *wallet.KeyInfo) { + t.Helper() + blob, err := json.Marshal(ki) + require.NoError(t, err) + fn := base32.RawStdEncoding.EncodeToString([]byte(name)) + require.NoError(t, os.WriteFile(filepath.Join(ksDir, fn), blob, 0o600)) +} + +// TestImportLotusKeystoreRoundTrip: a key exported from a Lantern wallet, +// laid out as a Lotus repo keystore, imports back with the same address. +// Covers base32 filename decoding, wallet-* filtering, and KeyInfo JSON. +func TestImportLotusKeystoreRoundTrip(t *testing.T) { + ctx := context.Background() + + // Source wallet: make a couple of keys, export their KeyInfo. + src := tWallet(t) + addrBLS, err := src.NewAddress(ctx, wallet.KTBLS) + require.NoError(t, err) + addrSecp, err := src.NewAddress(ctx, wallet.KTSecp256k1) + require.NoError(t, err) + kiBLS, err := src.Export(ctx, addrBLS) + require.NoError(t, err) + kiSecp, err := src.Export(ctx, addrSecp) + require.NoError(t, err) + + // Fake Lotus repo. + repo := t.TempDir() + ksDir := filepath.Join(repo, "keystore") + require.NoError(t, os.MkdirAll(ksDir, 0o700)) + writeLotusKeystoreEntry(t, ksDir, "wallet-"+addrBLS.String(), kiBLS) + writeLotusKeystoreEntry(t, ksDir, "wallet-"+addrSecp.String(), kiSecp) + // Non-wallet entries that must be ignored: libp2p host key + garbage. + writeLotusKeystoreEntry(t, ksDir, "libp2p-host", &wallet.KeyInfo{Type: "libp2p-host", PrivateKey: []byte("x")}) + require.NoError(t, os.WriteFile(filepath.Join(ksDir, "not-base32-!!!"), []byte("junk"), 0o600)) + + // Destination wallet: import the repo via the same walk the command + // uses (exercise the internals directly; the CLI wrapper only adds + // openWalletDefault + printing). + dst := tWallet(t) + imported := 0 + entries, err := os.ReadDir(ksDir) + require.NoError(t, err) + for _, e := range entries { + nameBytes, err := base32.RawStdEncoding.DecodeString(e.Name()) + if err != nil { + continue + } + if len(nameBytes) < 7 || string(nameBytes[:7]) != "wallet-" { + continue + } + blob, err := os.ReadFile(filepath.Join(ksDir, e.Name())) + require.NoError(t, err) + var ki wallet.KeyInfo + require.NoError(t, json.Unmarshal(blob, &ki)) + addr, err := dst.Import(ctx, &ki) + require.NoError(t, err) + require.Contains(t, []string{addrBLS.String(), addrSecp.String()}, addr.String(), + "import must derive the same address the key had in the source wallet") + imported++ + } + require.Equal(t, 2, imported, "exactly the two wallet-* entries import") + + // The imported keys must be able to sign. + _, err = dst.Sign(ctx, addrBLS, []byte("msg")) + require.NoError(t, err) +} + +// TestWalletHexKeyInfoRoundTrip: the `wallet export` wire format +// (hex of KeyInfo JSON) parses back to the same key. +func TestWalletHexKeyInfoRoundTrip(t *testing.T) { + ctx := context.Background() + src := tWallet(t) + addr, err := src.NewAddress(ctx, wallet.KTSecp256k1) + require.NoError(t, err) + ki, err := src.Export(ctx, addr) + require.NoError(t, err) + + blob, err := json.Marshal(ki) + require.NoError(t, err) + wire := hex.EncodeToString(blob) + + back, err := hex.DecodeString(wire) + require.NoError(t, err) + var ki2 wallet.KeyInfo + require.NoError(t, json.Unmarshal(back, &ki2)) + + dst := tWallet(t) + addr2, err := dst.Import(ctx, &ki2) + require.NoError(t, err) + require.Equal(t, addr.String(), addr2.String()) +} + +// TestExpandHome covers the ~ expansion helper. +func TestExpandHome(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + require.Equal(t, filepath.Join(home, ".lotus"), expandHome("~/.lotus")) + require.Equal(t, home, expandHome("~")) + require.Equal(t, "/abs/path", expandHome("/abs/path")) +} diff --git a/go.mod b/go.mod index d37c6cb..6ed8f40 100644 --- a/go.mod +++ b/go.mod @@ -34,8 +34,10 @@ require ( github.com/libp2p/go-libp2p v0.48.0 github.com/libp2p/go-libp2p-kad-dht v0.40.0 github.com/libp2p/go-libp2p-pubsub v0.15.0 + github.com/multiformats/go-base32 v0.1.0 github.com/multiformats/go-multiaddr v0.16.1 github.com/multiformats/go-multihash v0.2.3 + github.com/rvagg/go-skellam-pmf v0.0.2 github.com/stretchr/testify v1.11.1 github.com/whyrusleeping/cbor-gen v0.3.1 go.uber.org/zap v1.28.0 @@ -107,7 +109,6 @@ require ( github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.3.0 // indirect - github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multiaddr-dns v0.5.0 // indirect github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect @@ -146,7 +147,6 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect - github.com/rvagg/go-skellam-pmf v0.0.2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect github.com/wlynxg/anet v0.0.5 // indirect