Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ type Environment interface {
// OnAuthSettingChanged is called when the authentication (screen lock) setting is changed.
// This is also called when the app launches with the current setting.
OnAuthSettingChanged(enabled bool)
// CanEncryptLightningMnemonic reports whether Lightning mnemonics should be stored encrypted on
// this platform.
CanEncryptLightningMnemonic() bool
// StoreLightningEncryptionKey persists a backend-generated Lightning seed encryption key.
StoreLightningEncryptionKey(accountCode string, encryptionKey string) error
// LoadLightningEncryptionKey retrieves the stored Lightning seed encryption key.
LoadLightningEncryptionKey(accountCode string) (string, error)
// DeleteLightningEncryptionKey removes the persisted Lightning seed encryption key.
DeleteLightningEncryptionKey(accountCode string) error
// BluetoothConnect tries to connect to the peripheral by the given identifier.
// Use `backend.bluetooth.State()` to track failure.
BluetoothConnect(identifier string)
Expand Down Expand Up @@ -349,6 +358,7 @@ func NewBackend(arguments *arguments.Arguments, environment Environment) (*Backe

backend.lightning = lightning.NewLightning(backend.config,
backend.arguments.CacheDirectoryPath(),
backend.environment,
backend.Keystore, backend.httpClient,
backend.ratesUpdater,
btcCoin)
Expand Down
8 changes: 8 additions & 0 deletions backend/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,14 @@ func (e environment) Auth() {}

func (e environment) OnAuthSettingChanged(bool) {}

func (e environment) CanEncryptLightningMnemonic() bool { return false }

func (e environment) StoreLightningEncryptionKey(string, string) error { return nil }

func (e environment) LoadLightningEncryptionKey(string) (string, error) { return "", nil }

func (e environment) DeleteLightningEncryptionKey(string) error { return nil }

func (e environment) BluetoothConnect(string) {}

type mockTransactionsSource struct {
Expand Down
50 changes: 43 additions & 7 deletions backend/bridgecommon/bridgecommon.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,17 @@ type BackendEnvironment struct {
UsingMobileDataFunc func() bool
// NativeLocaleFunc is used by the backend to query native app layer for user
// preferred UI language.
NativeLocaleFunc func() string
GetSaveFilenameFunc func(string) string
SetDarkThemeFunc func(bool)
DetectDarkThemeFunc func() bool
AuthFunc func()
OnAuthSettingChangedFunc func(bool)
BluetoothConnectFunc func(string)
NativeLocaleFunc func() string
GetSaveFilenameFunc func(string) string
SetDarkThemeFunc func(bool)
DetectDarkThemeFunc func() bool
AuthFunc func()
OnAuthSettingChangedFunc func(bool)
CanEncryptLightningMnemonicFunc func() bool
StoreLightningEncryptionKeyFunc func(string, string) error
LoadLightningEncryptionKeyFunc func(string) (string, error)
DeleteLightningEncryptionKeyFunc func(string) error
BluetoothConnectFunc func(string)
}

// NotifyUser implements backend.Environment.
Expand Down Expand Up @@ -265,6 +269,38 @@ func (env *BackendEnvironment) OnAuthSettingChanged(enabled bool) {
}
}

// CanEncryptLightningMnemonic implements backend.Environment.
func (env *BackendEnvironment) CanEncryptLightningMnemonic() bool {
if env.CanEncryptLightningMnemonicFunc != nil {
return env.CanEncryptLightningMnemonicFunc()
}
return false
}

// StoreLightningEncryptionKey implements backend.Environment.
func (env *BackendEnvironment) StoreLightningEncryptionKey(accountCode string, encryptionKey string) error {
if env.StoreLightningEncryptionKeyFunc != nil {
return env.StoreLightningEncryptionKeyFunc(accountCode, encryptionKey)
}
return nil
}

// LoadLightningEncryptionKey implements backend.Environment.
func (env *BackendEnvironment) LoadLightningEncryptionKey(accountCode string) (string, error) {
if env.LoadLightningEncryptionKeyFunc != nil {
return env.LoadLightningEncryptionKeyFunc(accountCode)
}
return "", nil
}
Comment on lines +280 to +294
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Silent defaults on data-critical store/load could mask misconfiguration.

If a future caller sets CanEncryptLightningMnemonicFunc to return true but forgets to wire StoreLightningEncryptionKeyFunc or LoadLightningEncryptionKeyFunc, Lightning.sealMnemonic will think storage succeeded and Lightning.unsealMnemonic will receive an empty key (then fail base64-decode or AES-GCM open with the generic "reactivation required" message). This is consistent with how the other delegates degrade, but those are UX-only; here the consequence is users locked out of their Lightning seed. Consider returning an error when the func is missing so the misconfiguration surfaces immediately during activation rather than later at unlock time.

🛡️ Proposed defensive fix
 // StoreLightningEncryptionKey implements backend.Environment.
 func (env *BackendEnvironment) StoreLightningEncryptionKey(accountCode string, encryptionKey string) error {
 	if env.StoreLightningEncryptionKeyFunc != nil {
 		return env.StoreLightningEncryptionKeyFunc(accountCode, encryptionKey)
 	}
-	return nil
+	return errp.New("StoreLightningEncryptionKey not implemented in this environment")
 }
 
 // LoadLightningEncryptionKey implements backend.Environment.
 func (env *BackendEnvironment) LoadLightningEncryptionKey(accountCode string) (string, error) {
 	if env.LoadLightningEncryptionKeyFunc != nil {
 		return env.LoadLightningEncryptionKeyFunc(accountCode)
 	}
-	return "", nil
+	return "", errp.New("LoadLightningEncryptionKey not implemented in this environment")
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/bridgecommon/bridgecommon.go` around lines 280 - 294, The current
BackendEnvironment methods StoreLightningEncryptionKey and
LoadLightningEncryptionKey silently succeed/return empty when their delegates
are nil, which can hide misconfiguration; change them to return a clear error
when StoreLightningEncryptionKeyFunc or LoadLightningEncryptionKeyFunc is nil so
callers (e.g., Lightning.sealMnemonic/Lightning.unsealMnemonic) fail fast.
Specifically, in BackendEnvironment.StoreLightningEncryptionKey return a non-nil
error if StoreLightningEncryptionKeyFunc == nil (rather than nil), and in
BackendEnvironment.LoadLightningEncryptionKey return ("", error) if
LoadLightningEncryptionKeyFunc == nil; reference the methods
StoreLightningEncryptionKey, LoadLightningEncryptionKey and the
CanEncryptLightningMnemonicFunc contract to ensure activation-time
misconfiguration surfaces immediately.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this to allow using the seed without encryption in webdev mode. Lightning activation will be hidden in the UI for all the other platforms that don't support encryption in a future PR. I'll keep it as is for now.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Beerosagos, understood — since CanEncryptLightningMnemonic() returns false for webdev (and other non-supporting platforms), the Store/Load paths won't be reached during normal flow, and the future UI guard will provide an additional layer of protection. That makes the silent defaults a reasonable design choice here. Thanks for the context!


✏️ Learnings added
Learnt from: Beerosagos
Repo: BitBoxSwiss/bitbox-wallet-app PR: 4146
File: backend/bridgecommon/bridgecommon.go:280-294
Timestamp: 2026-05-14T09:40:51.331Z
Learning: In `backend/bridgecommon/bridgecommon.go`, the `BackendEnvironment` methods `StoreLightningEncryptionKey` and `LoadLightningEncryptionKey` intentionally return `nil`/`("", nil)` when their delegate funcs are not set. This is by design to support webdev mode (and other non-encrypting platforms) where `CanEncryptLightningMnemonic()` returns `false`, gating all encryption code paths. A future PR will hide the Lightning activation UI on platforms that do not support mnemonic encryption, adding another layer of protection.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


// DeleteLightningEncryptionKey implements backend.Environment.
func (env *BackendEnvironment) DeleteLightningEncryptionKey(accountCode string) error {
if env.DeleteLightningEncryptionKeyFunc != nil {
return env.DeleteLightningEncryptionKeyFunc(accountCode)
}
return nil
}

// BluetoothConnect implements backend.Environment.
func (env *BackendEnvironment) BluetoothConnect(identifier string) {
if env.BluetoothConnectFunc != nil {
Expand Down
8 changes: 8 additions & 0 deletions backend/bridgecommon/bridgecommon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ func (e environment) Auth() {}

func (e environment) OnAuthSettingChanged(bool) {}

func (e environment) CanEncryptLightningMnemonic() bool { return false }

func (e environment) StoreLightningEncryptionKey(string, string) error { return nil }

func (e environment) LoadLightningEncryptionKey(string) (string, error) { return "", nil }

func (e environment) DeleteLightningEncryptionKey(string) error { return nil }

func (e environment) BluetoothConnect(string) {}

// TestServeShutdownServe checks that you can call Serve twice in a row.
Expand Down
26 changes: 15 additions & 11 deletions backend/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,21 @@ type backendEnv struct {
Locale string // returned by NativeLocale
}

func (e *backendEnv) NotifyUser(string) {}
func (e *backendEnv) SystemOpen(string) error { return nil }
func (e *backendEnv) DeviceInfos() []usb.DeviceInfo { return nil }
func (e *backendEnv) UsingMobileData() bool { return false }
func (e *backendEnv) NativeLocale() string { return e.Locale }
func (e *backendEnv) GetSaveFilename(string) string { return "" }
func (e *backendEnv) SetDarkTheme(bool) {}
func (e *backendEnv) DetectDarkTheme() bool { return false }
func (e *backendEnv) Auth() {}
func (e *backendEnv) OnAuthSettingChanged(bool) {}
func (e *backendEnv) BluetoothConnect(string) {}
func (e *backendEnv) NotifyUser(string) {}
func (e *backendEnv) SystemOpen(string) error { return nil }
func (e *backendEnv) DeviceInfos() []usb.DeviceInfo { return nil }
func (e *backendEnv) UsingMobileData() bool { return false }
func (e *backendEnv) NativeLocale() string { return e.Locale }
func (e *backendEnv) GetSaveFilename(string) string { return "" }
func (e *backendEnv) SetDarkTheme(bool) {}
func (e *backendEnv) DetectDarkTheme() bool { return false }
func (e *backendEnv) Auth() {}
func (e *backendEnv) OnAuthSettingChanged(bool) {}
func (e *backendEnv) CanEncryptLightningMnemonic() bool { return false }
func (e *backendEnv) StoreLightningEncryptionKey(string, string) error { return nil }
func (e *backendEnv) LoadLightningEncryptionKey(string) (string, error) { return "", nil }
func (e *backendEnv) DeleteLightningEncryptionKey(string) error { return nil }
func (e *backendEnv) BluetoothConnect(string) {}

func TestGetNativeLocale(t *testing.T) {
const ptLocale = "pt"
Expand Down
144 changes: 138 additions & 6 deletions backend/lightning/lightning.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
package lightning

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"io"
"net/http"
"os"
"path"
Expand All @@ -29,12 +34,21 @@ const (
breezApiKeyUrl = "https://bitboxapp.shiftcrypto.dev/lightning/breez-api-key"
)

// Keep this local to avoid importing backend.Environment and creating a package cycle.
type environment interface {
CanEncryptLightningMnemonic() bool
StoreLightningEncryptionKey(accountCode string, encryptionKey string) error
LoadLightningEncryptionKey(accountCode string) (string, error)
DeleteLightningEncryptionKey(accountCode string) error
}

// Lightning manages the Breez SDK lightning node.
type Lightning struct {
observable.Implementation

backendConfig *config.Config
cacheDirectoryPath string
environment environment
getKeystore func() keystore.Keystore
synced bool

Expand All @@ -48,13 +62,15 @@ type Lightning struct {
// NewLightning creates a new instance of the Lightning struct.
func NewLightning(config *config.Config,
cacheDirectoryPath string,
environment environment,
getKeystore func() keystore.Keystore,
httpClient *http.Client,
ratesUpdater *rates.RateUpdater,
btcCoin coin.Coin) *Lightning {
return &Lightning{
backendConfig: config,
cacheDirectoryPath: cacheDirectoryPath,
environment: environment,
getKeystore: getKeystore,
log: logging.Get().WithGroup("lightning"),
synced: false,
Expand All @@ -64,7 +80,8 @@ func NewLightning(config *config.Config,
}
}

// Activate first creates a mnemonic from the keystore entropy then connects to instance.
// Activate first creates a mnemonic from the keystore entropy, persists it, and connects to the
// instance.
func (lightning *Lightning) Activate() error {
if lightning.Account() != nil {
return errp.New("Lightning accounts already configured")
Expand All @@ -91,13 +108,25 @@ func (lightning *Lightning) Activate() error {
return errp.New("Error generating mnemonic")
}

accountCode := types.Code(strings.Join([]string{"v0-", hex.EncodeToString(fingerprint), "-ln-0"}, ""))
sealedMnemonic, err := lightning.sealMnemonic(string(accountCode), entropyMnemonic)
if err != nil {
lightning.log.WithError(err).Warn("Error configuring Lightning secure storage")
return errp.New("Could not configure Lightning secure storage on this device")
}

lightningAccount := config.LightningAccountConfig{
Mnemonic: entropyMnemonic,
Mnemonic: sealedMnemonic,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would rename the Mnemonic field name too, as it sounds like it would contain a mnemonic. Also please add a docstring to the struct field about what is stored in it.

It also seems that the memonic field value can contain the sealed mnemonic or the plaintext memonic depending on CanEncryptLightningMnemonic. If this split will remain for a while, I'd use two different fields, or a union-like struct for clarity.

RootFingerprint: fingerprint,
Code: types.Code(strings.Join([]string{"v0-", hex.EncodeToString(fingerprint), "-ln-0"}, "")),
Code: accountCode,
Number: 0,
}
if err = lightning.SetAccount(&lightningAccount); err != nil {
if lightning.environment.CanEncryptLightningMnemonic() {
if deleteErr := lightning.environment.DeleteLightningEncryptionKey(string(accountCode)); deleteErr != nil {
lightning.log.WithError(deleteErr).Warn("Error deleting lightning encryption key after activation failure")
}
}
return err
}

Expand Down Expand Up @@ -149,6 +178,12 @@ func (lightning *Lightning) Deactivate() error {
return err
}

if lightning.environment.CanEncryptLightningMnemonic() {
if err := lightning.environment.DeleteLightningEncryptionKey(string(account.Code)); err != nil {
lightning.log.WithError(err).Warn("Error deleting lightning encryption key")
Comment thread
benma marked this conversation as resolved.
}
}

return nil
}

Expand All @@ -172,7 +207,6 @@ func (lightning *Lightning) Balance() (*accounts.Balance, error) {
// before returning the balance
EnsureSynced: &ensureSynced,
})

if err != nil {
return nil, err
}
Expand Down Expand Up @@ -201,9 +235,15 @@ func (lightning *Lightning) connect(_ bool) error {
return err
}

mnemonic, err := lightning.unsealMnemonic(account)
if err != nil {
lightning.log.WithError(err).Warn("Error unlocking Lightning mnemonic")
return errp.New("Error unlocking Lightning mnemonic from the device")
}

// Construct the seed using mnemonic words or entropy bytes
var seed breez_sdk_spark.Seed = breez_sdk_spark.SeedMnemonic{
Mnemonic: account.Mnemonic,
Mnemonic: mnemonic,
Passphrase: nil,
}

Expand All @@ -219,7 +259,9 @@ func (lightning *Lightning) connect(_ bool) error {
config.PrivateEnabledDefault = true
// Set the maximum fee to the fastest network recommended fee at the time of claim
// with a leeway of 1 sats/vbyte
networkRecommendedInterface := breez_sdk_spark.MaxFee(breez_sdk_spark.MaxFeeNetworkRecommended{LeewaySatPerVbyte: 1})
networkRecommendedInterface := breez_sdk_spark.MaxFee(
breez_sdk_spark.MaxFeeNetworkRecommended{LeewaySatPerVbyte: 1},
)
config.MaxDepositClaimFee = &networkRecommendedInterface

connectRequest := breez_sdk_spark.ConnectRequest{
Expand Down Expand Up @@ -252,6 +294,96 @@ func (lightning *Lightning) connect(_ bool) error {
return nil
}

func (lightning *Lightning) sealMnemonic(accountCode string, mnemonic string) (string, error) {
if !lightning.environment.CanEncryptLightningMnemonic() {
return mnemonic, nil
}

encryptionKey := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, encryptionKey); err != nil {
return "", err
}

sealedMnemonic, err := encryptMnemonic(mnemonic, encryptionKey)
if err != nil {
return "", err
}

if err := lightning.environment.StoreLightningEncryptionKey(
accountCode,
base64.StdEncoding.EncodeToString(encryptionKey),
); err != nil {
return "", err
}

return sealedMnemonic, nil
}

func (lightning *Lightning) unsealMnemonic(account *config.LightningAccountConfig) (string, error) {
if !lightning.environment.CanEncryptLightningMnemonic() {
return account.Mnemonic, nil
}

encryptionKeyBase64, err := lightning.environment.LoadLightningEncryptionKey(string(account.Code))
if err != nil {
return "", err
}

encryptionKey, err := base64.StdEncoding.DecodeString(encryptionKeyBase64)
if err != nil {
return "", err
}

mnemonic, err := decryptMnemonic(account.Mnemonic, encryptionKey)
if err != nil {
return "", err
}

return mnemonic, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func encryptMnemonic(mnemonic string, encryptionKey []byte) (string, error) {
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(mnemonic), nil)
return base64.StdEncoding.EncodeToString(append(nonce, ciphertext...)), nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about prepending a 0x00 version byte in front of the bytes, or a v1: version string prefix to to the base64 encoding? Then potential migrations will be simpler/clearer.

}

func decryptMnemonic(sealedMnemonic string, encryptionKey []byte) (string, error) {
rawCiphertext, err := base64.StdEncoding.DecodeString(sealedMnemonic)
if err != nil {
return "", err
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(rawCiphertext) < gcm.NonceSize() {
return "", errp.New("ciphertext too short")
}
nonce := rawCiphertext[:gcm.NonceSize()]
ciphertext := rawCiphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

func (lightning *Lightning) getBreezApiKey() (*string, error) {
_, breezApiKey, err := util.HTTPGet(lightning.httpClient, breezApiKeyUrl, "", int64(4096))
if err != nil {
Expand Down
Loading