diff --git a/cmd/lncli/cmd_macaroon.go b/cmd/lncli/cmd_macaroon.go index 9006cc46..683b2ba6 100644 --- a/cmd/lncli/cmd_macaroon.go +++ b/cmd/lncli/cmd_macaroon.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/golang/protobuf/proto" + "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/macaroons" "github.com/urfave/cli" @@ -93,7 +94,7 @@ func bakeMacaroon(ctx *cli.Context) error { ) if ctx.String("save_to") != "" { - savePath = cleanAndExpandPath(ctx.String("save_to")) + savePath = lncfg.CleanAndExpandPath(ctx.String("save_to")) } if ctx.IsSet("timeout") { @@ -349,7 +350,7 @@ func printMacaroon(ctx *cli.Context) error { ) switch { case ctx.IsSet("macaroon_file"): - macPath := cleanAndExpandPath(ctx.String("macaroon_file")) + macPath := lncfg.CleanAndExpandPath(ctx.String("macaroon_file")) // Load the specified macaroon file. macBytes, err = ioutil.ReadFile(macPath) diff --git a/cmd/lncli/cmd_profile.go b/cmd/lncli/cmd_profile.go new file mode 100644 index 00000000..e646635c --- /dev/null +++ b/cmd/lncli/cmd_profile.go @@ -0,0 +1,449 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path" + "strings" + + "github.com/btcsuite/btcutil" + "github.com/lightningnetwork/lnd/lncfg" + "github.com/urfave/cli" + "gopkg.in/macaroon.v2" +) + +var ( + // defaultLncliDir is the default directory to store the profile file + // in. This defaults to: + // C:\Users\\AppData\Local\Lncli\ on Windows + // ~/.lncli/ on Linux + // ~/Library/Application Support/Lncli/ on MacOS + defaultLncliDir = btcutil.AppDataDir("lncli", false) + + // defaultProfileFile is the full, absolute path of the profile file. + defaultProfileFile = path.Join(defaultLncliDir, "profiles.json") +) + +var profileSubCommand = cli.Command{ + Name: "profile", + Category: "Profiles", + Usage: "Create and manage lncli profiles", + Description: ` + Profiles for lncli are an easy and comfortable way to manage multiple + nodes from the command line by storing node specific parameters like RPC + host, network, TLS certificate path or macaroons in a named profile. + + To use a predefined profile, just use the '--profile=myprofile' (or + short version '-p=myprofile') with any lncli command. + + A default profile can also be defined, lncli will then always use the + connection/node parameters from that profile instead of the default + values. + + WARNING: Setting a default profile changes the default behavior of + lncli! To disable the use of the default profile for a single command, + set '--profile= '. + + The profiles are stored in a file called profiles.json in the user's + home directory, for example: + C:\Users\\AppData\Local\Lncli\profiles.json on Windows + ~/.lncli/profiles.json on Linux + ~/Library/Application Support/Lncli/profiles.json on MacOS + `, + Subcommands: []cli.Command{ + profileListCommand, + profileAddCommand, + profileRemoveCommand, + profileSetDefaultCommand, + profileUnsetDefaultCommand, + profileAddMacaroonCommand, + }, +} + +var profileListCommand = cli.Command{ + Name: "list", + Usage: "Lists all lncli profiles", + Action: profileList, +} + +func profileList(_ *cli.Context) error { + f, err := loadProfileFile(defaultProfileFile) + if err != nil { + return err + } + + printJSON(f) + return nil +} + +var profileAddCommand = cli.Command{ + Name: "add", + Usage: "Add a new profile", + ArgsUsage: "name", + Description: ` + Add a new named profile to the main profiles.json. All global options + (see 'lncli --help') passed into this command are stored in that named + profile. + `, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "name", + Usage: "the name of the new profile", + }, + cli.BoolFlag{ + Name: "default", + Usage: "set the new profile to be the default profile", + }, + }, + Action: profileAdd, +} + +func profileAdd(ctx *cli.Context) error { + if ctx.NArg() == 0 && ctx.NumFlags() == 0 { + return cli.ShowCommandHelp(ctx, "add") + } + + // Load the default profile file or create a new one if it doesn't exist + // yet. + f, err := loadProfileFile(defaultProfileFile) + switch { + case err == errNoProfileFile: + f = &profileFile{} + _ = os.MkdirAll(path.Dir(defaultProfileFile), 0700) + + case err != nil: + return err + } + + // Create a profile struct from all the global options. + profile, err := profileFromContext(ctx, true) + if err != nil { + return fmt.Errorf("could not load global options: %v", err) + } + + // Finally, all that's left is to get the profile name from either + // positional argument or flag. + args := ctx.Args() + switch { + case ctx.IsSet("name"): + profile.Name = ctx.String("name") + case args.Present(): + profile.Name = args.First() + default: + return fmt.Errorf("name argument missing") + } + + // Is there already a profile with that name? + for _, p := range f.Profiles { + if p.Name == profile.Name { + return fmt.Errorf("a profile with the name %s already "+ + "exists", profile.Name) + } + } + + // Do we need to update the default entry to be this one? + if ctx.Bool("default") { + f.Default = profile.Name + } + + // All done, store the updated profile file. + f.Profiles = append(f.Profiles, profile) + if err = saveProfileFile(defaultProfileFile, f); err != nil { + return fmt.Errorf("error writing profile file %s: %v", + defaultProfileFile, err) + } + + fmt.Printf("Profile %s added to file %s.\n", profile.Name, + defaultProfileFile) + return nil +} + +var profileRemoveCommand = cli.Command{ + Name: "remove", + Usage: "Remove a profile", + ArgsUsage: "name", + Description: `Remove the specified profile from the profile file.`, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "name", + Usage: "the name of the profile to delete", + }, + }, + Action: profileRemove, +} + +func profileRemove(ctx *cli.Context) error { + if ctx.NArg() == 0 && ctx.NumFlags() == 0 { + return cli.ShowCommandHelp(ctx, "remove") + } + + // Load the default profile file. + f, err := loadProfileFile(defaultProfileFile) + if err != nil { + return fmt.Errorf("could not load profile file: %v", err) + } + + // Get the profile name from either positional argument or flag. + var ( + args = ctx.Args() + name string + found = false + ) + switch { + case ctx.IsSet("name"): + name = ctx.String("name") + case args.Present(): + name = args.First() + default: + return fmt.Errorf("name argument missing") + } + + // Create a copy of all profiles but don't include the one to delete. + newProfiles := make([]*profileEntry, 0, len(f.Profiles)-1) + for _, p := range f.Profiles { + // Skip the one we want to delete. + if p.Name == name { + found = true + + if p.Name == f.Default { + fmt.Println("Warning: removing default profile.") + } + continue + } + + // Keep all others. + newProfiles = append(newProfiles, p) + } + + // If what we were looking for didn't exist in the first place, there's + // no need for updating the file. + if !found { + return fmt.Errorf("profile with name %s not found in file", + name) + } + + // Great, everything updated, now let's save the file. + f.Profiles = newProfiles + return saveProfileFile(defaultProfileFile, f) +} + +var profileSetDefaultCommand = cli.Command{ + Name: "setdefault", + Usage: "Set the default profile", + ArgsUsage: "name", + Description: ` + Set a specified profile to be used as the default profile. + + WARNING: Setting a default profile changes the default behavior of + lncli! To disable the use of the default profile for a single command, + set '--profile= '. + `, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "name", + Usage: "the name of the profile to set as default", + }, + }, + Action: profileSetDefault, +} + +func profileSetDefault(ctx *cli.Context) error { + if ctx.NArg() == 0 && ctx.NumFlags() == 0 { + return cli.ShowCommandHelp(ctx, "setdefault") + } + + // Load the default profile file. + f, err := loadProfileFile(defaultProfileFile) + if err != nil { + return fmt.Errorf("could not load profile file: %v", err) + } + + // Get the profile name from either positional argument or flag. + var ( + args = ctx.Args() + name string + found = false + ) + switch { + case ctx.IsSet("name"): + name = ctx.String("name") + case args.Present(): + name = args.First() + default: + return fmt.Errorf("name argument missing") + } + + // Make sure the new default profile actually exists. + for _, p := range f.Profiles { + if p.Name == name { + found = true + f.Default = p.Name + + break + } + } + + // If the default profile doesn't exist, there's no need for updating + // the file. + if !found { + return fmt.Errorf("profile with name %s not found in file", + name) + } + + // Great, everything updated, now let's save the file. + return saveProfileFile(defaultProfileFile, f) +} + +var profileUnsetDefaultCommand = cli.Command{ + Name: "unsetdefault", + Usage: "Unsets the default profile", + Description: ` + Disables the use of a default profile and restores lncli to its original + behavior. + `, + Action: profileUnsetDefault, +} + +func profileUnsetDefault(_ *cli.Context) error { + // Load the default profile file. + f, err := loadProfileFile(defaultProfileFile) + if err != nil { + return fmt.Errorf("could not load profile file: %v", err) + } + + // Save the file with the flag disabled. + f.Default = "" + return saveProfileFile(defaultProfileFile, f) +} + +var profileAddMacaroonCommand = cli.Command{ + Name: "addmacaroon", + Usage: "Add a macaroon to a profile's macaroon jar", + ArgsUsage: "macaroon-name", + Description: ` + Add an additional macaroon specified by the global option --macaroonpath + to an existing profile's macaroon jar. + + If no profile is selected, the macaroon is added to the default profile + (if one exists). To add a macaroon to a specific profile, use the global + --profile=myprofile option. + + If multiple macaroons exist in a profile's macaroon jar, the one to use + can be specified with the global option --macfromjar=xyz. + `, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "name", + Usage: "the name of the macaroon", + }, + cli.BoolFlag{ + Name: "default", + Usage: "set the new macaroon to be the default " + + "macaroon in the jar", + }, + }, + Action: profileAddMacaroon, +} + +func profileAddMacaroon(ctx *cli.Context) error { + if ctx.NArg() == 0 && ctx.NumFlags() == 0 { + return cli.ShowCommandHelp(ctx, "addmacaroon") + } + + // Load the default profile file or create a new one if it doesn't exist + // yet. + f, err := loadProfileFile(defaultProfileFile) + if err != nil { + return fmt.Errorf("could not load profile file: %v", err) + } + + // Finally, all that's left is to get the profile name from either + // positional argument or flag. + var ( + args = ctx.Args() + profileName string + macName string + ) + switch { + case ctx.IsSet("name"): + macName = ctx.String("name") + case args.Present(): + macName = args.First() + default: + return fmt.Errorf("name argument missing") + } + + // Make sure the user actually set a macaroon path to use. + if !ctx.GlobalIsSet("macaroonpath") { + return fmt.Errorf("macaroonpath global option missing") + } + + // Find out which profile we should add the macaroon. The global flag + // takes precedence over the default profile. + if f.Default != "" { + profileName = f.Default + } + if ctx.GlobalIsSet("profile") { + profileName = ctx.GlobalString("profile") + } + if len(strings.TrimSpace(profileName)) == 0 { + return fmt.Errorf("no profile specified and no default " + + "profile exists") + } + + // Is there a profile with that name? + var selectedProfile *profileEntry + for _, p := range f.Profiles { + if p.Name == profileName { + selectedProfile = p + break + } + } + if selectedProfile == nil { + return fmt.Errorf("profile with name %s not found", profileName) + } + + // Does a macaroon with that name already exist? + for _, m := range selectedProfile.Macaroons.Jar { + if m.Name == macName { + return fmt.Errorf("a macaroon with the name %s "+ + "already exists", macName) + } + } + + // Do we need to update the default entry to be this one? + if ctx.Bool("default") { + selectedProfile.Macaroons.Default = macName + } + + // Now load and possibly encrypt the macaroon file. + macPath := lncfg.CleanAndExpandPath(ctx.GlobalString("macaroonpath")) + macBytes, err := ioutil.ReadFile(macPath) + if err != nil { + return fmt.Errorf("unable to read macaroon path: %v", err) + } + mac := &macaroon.Macaroon{} + if err = mac.UnmarshalBinary(macBytes); err != nil { + return fmt.Errorf("unable to decode macaroon: %v", err) + } + macEntry := &macaroonEntry{ + Name: macName, + } + if err = macEntry.storeMacaroon(mac, nil); err != nil { + return fmt.Errorf("unable to store macaroon: %v", err) + } + + // All done, store the updated profile file. + selectedProfile.Macaroons.Jar = append( + selectedProfile.Macaroons.Jar, macEntry, + ) + if err = saveProfileFile(defaultProfileFile, f); err != nil { + return fmt.Errorf("error writing profile file %s: %v", + defaultProfileFile, err) + } + + fmt.Printf("Macaroon %s added to profile %s in file %s.\n", macName, + selectedProfile.Name, defaultProfileFile) + return nil +} diff --git a/cmd/lncli/commands.go b/cmd/lncli/commands.go index 8d6dc6e7..4a19b908 100644 --- a/cmd/lncli/commands.go +++ b/cmd/lncli/commands.go @@ -14,7 +14,6 @@ import ( "strconv" "strings" "sync" - "syscall" "time" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -26,7 +25,6 @@ import ( "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/walletunlocker" "github.com/urfave/cli" - "golang.org/x/crypto/ssh/terminal" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -1340,15 +1338,13 @@ mnemonicCheck: // Additionally, the user may have a passphrase, that will also // need to be provided so the daemon can properly decipher the // cipher seed. - fmt.Printf("Input your cipher seed passphrase (press enter if " + - "your seed doesn't have a passphrase): ") - passphrase, err := terminal.ReadPassword(int(syscall.Stdin)) + aezeedPass, err = readPassword("Input your cipher seed " + + "passphrase (press enter if your seed doesn't have a " + + "passphrase): ") if err != nil { return err } - aezeedPass = []byte(passphrase) - for { fmt.Println() fmt.Printf("Input an optional address look-ahead "+ @@ -1460,12 +1456,10 @@ func capturePassword(instruction string, optional bool, validate func([]byte) error) ([]byte, error) { for { - fmt.Printf(instruction) - password, err := terminal.ReadPassword(int(syscall.Stdin)) + password, err := readPassword(instruction) if err != nil { return nil, err } - fmt.Println() // Do not require users to repeat password if // it is optional and they are not using one. @@ -1481,21 +1475,16 @@ func capturePassword(instruction string, optional bool, continue } - fmt.Printf("Confirm password: ") - passwordConfirmed, err := terminal.ReadPassword( - int(syscall.Stdin), - ) + passwordConfirmed, err := readPassword("Confirm password: ") if err != nil { return nil, err } - fmt.Println() if bytes.Equal(password, passwordConfirmed) { return password, nil } - fmt.Println("Passwords don't match, " + - "please try again") + fmt.Println("Passwords don't match, please try again") fmt.Println() } } @@ -1558,13 +1547,7 @@ func unlock(ctx *cli.Context) error { // terminal to be a real tty and will fail if a string is piped into // lncli. default: - fmt.Printf("Input wallet password: ") - - // The variable syscall.Stdin is of a different type in the - // Windows API that's why we need the explicit cast. And of - // course the linter doesn't like it either. - pw, err = terminal.ReadPassword(int(syscall.Stdin)) // nolint:unconvert - fmt.Println() + pw, err = readPassword("Input wallet password: ") } if err != nil { return err @@ -1625,26 +1608,20 @@ func changePassword(ctx *cli.Context) error { client, cleanUp := getWalletUnlockerClient(ctx) defer cleanUp() - fmt.Printf("Input current wallet password: ") - currentPw, err := terminal.ReadPassword(int(syscall.Stdin)) + currentPw, err := readPassword("Input current wallet password: ") if err != nil { return err } - fmt.Println() - fmt.Printf("Input new wallet password: ") - newPw, err := terminal.ReadPassword(int(syscall.Stdin)) + newPw, err := readPassword("Input new wallet password: ") if err != nil { return err } - fmt.Println() - fmt.Printf("Confirm new wallet password: ") - confirmPw, err := terminal.ReadPassword(int(syscall.Stdin)) + confirmPw, err := readPassword("Confirm new wallet password: ") if err != nil { return err } - fmt.Println() if !bytes.Equal(newPw, confirmPw) { return fmt.Errorf("passwords don't match") diff --git a/cmd/lncli/macaroon_jar.go b/cmd/lncli/macaroon_jar.go new file mode 100644 index 00000000..29b36490 --- /dev/null +++ b/cmd/lncli/macaroon_jar.go @@ -0,0 +1,162 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/btcsuite/btcwallet/snacl" + "gopkg.in/macaroon.v2" +) + +const ( + encryptionPrefix = "snacl:" +) + +// getPasswordFn is a function that asks the user to type a password after +// presenting it the given prompt. +type getPasswordFn func(prompt string) ([]byte, error) + +// macaroonJar is a struct that represents all macaroons of a profile. +type macaroonJar struct { + Default string `json:"default,omitempty"` + Timeout int64 `json:"timeout,omitempty"` + IP string `json:"ip,omitempty"` + Jar []*macaroonEntry `json:"jar"` +} + +// macaroonEntry is a struct that represents a single macaroon. Its content can +// either be cleartext (hex encoded) or encrypted (snacl secretbox). +type macaroonEntry struct { + Name string `json:"name"` + Data string `json:"data"` +} + +// loadMacaroon returns the fully usable macaroon instance from the entry. This +// detects whether the macaroon needs to be decrypted and does so if necessary. +// An encrypted macaroon that needs to be decrypted will prompt for the user's +// password by calling the provided password callback. Normally that should +// result in the user being prompted for the password in the terminal. +func (e *macaroonEntry) loadMacaroon( + pwCallback getPasswordFn) (*macaroon.Macaroon, error) { + + if len(strings.TrimSpace(e.Data)) == 0 { + return nil, fmt.Errorf("macaroon data is empty") + } + + var ( + macBytes []byte + err error + ) + + // Either decrypt or simply decode the macaroon data. + if strings.HasPrefix(e.Data, encryptionPrefix) { + parts := strings.Split(e.Data, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid encrypted macaroon " + + "format, expected 'snacl::" + + "'") + } + + pw, err := pwCallback("Enter macaroon encryption password: ") + if err != nil { + return nil, fmt.Errorf("could not read password from "+ + "terminal: %v", err) + } + + macBytes, err = decryptMacaroon(parts[1], parts[2], pw) + if err != nil { + return nil, fmt.Errorf("unable to decrypt macaroon: %v", + err) + } + } else { + macBytes, err = hex.DecodeString(e.Data) + if err != nil { + return nil, fmt.Errorf("unable to hex decode "+ + "macaroon: %v", err) + } + } + + // Parse the macaroon data into its native struct. + mac := &macaroon.Macaroon{} + if err := mac.UnmarshalBinary(macBytes); err != nil { + return nil, fmt.Errorf("unable to decode macaroon: %v", err) + } + return mac, nil +} + +// storeMacaroon stores a native macaroon instance to the entry. If a non-nil +// password is provided, then the macaroon is encrypted with that password. If +// not, the macaroon is stored as plain text. +func (e *macaroonEntry) storeMacaroon(mac *macaroon.Macaroon, pw []byte) error { + // First of all, make sure we can serialize the macaroon. + macBytes, err := mac.MarshalBinary() + if err != nil { + return fmt.Errorf("unable to marshal macaroon: %v", err) + } + + if len(pw) == 0 { + e.Data = hex.EncodeToString(macBytes) + return nil + } + + // The user did set a password. Let's derive an encryption key from it. + key, err := snacl.NewSecretKey( + &pw, snacl.DefaultN, snacl.DefaultR, snacl.DefaultP, + ) + if err != nil { + return fmt.Errorf("unable to create encryption key: %v", err) + } + + // Encrypt the macaroon data with the derived key and store it in the + // human readable format snacl::. + encryptedMac, err := key.Encrypt(macBytes) + if err != nil { + return fmt.Errorf("unable to encrypt macaroon: %v", err) + } + + keyB64 := base64.StdEncoding.EncodeToString(key.Marshal()) + dataB64 := base64.StdEncoding.EncodeToString(encryptedMac) + e.Data = fmt.Sprintf("%s%s:%s", encryptionPrefix, keyB64, dataB64) + + return nil +} + +// decryptMacaroon decrypts the cipher text macaroon by using the serialized +// encryption key and the password. +func decryptMacaroon(keyB64, dataB64 string, pw []byte) ([]byte, error) { + // Base64 decode both the marshalled encryption key and macaroon data. + keyData, err := base64.StdEncoding.DecodeString(keyB64) + if err != nil { + return nil, fmt.Errorf("could not base64 decode encryption "+ + "key: %v", err) + } + encryptedMac, err := base64.StdEncoding.DecodeString(dataB64) + if err != nil { + return nil, fmt.Errorf("could not base64 decode macaroon "+ + "data: %v", err) + } + + // Unmarshal the encryption key and ask the user for the password. + key := &snacl.SecretKey{} + err = key.Unmarshal(keyData) + if err != nil { + return nil, fmt.Errorf("could not unmarshal encryption key: %v", + err) + } + + // Derive the final encryption key and then decrypt the macaroon with + // it. + err = key.DeriveKey(&pw) + if err != nil { + return nil, fmt.Errorf("could not derive encryption key, "+ + "possibly due to incorrect password: %v", err) + } + macBytes, err := key.Decrypt(encryptedMac) + if err != nil { + return nil, fmt.Errorf("could not decrypt macaroon data: %v", + err) + } + return macBytes, nil +} diff --git a/cmd/lncli/macaroon_jar_test.go b/cmd/lncli/macaroon_jar_test.go new file mode 100644 index 00000000..8e1d1c6b --- /dev/null +++ b/cmd/lncli/macaroon_jar_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/macaroon.v2" +) + +var ( + dummyMacStr = "0201047465737402067788991234560000062052d26ed139ea5af8" + + "3e675500c4ccb2471f62191b745bab820f129e5588a255d2" + dummyMac, _ = hex.DecodeString(dummyMacStr) + encryptedEntry = &macaroonEntry{ + Name: "encryptedMac", + Data: "snacl:exX8xbUOb6Gih88ybL2jZGo+DBDPU2tYKkvo0eVVmbDGDoFP" + + "zlv5xvqNK5eml0LKLcB8LdZRw43qXK1W2OLs/gBAAAAAAAAACAAA" + + "AAAAAAABAAAAAAAAAA==:C8TN/aDOvSLiBCX+IdoPTx+UUWhVdGj" + + "NQvbcaWp+KXQWqPfpRZpjJQ6B2PDx5mJxImcezJGPx8ShAqMdxWe" + + "l2precU+1cOjk7HQFkYuu943eJ00s6JerAY+ssg==", + } + plaintextEntry = &macaroonEntry{ + Name: "plaintextMac", + Data: dummyMacStr, + } + + testPassword = []byte("S3curePazzw0rd") + pwCallback = func(string) ([]byte, error) { + return testPassword, nil + } + noPwCallback = func(string) ([]byte, error) { + return nil, nil + } +) + +// TestMacaroonJarEncrypted tests that a macaroon can be stored and retrieved +// safely by encrypting/decrypting it with a password. +func TestMacaroonJarEncrypted(t *testing.T) { + // Create a new macaroon entry from the dummy macaroon and encrypt it + // with the test password. + newEntry := &macaroonEntry{ + Name: "encryptedMac", + } + err := newEntry.storeMacaroon(toMacaroon(t, dummyMac), testPassword) + require.NoError(t, err) + + // Now decrypt it again and make sure we get the same content back. + mac, err := newEntry.loadMacaroon(pwCallback) + require.NoError(t, err) + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + require.Equal(t, dummyMac, macBytes) + + // The encrypted data of the entry we just created shouldn't be the + // same as our test entry because of the salt snacl uses. + require.NotEqual(t, encryptedEntry.Data, newEntry.Data) + + // Decrypt the hard coded test entry and make sure the decrypted content + // matches our created entry. + mac, err = encryptedEntry.loadMacaroon(pwCallback) + require.NoError(t, err) + macBytes, err = mac.MarshalBinary() + require.NoError(t, err) + require.Equal(t, dummyMac, macBytes) +} + +// TestMacaroonJarPlaintext tests that a macaroon can be stored and retrieved +// as plaintext as well. +func TestMacaroonJarPlaintext(t *testing.T) { + // Create a new macaroon entry from the dummy macaroon and encrypt it + // with the test password. + newEntry := &macaroonEntry{ + Name: "plaintextMac", + } + err := newEntry.storeMacaroon(toMacaroon(t, dummyMac), nil) + require.NoError(t, err) + + // Now decrypt it again and make sure we get the same content back. + mac, err := newEntry.loadMacaroon(noPwCallback) + require.NoError(t, err) + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + require.Equal(t, dummyMac, macBytes) + require.Equal(t, plaintextEntry.Data, newEntry.Data) + + // Load the hard coded plaintext test entry and make sure the loaded + // content matches our created entry. + mac, err = plaintextEntry.loadMacaroon(noPwCallback) + require.NoError(t, err) + macBytes, err = mac.MarshalBinary() + require.NoError(t, err) + require.Equal(t, dummyMac, macBytes) +} + +func toMacaroon(t *testing.T, macData []byte) *macaroon.Macaroon { + mac := &macaroon.Macaroon{} + err := mac.UnmarshalBinary(macData) + require.NoError(t, err) + + return mac +} diff --git a/cmd/lncli/main.go b/cmd/lncli/main.go index b956d49f..eb75fe91 100644 --- a/cmd/lncli/main.go +++ b/cmd/lncli/main.go @@ -6,13 +6,10 @@ package main import ( "fmt" - "io/ioutil" "os" - "os/user" "path/filepath" "strings" - - macaroon "gopkg.in/macaroon.v2" + "syscall" "github.com/btcsuite/btcutil" "github.com/lightningnetwork/lnd/build" @@ -21,6 +18,7 @@ import ( "github.com/lightningnetwork/lnd/macaroons" "github.com/urfave/cli" + "golang.org/x/crypto/ssh/terminal" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) @@ -69,18 +67,20 @@ func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) { } func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn { - // First, we'll parse the args from the command. - tlsCertPath, macPath, err := extractPathArgs(ctx) + // First, we'll get the selected stored profile or an ephemeral one + // created from the global options in the CLI context. + profile, err := getGlobalOptions(ctx) if err != nil { - fatal(err) + fatal(fmt.Errorf("could not load global options: %v", err)) } // Load the specified TLS certificate and build transport credentials // with it. - creds, err := credentials.NewClientTLSFromFile(tlsCertPath, "") + certPool, err := profile.cert() if err != nil { - fatal(err) + fatal(fmt.Errorf("could not create cert pool: %v", err)) } + creds := credentials.NewClientTLSFromCert(certPool, "") // Create a dial options array. opts := []grpc.DialOption{ @@ -89,17 +89,31 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn { // Only process macaroon credentials if --no-macaroons isn't set and // if we're not skipping macaroon processing. - if !ctx.GlobalBool("no-macaroons") && !skipMacaroons { - // Load the specified macaroon file. - macBytes, err := ioutil.ReadFile(macPath) - if err != nil { - fatal(fmt.Errorf("unable to read macaroon path (check "+ - "the network setting!): %v", err)) + if !profile.NoMacaroons && !skipMacaroons { + // Find out which macaroon to load. + macName := profile.Macaroons.Default + if ctx.GlobalIsSet("macfromjar") { + macName = ctx.GlobalString("macfromjar") + } + var macEntry *macaroonEntry + for _, entry := range profile.Macaroons.Jar { + if entry.Name == macName { + macEntry = entry + break + } + } + if macEntry == nil { + fatal(fmt.Errorf("macaroon with name '%s' not found "+ + "in profile", macName)) } - mac := &macaroon.Macaroon{} - if err = mac.UnmarshalBinary(macBytes); err != nil { - fatal(fmt.Errorf("unable to decode macaroon: %v", err)) + // Get and possibly decrypt the specified macaroon. + // + // TODO(guggero): Make it possible to cache the password so we + // don't need to ask for it every time. + mac, err := macEntry.loadMacaroon(readPassword) + if err != nil { + fatal(fmt.Errorf("could not load macaroon: %v", err)) } macConstraints := []macaroons.Constraint{ @@ -114,16 +128,18 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn { // altogether if, in the latter case, this time is more than 60 // seconds). // TODO(aakselrod): add better anti-replay protection. - macaroons.TimeoutConstraint(ctx.GlobalInt64("macaroontimeout")), + macaroons.TimeoutConstraint(profile.Macaroons.Timeout), // Lock macaroon down to a specific IP address. - macaroons.IPLockConstraint(ctx.GlobalString("macaroonip")), + macaroons.IPLockConstraint(profile.Macaroons.IP), // ... Add more constraints if needed. } // Apply constraints to the macaroon. - constrainedMac, err := macaroons.AddConstraints(mac, macConstraints...) + constrainedMac, err := macaroons.AddConstraints( + mac, macConstraints..., + ) if err != nil { fatal(err) } @@ -139,7 +155,7 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn { opts = append(opts, grpc.WithContextDialer(genericDialer)) opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize)) - conn, err := grpc.Dial(ctx.GlobalString("rpcserver"), opts...) + conn, err := grpc.Dial(profile.RPCServer, opts...) if err != nil { fatal(fmt.Errorf("unable to connect to RPC server: %v", err)) } @@ -171,13 +187,13 @@ func extractPathArgs(ctx *cli.Context) (string, string, error) { // properly read the macaroons (if needed) and also the cert. This will // either be the default, or will have been overwritten by the end // user. - lndDir := cleanAndExpandPath(ctx.GlobalString("lnddir")) + lndDir := lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir")) // If the macaroon path as been manually provided, then we'll only // target the specified file. var macPath string if ctx.GlobalString("macaroonpath") != "" { - macPath = cleanAndExpandPath(ctx.GlobalString("macaroonpath")) + macPath = lncfg.CleanAndExpandPath(ctx.GlobalString("macaroonpath")) } else { // Otherwise, we'll go into the path: // lnddir/data/chain// in order to fetch the @@ -188,7 +204,7 @@ func extractPathArgs(ctx *cli.Context) (string, string, error) { ) } - tlsCertPath := cleanAndExpandPath(ctx.GlobalString("tlscertpath")) + tlsCertPath := lncfg.CleanAndExpandPath(ctx.GlobalString("tlscertpath")) // If a custom lnd directory was set, we'll also check if custom paths // for the TLS cert and macaroon file were set as well. If not, we'll @@ -211,45 +227,60 @@ func main() { cli.StringFlag{ Name: "rpcserver", Value: defaultRPCHostPort, - Usage: "host:port of ln daemon", + Usage: "The host:port of LN daemon.", }, cli.StringFlag{ Name: "lnddir", Value: defaultLndDir, - Usage: "path to lnd's base directory", + Usage: "The path to lnd's base directory.", }, cli.StringFlag{ Name: "tlscertpath", Value: defaultTLSCertPath, - Usage: "path to TLS certificate", + Usage: "The path to lnd's TLS certificate.", }, cli.StringFlag{ Name: "chain, c", - Usage: "the chain lnd is running on e.g. bitcoin", + Usage: "The chain lnd is running on, e.g. bitcoin.", Value: "bitcoin", }, cli.StringFlag{ Name: "network, n", - Usage: "the network lnd is running on e.g. mainnet, " + + Usage: "The network lnd is running on, e.g. mainnet, " + "testnet, etc.", Value: "mainnet", }, cli.BoolFlag{ Name: "no-macaroons", - Usage: "disable macaroon authentication", + Usage: "Disable macaroon authentication.", }, cli.StringFlag{ Name: "macaroonpath", - Usage: "path to macaroon file", + Usage: "The path to macaroon file.", }, cli.Int64Flag{ Name: "macaroontimeout", Value: 60, - Usage: "anti-replay macaroon validity time in seconds", + Usage: "Anti-replay macaroon validity time in seconds.", }, cli.StringFlag{ Name: "macaroonip", - Usage: "if set, lock macaroon to specific IP address", + Usage: "If set, lock macaroon to specific IP address.", + }, + cli.StringFlag{ + Name: "profile, p", + Usage: "Instead of reading settings from command " + + "line parameters or using the default " + + "profile, use a specific profile. If " + + "a default profile is set, this flag can be " + + "set to an empty string to disable reading " + + "values from the profiles file.", + }, + cli.StringFlag{ + Name: "macfromjar", + Usage: "Use this macaroon from the profile's " + + "macaroon jar instead of the default one. " + + "Can only be used if profiles are defined.", }, } app.Commands = []cli.Command{ @@ -307,6 +338,7 @@ func main() { printMacaroonCommand, trackPaymentCommand, versionCommand, + profileSubCommand, } // Add any extra commands determined by build flags. @@ -322,28 +354,15 @@ func main() { } } -// cleanAndExpandPath expands environment variables and leading ~ in the -// passed path, cleans the result, and returns it. -// This function is taken from https://github.com/btcsuite/btcd -func cleanAndExpandPath(path string) string { - if path == "" { - return "" - } +// readPassword reads a password from the terminal. This requires there to be an +// actual TTY so passing in a password from stdin won't work. +func readPassword(text string) ([]byte, error) { + fmt.Print(text) - // Expand initial ~ to OS specific home directory. - if strings.HasPrefix(path, "~") { - var homeDir string - user, err := user.Current() - if err == nil { - homeDir = user.HomeDir - } else { - homeDir = os.Getenv("HOME") - } - - path = strings.Replace(path, "~", homeDir, 1) - } - - // NOTE: The os.ExpandEnv doesn't work with Windows-style %VARIABLE%, - // but the variables can still be expanded via POSIX-style $VARIABLE. - return filepath.Clean(os.ExpandEnv(path)) + // The variable syscall.Stdin is of a different type in the Windows API + // that's why we need the explicit cast. And of course the linter + // doesn't like it either. + pw, err := terminal.ReadPassword(int(syscall.Stdin)) // nolint:unconvert + fmt.Println() + return pw, err } diff --git a/cmd/lncli/profile.go b/cmd/lncli/profile.go new file mode 100644 index 00000000..b1583f6d --- /dev/null +++ b/cmd/lncli/profile.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "path" + "strings" + + "github.com/lightningnetwork/lnd/lncfg" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/walletunlocker" + "github.com/urfave/cli" + "gopkg.in/macaroon.v2" +) + +var ( + errNoProfileFile = errors.New("no profile file found") +) + +// profileEntry is a struct that represents all settings for one specific +// profile. +type profileEntry struct { + Name string `json:"name"` + RPCServer string `json:"rpcserver"` + LndDir string `json:"lnddir"` + Chain string `json:"chain"` + Network string `json:"network"` + NoMacaroons bool `json:"no-macaroons,omitempty"` + TLSCert string `json:"tlscert"` + Macaroons *macaroonJar `json:"macaroons"` +} + +// cert returns the profile's TLS certificate as a x509 certificate pool. +func (e *profileEntry) cert() (*x509.CertPool, error) { + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM([]byte(e.TLSCert)) { + return nil, fmt.Errorf("credentials: failed to append " + + "certificate") + } + return cp, nil +} + +// getGlobalOptions returns the global connection options. If a profile file +// exists, these global options might be read from a predefined profile. If no +// profile exists, the global options from the command line are returned as an +// ephemeral profile entry. +func getGlobalOptions(ctx *cli.Context) (*profileEntry, error) { + var profileName string + + // Try to load the default profile file and depending on its existence + // what profile to use. + f, err := loadProfileFile(defaultProfileFile) + switch { + // The legacy case where no profile file exists and the user also didn't + // request to use one. We only consider the global options here. + case err == errNoProfileFile && !ctx.GlobalIsSet("profile"): + return profileFromContext(ctx, false) + + // The file doesn't exist but the user specified an explicit profile. + case err == errNoProfileFile && ctx.GlobalIsSet("profile"): + return nil, fmt.Errorf("profile file %s does not exist", + defaultProfileFile) + + // There is a file but we couldn't read/parse it. + case err != nil: + return nil, fmt.Errorf("could not read profile file %s: "+ + "%v", defaultProfileFile, err) + + // The user explicitly disabled the use of profiles for this command by + // setting the flag to an empty string. We fall back to the default/old + // behavior. + case ctx.GlobalIsSet("profile") && ctx.GlobalString("profile") == "": + return profileFromContext(ctx, false) + + // There is a file, but no default profile is specified. The user also + // didn't specify a profile to use so we fall back to the default/old + // behavior. + case !ctx.GlobalIsSet("profile") && len(f.Default) == 0: + return profileFromContext(ctx, false) + + // The user didn't specify a profile but there is a default one defined. + case !ctx.GlobalIsSet("profile") && len(f.Default) > 0: + profileName = f.Default + + // The user specified a specific profile to use. + case ctx.GlobalIsSet("profile"): + profileName = ctx.GlobalString("profile") + } + + // If we got to here, we do have a profile file and know the name of the + // profile to use. Now we just need to make sure it does exist. + for _, prof := range f.Profiles { + if prof.Name == profileName { + return prof, nil + } + } + + return nil, fmt.Errorf("profile '%s' not found in file %s", profileName, + defaultProfileFile) +} + +// profileFromContext creates an ephemeral profile entry from the global options +// set in the CLI context. +func profileFromContext(ctx *cli.Context, store bool) (*profileEntry, error) { + // Parse the paths of the cert and macaroon. This will validate the + // chain and network value as well. + tlsCertPath, macPath, err := extractPathArgs(ctx) + if err != nil { + return nil, err + } + + // Load the certificate file now. We store it as plain PEM directly. + tlsCert, err := ioutil.ReadFile(tlsCertPath) + if err != nil { + return nil, fmt.Errorf("could not load TLS cert file %s: %v", + tlsCertPath, err) + } + + // Now load and possibly encrypt the macaroon file. + macBytes, err := ioutil.ReadFile(macPath) + if err != nil { + return nil, fmt.Errorf("unable to read macaroon path (check "+ + "the network setting!): %v", err) + } + mac := &macaroon.Macaroon{} + if err = mac.UnmarshalBinary(macBytes); err != nil { + return nil, fmt.Errorf("unable to decode macaroon: %v", err) + } + + var pw []byte + if store { + // Read a password from the terminal. If it's empty, we won't + // encrypt the macaroon and store it plaintext. + pw, err = capturePassword( + "Enter password to encrypt macaroon with or leave "+ + "blank to store in plaintext: ", true, + walletunlocker.ValidatePassword, + ) + if err != nil { + return nil, fmt.Errorf("unable to get encryption "+ + "password: %v", err) + } + } + macEntry := &macaroonEntry{} + if err = macEntry.storeMacaroon(mac, pw); err != nil { + return nil, fmt.Errorf("unable to store macaroon: %v", err) + } + + // We determine the name of the macaroon from the file itself but cut + // off the ".macaroon" at the end. + macEntry.Name = path.Base(macPath) + if path.Ext(macEntry.Name) == "macaroon" { + macEntry.Name = strings.TrimSuffix(macEntry.Name, ".macaroon") + } + + // Now that we have the complicated arguments behind us, let's return + // the new entry with all the values populated. + return &profileEntry{ + RPCServer: ctx.GlobalString("rpcserver"), + LndDir: lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir")), + Chain: ctx.GlobalString("chain"), + Network: ctx.GlobalString("network"), + NoMacaroons: ctx.GlobalBool("no-macaroons"), + TLSCert: string(tlsCert), + Macaroons: &macaroonJar{ + Default: macEntry.Name, + Timeout: ctx.GlobalInt64("macaroontimeout"), + IP: ctx.GlobalString("macaroonip"), + Jar: []*macaroonEntry{macEntry}, + }, + }, nil +} + +// loadProfileFile tries to load the file specified and JSON deserialize it into +// the profile file struct. +func loadProfileFile(file string) (*profileFile, error) { + if !lnrpc.FileExists(file) { + return nil, errNoProfileFile + } + + content, err := ioutil.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("could not load profile file %s: %v", + file, err) + } + f := &profileFile{} + err = f.unmarshalJSON(content) + if err != nil { + return nil, fmt.Errorf("could not unmarshal profile file %s: "+ + "%v", file, err) + } + return f, nil +} + +// saveProfileFile stores the given profile file struct in the specified file, +// overwriting it if it already existed. +func saveProfileFile(file string, f *profileFile) error { + content, err := f.marshalJSON() + if err != nil { + return fmt.Errorf("could not marshal profile: %v", err) + } + return ioutil.WriteFile(file, content, 0644) +} + +// profileFile is a struct that represents the whole content of a profile file. +type profileFile struct { + Default string `json:"default,omitempty"` + Profiles []*profileEntry `json:"profiles"` +} + +// unmarshalJSON tries to parse the given JSON and unmarshal it into the +// receiving instance. +func (f *profileFile) unmarshalJSON(content []byte) error { + return json.Unmarshal(content, f) +} + +// marshalJSON serializes the receiving instance to formatted/indented JSON. +func (f *profileFile) marshalJSON() ([]byte, error) { + b, err := json.Marshal(f) + if err != nil { + return nil, fmt.Errorf("error JSON marshalling profile: %v", + err) + } + + var out bytes.Buffer + err = json.Indent(&out, b, "", " ") + if err != nil { + return nil, fmt.Errorf("error indenting profile JSON: %v", err) + } + out.WriteString("\n") + return out.Bytes(), nil +}