lncli: add encrypted macaroon jar
With this commit we add a simple macaroon jar that can encrypt its content with a user-provided password when being serialized to JSON.
This commit is contained in:
parent
b4bf4b2906
commit
c34732af3d
162
cmd/lncli/macaroon_jar.go
Normal file
162
cmd/lncli/macaroon_jar.go
Normal file
@ -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:<key_base64>:" +
|
||||||
|
"<encrypted_macaroon_base64>'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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:<key_base64>:<encrypted_macaroon_base64>.
|
||||||
|
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
|
||||||
|
}
|
102
cmd/lncli/macaroon_jar_test.go
Normal file
102
cmd/lncli/macaroon_jar_test.go
Normal file
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user