Skip to content

Commit

Permalink
feat: do not require unlock password to recover encrypted scb (#768)
Browse files Browse the repository at this point in the history
* feat: do not require unlock password to recover encrypted scb

* fix: use seed as key to encrypt static channel backup

* chore: move encrypted channel backup derivation to alby oauth service

* chore: add extra test

* chore: reduce duplicated code

* fix: change key derivation paths

* chore: simplify backup page copy for connected alby accounts

* chore: improve copy

* fix: initialize keys before starting ln backend

* chore: improve test assertions
  • Loading branch information
rolznz authored Nov 1, 2024
1 parent 731d248 commit 7ec4746
Show file tree
Hide file tree
Showing 34 changed files with 417 additions and 182 deletions.
59 changes: 36 additions & 23 deletions alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

decodepay "github.com/nbd-wtf/ln-decodepay"
"github.com/sirupsen/logrus"
"github.com/tyler-smith/go-bip32"
"golang.org/x/oauth2"
"gorm.io/gorm"

Expand Down Expand Up @@ -723,46 +724,58 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve
}
}

func (svc *albyOAuthService) backupChannels(ctx context.Context, event *events.Event) error {
bkpEvent, ok := event.Properties.(*events.StaticChannelsBackupEvent)
if !ok {
return fmt.Errorf("invalid nwc_backup_channels event properties, could not cast to the expected type: %+v", event.Properties)
type channelsBackup struct {
Description string `json:"description"`
Data string `json:"data"`
}

func (svc *albyOAuthService) createEncryptedChannelBackup(event *events.StaticChannelsBackupEvent) (*channelsBackup, error) {

eventData := bytes.NewBuffer([]byte{})
err := json.NewEncoder(eventData).Encode(event)
if err != nil {
return nil, fmt.Errorf("failed to encode channels backup data: %w", err)
}

token, err := svc.fetchUserToken(ctx)
path := []uint32{bip32.FirstHardenedChild}
backupKey, err := svc.keys.DeriveKey(path)
if err != nil {
return fmt.Errorf("failed to fetch user token: %w", err)
logger.Logger.WithError(err).Error("Failed to generate channels backup key")
return nil, err
}

client := svc.oauthConf.Client(ctx, token)
encrypted, err := config.AesGcmEncryptWithKey(eventData.String(), backupKey.Key)
if err != nil {
return nil, fmt.Errorf("failed to encrypt channels backup data: %w", err)
}

type channelsBackup struct {
Description string `json:"description"`
Data string `json:"data"`
backup := &channelsBackup{
Description: "channels_v2",
Data: encrypted,
}
return backup, nil
}

eventData := bytes.NewBuffer([]byte{})
err = json.NewEncoder(eventData).Encode(bkpEvent)
if err != nil {
return fmt.Errorf("failed to encode channels backup data: %w", err)
func (svc *albyOAuthService) backupChannels(ctx context.Context, event *events.Event) error {
bkpEvent, ok := event.Properties.(*events.StaticChannelsBackupEvent)
if !ok {
return fmt.Errorf("invalid nwc_backup_channels event properties, could not cast to the expected type: %+v", event.Properties)
}

// use the encrypted mnemonic as the password to encrypt the backup data
encryptedMnemonic, err := svc.cfg.Get("Mnemonic", "")
backup, err := svc.createEncryptedChannelBackup(bkpEvent)
if err != nil {
return fmt.Errorf("failed to fetch encryption key: %w", err)
return fmt.Errorf("failed to encrypt channel backup: %w", err)
}

encrypted, err := config.AesGcmEncrypt(eventData.String(), encryptedMnemonic)
token, err := svc.fetchUserToken(ctx)
if err != nil {
return fmt.Errorf("failed to encrypt channels backup data: %w", err)
return fmt.Errorf("failed to fetch user token: %w", err)
}

client := svc.oauthConf.Client(ctx, token)

body := bytes.NewBuffer([]byte{})
err = json.NewEncoder(body).Encode(&channelsBackup{
Description: "channels",
Data: encrypted,
})
err = json.NewEncoder(body).Encode(backup)
if err != nil {
return fmt.Errorf("failed to encode channels backup request payload: %w", err)
}
Expand Down
75 changes: 75 additions & 0 deletions alby/alby_oauth_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package alby

import (
"testing"

"github.com/getAlby/hub/config"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tyler-smith/go-bip32"
"github.com/tyler-smith/go-bip39"
)

func TestExistingEncryptedBackup(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
require.NoError(t, err)

mnemonic := "limit reward expect search tissue call visa fit thank cream brave jump"
unlockPassword := "123"
svc.Cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword)
err = svc.Keys.Init(svc.Cfg, unlockPassword)
assert.NoError(t, err)

encryptedBackup := "3fd21f9a393d8345ddbdd449-ba05c3dbafdfb7eea574373b7763d0c81c599b2cd1735e59a1c5571379498f4da8fe834c3403824ab02b61005abc1f563c638f425c65420e82941efe94794555c8b145a0603733ee115277f860011e6a17fd8c22f1d73a096ff7275582aac19b430940b40a2559c7ff59a063305290ef7c9ba46f9de17b0ddbac9030b0"

masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
assert.NoError(t, err)

appKey, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 128029 /* 🐝 */)
assert.NoError(t, err)
encryptedChannelsBackupKey, err := appKey.NewChildKey(bip32.FirstHardenedChild)
assert.NoError(t, err)

decrypted, err := config.AesGcmDecryptWithKey(encryptedBackup, encryptedChannelsBackupKey.Key)
assert.NoError(t, err)

assert.Equal(t, "{\"node_id\":\"037e702144c4fa485d42f0f69864e943605823763866cf4bf619d2d2cf2eda420b\",\"channels\":[],\"monitors\":[]}\n", decrypted)
}

func TestEncryptedBackup(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
require.NoError(t, err)

mnemonic := "limit reward expect search tissue call visa fit thank cream brave jump"
unlockPassword := "123"
svc.Cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword)
err = svc.Keys.Init(svc.Cfg, unlockPassword)
assert.NoError(t, err)

albyOAuthSvc := NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher)
encryptedBackup, err := albyOAuthSvc.createEncryptedChannelBackup(&events.StaticChannelsBackupEvent{
NodeID: "037e702144c4fa485d42f0f69864e943605823763866cf4bf619d2d2cf2eda420b",
Channels: []events.ChannelBackup{},
Monitors: []events.EncodedChannelMonitorBackup{},
})

assert.NoError(t, err)
assert.Equal(t, "channels_v2", encryptedBackup.Description)

masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
assert.NoError(t, err)

appKey, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 128029 /* 🐝 */)
assert.NoError(t, err)
encryptedChannelsBackupKey, err := appKey.NewChildKey(bip32.FirstHardenedChild)
assert.NoError(t, err)

decrypted, err := config.AesGcmDecryptWithKey(encryptedBackup.Data, encryptedChannelsBackupKey.Key)
assert.NoError(t, err)

assert.Equal(t, "{\"node_id\":\"037e702144c4fa485d42f0f69864e943605823763866cf4bf619d2d2cf2eda420b\",\"channels\":[],\"monitors\":[]}\n", decrypted)
}
51 changes: 39 additions & 12 deletions config/aesgcm.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"

"golang.org/x/crypto/argon2"
Expand All @@ -23,14 +24,40 @@ func DeriveKey(password string, salt []byte) ([]byte, []byte, error) {
return key, salt, nil
}

func AesGcmEncrypt(plaintext string, password string) (string, error) {
func AesGcmEncryptWithPassword(plaintext string, password string) (string, error) {
secretKey, salt, err := DeriveKey(password, nil)
if err != nil {
return "", err
}

ciphertext, err := AesGcmEncryptWithKey(plaintext, secretKey)
if err != nil {
return "", err
}

return hex.EncodeToString(salt) + "-" + ciphertext, nil
}

func AesGcmDecryptWithPassword(ciphertext string, password string) (string, error) {
arr := strings.Split(ciphertext, "-")
salt, _ := hex.DecodeString(arr[0])
secretKey, _, err := DeriveKey(password, salt)
if err != nil {
return "", err
}

return AesGcmDecryptWithKey(arr[1]+"-"+arr[2], secretKey)
}

func AesGcmEncryptWithKey(plaintext string, key []byte) (string, error) {
// require a 32 bytes key (256 bits)
if len(key) != 32 {
return "", fmt.Errorf("key must be at least 32 bytes, got %d", len(key))
}

plaintextBytes := []byte(plaintext)

aes, err := aes.NewCipher([]byte(secretKey))
aes, err := aes.NewCipher(key)
if err != nil {
return "", err
}
Expand All @@ -48,20 +75,20 @@ func AesGcmEncrypt(plaintext string, password string) (string, error) {

ciphertext := aesgcm.Seal(nil, nonce, plaintextBytes, nil)

return hex.EncodeToString(salt) + "-" + hex.EncodeToString(nonce) + "-" + hex.EncodeToString(ciphertext), nil
return hex.EncodeToString(nonce) + "-" + hex.EncodeToString(ciphertext), nil
}

func AesGcmDecrypt(ciphertext string, password string) (string, error) {
func AesGcmDecryptWithKey(ciphertext string, key []byte) (string, error) {
// require a 32 bytes key (256 bits)
if len(key) != 32 {
return "", fmt.Errorf("key must be at least 32 bytes, got %d", len(key))
}

arr := strings.Split(ciphertext, "-")
salt, _ := hex.DecodeString(arr[0])
nonce, _ := hex.DecodeString(arr[1])
data, _ := hex.DecodeString(arr[2])
nonce, _ := hex.DecodeString(arr[0])
data, _ := hex.DecodeString(arr[1])

secretKey, _, err := DeriveKey(password, salt)
if err != nil {
return "", err
}
aes, err := aes.NewCipher([]byte(secretKey))
aes, err := aes.NewCipher([]byte(key))
if err != nil {
return "", err
}
Expand Down
49 changes: 49 additions & 0 deletions config/aesgcm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package config

import (
"encoding/hex"
"testing"

"github.com/stretchr/testify/assert"
"github.com/tyler-smith/go-bip32"
"github.com/tyler-smith/go-bip39"
)

func TestDecryptExistingCiphertextWithPassword(t *testing.T) {
value, err := AesGcmDecryptWithPassword("323f41394d3175b72454ccae9c0081f94df5fb4c2fb0b9283a87e5aafba81839-c335b9eeea75c28a6f823354-5055b90dadbdd01c52fbdbb7efb80609e4410357481651e89ceb1501c8e1dea1f33a8e3322a1cef4f641773667423bca5154dfeccac390cfcd719b36965adc3e6ae56fd5d6c82819596e9ef4ff07193ae345eb291fa412a1ce6066864b", "123")
assert.NoError(t, err)
assert.Equal(t, "connect maximum march lava ignore resist visa kind kiwi kidney develop animal", value)
}

func TestEncryptDecryptWithPassword(t *testing.T) {
mnemonic := "connect maximum march lava ignore resist visa kind kiwi kidney develop animal"
encrypted, err := AesGcmEncryptWithPassword(mnemonic, "123")
assert.NoError(t, err)
value, err := AesGcmDecryptWithPassword(encrypted, "123")
assert.NoError(t, err)
assert.Equal(t, mnemonic, value)
}

func TestDecryptExistingCiphertextWithKey(t *testing.T) {
mnemonic := "connect maximum march lava ignore resist visa kind kiwi kidney develop animal"
masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
assert.NoError(t, err)
value, err := AesGcmDecryptWithKey("22ad485dea4f49696594c7c4-afe35ce65fc5a45249bf1b9078472fb28395fc88c30a79c76c7d8d37cf", masterKey.Key)
assert.NoError(t, err)
assert.Equal(t, "Hello, world!", value)
}

func TestEncryptDecryptWithKey(t *testing.T) {
plaintext := "Hello, world!"
mnemonic := "connect maximum march lava ignore resist visa kind kiwi kidney develop animal"
masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
assert.NoError(t, err)

assert.Equal(t, "409e902eafba273b21dff921f0eb4bec6cbb0b657fdce8d245ca78d2920f8b73", hex.EncodeToString(masterKey.Key))

encrypted, err := AesGcmEncryptWithKey(plaintext, masterKey.Key)
assert.NoError(t, err)
value, err := AesGcmDecryptWithKey(encrypted, masterKey.Key)
assert.NoError(t, err)
assert.Equal(t, plaintext, value)
}
4 changes: 2 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func (cfg *config) get(key string, encryptionKey string, gormDB *gorm.DB) (strin

value := userConfig.Value
if userConfig.Value != "" && encryptionKey != "" && userConfig.Encrypted {
decrypted, err := AesGcmDecrypt(value, encryptionKey)
decrypted, err := AesGcmDecryptWithPassword(value, encryptionKey)
if err != nil {
return "", err
}
Expand All @@ -165,7 +165,7 @@ func (cfg *config) get(key string, encryptionKey string, gormDB *gorm.DB) (strin

func (cfg *config) set(key string, value string, clauses clause.OnConflict, encryptionKey string, gormDB *gorm.DB) error {
if encryptionKey != "" {
encrypted, err := AesGcmEncrypt(value, encryptionKey)
encrypted, err := AesGcmEncryptWithPassword(value, encryptionKey)
if err != nil {
return fmt.Errorf("failed to encrypt: %v", err)
}
Expand Down
29 changes: 8 additions & 21 deletions frontend/src/screens/BackupMnemonic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,19 +135,15 @@ export function BackupMnemonic() {
<b>backs up your wallet savings balance</b>.&nbsp;
{info?.albyAccountConnected && (
<>
You also need to make sure you do not forget your{" "}
<b>unlock password</b> as this will be used to recover funds
from channels. Encrypted channel backups are saved
automatically to your Alby Account.
Channel backups are saved automatically to your Alby
Account, encrypted with your recovery phrase.
</>
)}
{!info?.albyAccountConnected && (
<>
Make sure to also backup your <b>data directory</b> as this
is required to recover funds on your channels. You can also
connect your Alby Account for automatic encrypted backups
(you still need your seed and unlock password to decrypt
those).
connect your Alby Account for automatic encrypted backups.
</>
)}
</span>
Expand All @@ -166,14 +162,9 @@ export function BackupMnemonic() {
</div>
<span>
If you lose access to your hub and do not have your{" "}
<b>recovery phrase</b>&nbsp;
{info?.albyAccountConnected && (
<>
or your <b>unlock password</b>
</>
)}
<b>recovery phrase</b>
{!info?.albyAccountConnected && (
<>or do not backup your data directory</>
<>&nbsp;or do not backup your data directory</>
)}
, you will lose access to your funds.
</span>
Expand Down Expand Up @@ -201,7 +192,7 @@ export function BackupMnemonic() {
secure place
</Label>
</div>
{backedUp && (
{backedUp && !info?.albyAccountConnected && (
<div className="flex mt-5">
<Checkbox
id="backup2"
Expand All @@ -210,12 +201,8 @@ export function BackupMnemonic() {
/>
<Label htmlFor="backup2" className="ml-2">
I understand the <b>recovery phrase</b> AND{" "}
{info?.albyAccountConnected ? (
<b>unlock password</b>
) : (
<b>a backup of my hub data directory</b>
)}{" "}
is required to recover funds from my lightning channels.{" "}
<b>a backup of my hub data directory</b> is required to
recover funds from my lightning channels.{" "}
</Label>
</div>
)}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/DataDog/datadog-go/v5 v5.3.0 // indirect
github.com/DataDog/gostackparse v0.7.0 // indirect
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
Expand Down Expand Up @@ -237,6 +239,7 @@ require (
github.com/labstack/echo-jwt/v4 v4.2.0
github.com/lightningnetwork/lnd v0.18.2-beta
github.com/sirupsen/logrus v1.9.3
github.com/tyler-smith/go-bip32 v1.0.0
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
gorm.io/datatypes v1.2.4
)
Expand Down
Loading

0 comments on commit 7ec4746

Please sign in to comment.