Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <addr>` / `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 <repo>` 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).
Expand Down
17 changes: 16 additions & 1 deletion cmd/lantern/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// lantern wallet list
// lantern wallet balance <addr>
// lantern wallet send <to> <amount>
// lantern wallet export <addr> — Lotus-hex KeyInfo
// lantern wallet import [hex|-]
// lantern wallet import-lotus ~/.lotus
// lantern chain head
// lantern state get-actor <addr>
// lantern info — print FULLNODE_API_INFO + status
Expand Down Expand Up @@ -161,6 +164,9 @@ COMMANDS
wallet list
wallet balance <addr>
wallet send <to> <amount-FIL> (DRY-RUN — message preview)
wallet export <addr> (Lotus-hex KeyInfo to stdout)
wallet import [hex|-] (Lotus-hex KeyInfo from arg or stdin)
wallet import-lotus <repo-path> (bulk import from a Lotus repo keystore)
chain head
state get-actor <addr>
info Show daemon status + FULLNODE_API_INFO
Expand Down Expand Up @@ -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:]
Expand All @@ -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)
}
Expand Down
170 changes: 170 additions & 0 deletions cmd/lantern/wallet_import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Wallet import/export commands (#93).
//
// `lantern wallet export <addr>` - Lotus-hex KeyInfo to stdout
// `lantern wallet import [hex|-]` - Lotus-hex KeyInfo from arg/stdin
// `lantern wallet import-lotus <dir>` - bulk import from a Lotus repo
//
// Lotus's on-disk keystore is `<repo>/keystore/<base32(name)>` with
// RawStdEncoding filenames; wallet keys are named `wallet-<addr>` 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 <address>")
}
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 <lotus-repo-path> (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
}
127 changes: 127 additions & 0 deletions cmd/lantern/wallet_import_test.go
Original file line number Diff line number Diff line change
@@ -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-<addr>"), 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"))
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading