Skip to content

Commit 7ec4746

Browse files
authored
feat: do not require unlock password to recover encrypted scb (#768)
* 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
1 parent 731d248 commit 7ec4746

34 files changed

+417
-182
lines changed

alby/alby_oauth_service.go

+36-23
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
decodepay "github.com/nbd-wtf/ln-decodepay"
1919
"github.com/sirupsen/logrus"
20+
"github.com/tyler-smith/go-bip32"
2021
"golang.org/x/oauth2"
2122
"gorm.io/gorm"
2223

@@ -723,46 +724,58 @@ func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Eve
723724
}
724725
}
725726

726-
func (svc *albyOAuthService) backupChannels(ctx context.Context, event *events.Event) error {
727-
bkpEvent, ok := event.Properties.(*events.StaticChannelsBackupEvent)
728-
if !ok {
729-
return fmt.Errorf("invalid nwc_backup_channels event properties, could not cast to the expected type: %+v", event.Properties)
727+
type channelsBackup struct {
728+
Description string `json:"description"`
729+
Data string `json:"data"`
730+
}
731+
732+
func (svc *albyOAuthService) createEncryptedChannelBackup(event *events.StaticChannelsBackupEvent) (*channelsBackup, error) {
733+
734+
eventData := bytes.NewBuffer([]byte{})
735+
err := json.NewEncoder(eventData).Encode(event)
736+
if err != nil {
737+
return nil, fmt.Errorf("failed to encode channels backup data: %w", err)
730738
}
731739

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

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

739-
type channelsBackup struct {
740-
Description string `json:"description"`
741-
Data string `json:"data"`
752+
backup := &channelsBackup{
753+
Description: "channels_v2",
754+
Data: encrypted,
742755
}
756+
return backup, nil
757+
}
743758

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

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

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

775+
client := svc.oauthConf.Client(ctx, token)
776+
761777
body := bytes.NewBuffer([]byte{})
762-
err = json.NewEncoder(body).Encode(&channelsBackup{
763-
Description: "channels",
764-
Data: encrypted,
765-
})
778+
err = json.NewEncoder(body).Encode(backup)
766779
if err != nil {
767780
return fmt.Errorf("failed to encode channels backup request payload: %w", err)
768781
}

alby/alby_oauth_service_test.go

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package alby
2+
3+
import (
4+
"testing"
5+
6+
"github.com/getAlby/hub/config"
7+
"github.com/getAlby/hub/events"
8+
"github.com/getAlby/hub/tests"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"github.com/tyler-smith/go-bip32"
12+
"github.com/tyler-smith/go-bip39"
13+
)
14+
15+
func TestExistingEncryptedBackup(t *testing.T) {
16+
defer tests.RemoveTestService()
17+
svc, err := tests.CreateTestService()
18+
require.NoError(t, err)
19+
20+
mnemonic := "limit reward expect search tissue call visa fit thank cream brave jump"
21+
unlockPassword := "123"
22+
svc.Cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword)
23+
err = svc.Keys.Init(svc.Cfg, unlockPassword)
24+
assert.NoError(t, err)
25+
26+
encryptedBackup := "3fd21f9a393d8345ddbdd449-ba05c3dbafdfb7eea574373b7763d0c81c599b2cd1735e59a1c5571379498f4da8fe834c3403824ab02b61005abc1f563c638f425c65420e82941efe94794555c8b145a0603733ee115277f860011e6a17fd8c22f1d73a096ff7275582aac19b430940b40a2559c7ff59a063305290ef7c9ba46f9de17b0ddbac9030b0"
27+
28+
masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
29+
assert.NoError(t, err)
30+
31+
appKey, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 128029 /* 🐝 */)
32+
assert.NoError(t, err)
33+
encryptedChannelsBackupKey, err := appKey.NewChildKey(bip32.FirstHardenedChild)
34+
assert.NoError(t, err)
35+
36+
decrypted, err := config.AesGcmDecryptWithKey(encryptedBackup, encryptedChannelsBackupKey.Key)
37+
assert.NoError(t, err)
38+
39+
assert.Equal(t, "{\"node_id\":\"037e702144c4fa485d42f0f69864e943605823763866cf4bf619d2d2cf2eda420b\",\"channels\":[],\"monitors\":[]}\n", decrypted)
40+
}
41+
42+
func TestEncryptedBackup(t *testing.T) {
43+
defer tests.RemoveTestService()
44+
svc, err := tests.CreateTestService()
45+
require.NoError(t, err)
46+
47+
mnemonic := "limit reward expect search tissue call visa fit thank cream brave jump"
48+
unlockPassword := "123"
49+
svc.Cfg.SetUpdate("Mnemonic", mnemonic, unlockPassword)
50+
err = svc.Keys.Init(svc.Cfg, unlockPassword)
51+
assert.NoError(t, err)
52+
53+
albyOAuthSvc := NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher)
54+
encryptedBackup, err := albyOAuthSvc.createEncryptedChannelBackup(&events.StaticChannelsBackupEvent{
55+
NodeID: "037e702144c4fa485d42f0f69864e943605823763866cf4bf619d2d2cf2eda420b",
56+
Channels: []events.ChannelBackup{},
57+
Monitors: []events.EncodedChannelMonitorBackup{},
58+
})
59+
60+
assert.NoError(t, err)
61+
assert.Equal(t, "channels_v2", encryptedBackup.Description)
62+
63+
masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
64+
assert.NoError(t, err)
65+
66+
appKey, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 128029 /* 🐝 */)
67+
assert.NoError(t, err)
68+
encryptedChannelsBackupKey, err := appKey.NewChildKey(bip32.FirstHardenedChild)
69+
assert.NoError(t, err)
70+
71+
decrypted, err := config.AesGcmDecryptWithKey(encryptedBackup.Data, encryptedChannelsBackupKey.Key)
72+
assert.NoError(t, err)
73+
74+
assert.Equal(t, "{\"node_id\":\"037e702144c4fa485d42f0f69864e943605823763866cf4bf619d2d2cf2eda420b\",\"channels\":[],\"monitors\":[]}\n", decrypted)
75+
}

config/aesgcm.go

+39-12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/cipher"
66
"crypto/rand"
77
"encoding/hex"
8+
"fmt"
89
"strings"
910

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

26-
func AesGcmEncrypt(plaintext string, password string) (string, error) {
27+
func AesGcmEncryptWithPassword(plaintext string, password string) (string, error) {
2728
secretKey, salt, err := DeriveKey(password, nil)
2829
if err != nil {
2930
return "", err
3031
}
32+
33+
ciphertext, err := AesGcmEncryptWithKey(plaintext, secretKey)
34+
if err != nil {
35+
return "", err
36+
}
37+
38+
return hex.EncodeToString(salt) + "-" + ciphertext, nil
39+
}
40+
41+
func AesGcmDecryptWithPassword(ciphertext string, password string) (string, error) {
42+
arr := strings.Split(ciphertext, "-")
43+
salt, _ := hex.DecodeString(arr[0])
44+
secretKey, _, err := DeriveKey(password, salt)
45+
if err != nil {
46+
return "", err
47+
}
48+
49+
return AesGcmDecryptWithKey(arr[1]+"-"+arr[2], secretKey)
50+
}
51+
52+
func AesGcmEncryptWithKey(plaintext string, key []byte) (string, error) {
53+
// require a 32 bytes key (256 bits)
54+
if len(key) != 32 {
55+
return "", fmt.Errorf("key must be at least 32 bytes, got %d", len(key))
56+
}
57+
3158
plaintextBytes := []byte(plaintext)
3259

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

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

51-
return hex.EncodeToString(salt) + "-" + hex.EncodeToString(nonce) + "-" + hex.EncodeToString(ciphertext), nil
78+
return hex.EncodeToString(nonce) + "-" + hex.EncodeToString(ciphertext), nil
5279
}
5380

54-
func AesGcmDecrypt(ciphertext string, password string) (string, error) {
81+
func AesGcmDecryptWithKey(ciphertext string, key []byte) (string, error) {
82+
// require a 32 bytes key (256 bits)
83+
if len(key) != 32 {
84+
return "", fmt.Errorf("key must be at least 32 bytes, got %d", len(key))
85+
}
86+
5587
arr := strings.Split(ciphertext, "-")
56-
salt, _ := hex.DecodeString(arr[0])
57-
nonce, _ := hex.DecodeString(arr[1])
58-
data, _ := hex.DecodeString(arr[2])
88+
nonce, _ := hex.DecodeString(arr[0])
89+
data, _ := hex.DecodeString(arr[1])
5990

60-
secretKey, _, err := DeriveKey(password, salt)
61-
if err != nil {
62-
return "", err
63-
}
64-
aes, err := aes.NewCipher([]byte(secretKey))
91+
aes, err := aes.NewCipher([]byte(key))
6592
if err != nil {
6693
return "", err
6794
}

config/aesgcm_test.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package config
2+
3+
import (
4+
"encoding/hex"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/tyler-smith/go-bip32"
9+
"github.com/tyler-smith/go-bip39"
10+
)
11+
12+
func TestDecryptExistingCiphertextWithPassword(t *testing.T) {
13+
value, err := AesGcmDecryptWithPassword("323f41394d3175b72454ccae9c0081f94df5fb4c2fb0b9283a87e5aafba81839-c335b9eeea75c28a6f823354-5055b90dadbdd01c52fbdbb7efb80609e4410357481651e89ceb1501c8e1dea1f33a8e3322a1cef4f641773667423bca5154dfeccac390cfcd719b36965adc3e6ae56fd5d6c82819596e9ef4ff07193ae345eb291fa412a1ce6066864b", "123")
14+
assert.NoError(t, err)
15+
assert.Equal(t, "connect maximum march lava ignore resist visa kind kiwi kidney develop animal", value)
16+
}
17+
18+
func TestEncryptDecryptWithPassword(t *testing.T) {
19+
mnemonic := "connect maximum march lava ignore resist visa kind kiwi kidney develop animal"
20+
encrypted, err := AesGcmEncryptWithPassword(mnemonic, "123")
21+
assert.NoError(t, err)
22+
value, err := AesGcmDecryptWithPassword(encrypted, "123")
23+
assert.NoError(t, err)
24+
assert.Equal(t, mnemonic, value)
25+
}
26+
27+
func TestDecryptExistingCiphertextWithKey(t *testing.T) {
28+
mnemonic := "connect maximum march lava ignore resist visa kind kiwi kidney develop animal"
29+
masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
30+
assert.NoError(t, err)
31+
value, err := AesGcmDecryptWithKey("22ad485dea4f49696594c7c4-afe35ce65fc5a45249bf1b9078472fb28395fc88c30a79c76c7d8d37cf", masterKey.Key)
32+
assert.NoError(t, err)
33+
assert.Equal(t, "Hello, world!", value)
34+
}
35+
36+
func TestEncryptDecryptWithKey(t *testing.T) {
37+
plaintext := "Hello, world!"
38+
mnemonic := "connect maximum march lava ignore resist visa kind kiwi kidney develop animal"
39+
masterKey, err := bip32.NewMasterKey(bip39.NewSeed(mnemonic, ""))
40+
assert.NoError(t, err)
41+
42+
assert.Equal(t, "409e902eafba273b21dff921f0eb4bec6cbb0b657fdce8d245ca78d2920f8b73", hex.EncodeToString(masterKey.Key))
43+
44+
encrypted, err := AesGcmEncryptWithKey(plaintext, masterKey.Key)
45+
assert.NoError(t, err)
46+
value, err := AesGcmDecryptWithKey(encrypted, masterKey.Key)
47+
assert.NoError(t, err)
48+
assert.Equal(t, plaintext, value)
49+
}

config/config.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func (cfg *config) get(key string, encryptionKey string, gormDB *gorm.DB) (strin
154154

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

166166
func (cfg *config) set(key string, value string, clauses clause.OnConflict, encryptionKey string, gormDB *gorm.DB) error {
167167
if encryptionKey != "" {
168-
encrypted, err := AesGcmEncrypt(value, encryptionKey)
168+
encrypted, err := AesGcmEncryptWithPassword(value, encryptionKey)
169169
if err != nil {
170170
return fmt.Errorf("failed to encrypt: %v", err)
171171
}

frontend/src/screens/BackupMnemonic.tsx

+8-21
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,15 @@ export function BackupMnemonic() {
135135
<b>backs up your wallet savings balance</b>.&nbsp;
136136
{info?.albyAccountConnected && (
137137
<>
138-
You also need to make sure you do not forget your{" "}
139-
<b>unlock password</b> as this will be used to recover funds
140-
from channels. Encrypted channel backups are saved
141-
automatically to your Alby Account.
138+
Channel backups are saved automatically to your Alby
139+
Account, encrypted with your recovery phrase.
142140
</>
143141
)}
144142
{!info?.albyAccountConnected && (
145143
<>
146144
Make sure to also backup your <b>data directory</b> as this
147145
is required to recover funds on your channels. You can also
148-
connect your Alby Account for automatic encrypted backups
149-
(you still need your seed and unlock password to decrypt
150-
those).
146+
connect your Alby Account for automatic encrypted backups.
151147
</>
152148
)}
153149
</span>
@@ -166,14 +162,9 @@ export function BackupMnemonic() {
166162
</div>
167163
<span>
168164
If you lose access to your hub and do not have your{" "}
169-
<b>recovery phrase</b>&nbsp;
170-
{info?.albyAccountConnected && (
171-
<>
172-
or your <b>unlock password</b>
173-
</>
174-
)}
165+
<b>recovery phrase</b>
175166
{!info?.albyAccountConnected && (
176-
<>or do not backup your data directory</>
167+
<>&nbsp;or do not backup your data directory</>
177168
)}
178169
, you will lose access to your funds.
179170
</span>
@@ -201,7 +192,7 @@ export function BackupMnemonic() {
201192
secure place
202193
</Label>
203194
</div>
204-
{backedUp && (
195+
{backedUp && !info?.albyAccountConnected && (
205196
<div className="flex mt-5">
206197
<Checkbox
207198
id="backup2"
@@ -210,12 +201,8 @@ export function BackupMnemonic() {
210201
/>
211202
<Label htmlFor="backup2" className="ml-2">
212203
I understand the <b>recovery phrase</b> AND{" "}
213-
{info?.albyAccountConnected ? (
214-
<b>unlock password</b>
215-
) : (
216-
<b>a backup of my hub data directory</b>
217-
)}{" "}
218-
is required to recover funds from my lightning channels.{" "}
204+
<b>a backup of my hub data directory</b> is required to
205+
recover funds from my lightning channels.{" "}
219206
</Label>
220207
</div>
221208
)}

go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ require (
3131
github.com/BurntSushi/toml v1.2.1 // indirect
3232
github.com/DataDog/datadog-go/v5 v5.3.0 // indirect
3333
github.com/DataDog/gostackparse v0.7.0 // indirect
34+
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect
35+
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect
3436
github.com/Microsoft/go-winio v0.6.1 // indirect
3537
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
3638
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
@@ -237,6 +239,7 @@ require (
237239
github.com/labstack/echo-jwt/v4 v4.2.0
238240
github.com/lightningnetwork/lnd v0.18.2-beta
239241
github.com/sirupsen/logrus v1.9.3
242+
github.com/tyler-smith/go-bip32 v1.0.0
240243
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
241244
gorm.io/datatypes v1.2.4
242245
)

0 commit comments

Comments
 (0)