From c34732af3de6fa933a606726c1303104ed8d4838 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Fri, 4 Sep 2020 16:06:06 +0200 Subject: [PATCH] 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. --- cmd/lncli/macaroon_jar.go | 162 +++++++++++++++++++++++++++++++++ cmd/lncli/macaroon_jar_test.go | 102 +++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 cmd/lncli/macaroon_jar.go create mode 100644 cmd/lncli/macaroon_jar_test.go 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 +}