diff --git a/config.go b/config.go index fc6fd7f9..038f39ef 100644 --- a/config.go +++ b/config.go @@ -27,6 +27,7 @@ import ( "github.com/lightninglabs/lightning-terminal/db/sqlc" "github.com/lightninglabs/lightning-terminal/firewall" "github.com/lightninglabs/lightning-terminal/firewalldb" + "github.com/lightninglabs/lightning-terminal/macaroons" mid "github.com/lightninglabs/lightning-terminal/rpcmiddleware" "github.com/lightninglabs/lightning-terminal/session" "github.com/lightninglabs/lightning-terminal/subservers" @@ -96,6 +97,22 @@ const ( // autogenerated lit macaroon. DefaultMacaroonFilename = "lit.macaroon" + // DefaultSuperMacaroonFilename is the default file name for the + // autogenerated super macaroon. + DefaultSuperMacaroonFilename = "super.macaroon" + + // noneChoice is the none choice for the bake-super-macaroon + // configuration option. + noneChoice = "none" + + // readOnlyChoice is the read-only choice for the bake-super-macaroon + // configuration option. + readOnlyChoice = "read-only" + + // defaultBakeSuperMacaroon is the default value for the + // bake-super-macaroon configuration option. + defaultBakeSuperMacaroon = noneChoice + defaultFirstLNCConnTimeout = 10 * time.Minute // DatabaseBackendSqlite is the name of the SQLite database backend. @@ -169,6 +186,12 @@ var ( defaultSqliteDatabasePath = filepath.Join( DefaultLitDir, DefaultNetwork, defaultSqliteDatabaseFileName, ) + + // DefaultSuperMacaroonPath is the default full path of the super + // macaroon. + DefaultSuperMacaroonPath = filepath.Join( + DefaultLitDir, DefaultNetwork, DefaultSuperMacaroonFilename, + ) ) // Config is the main configuration struct of lightning-terminal. It contains @@ -203,6 +226,9 @@ type Config struct { MacaroonPath string `long:"macaroonpath" description:"Path to write the macaroon for litd's RPC and REST services if it doesn't exist."` + BakeSuperMacaroon string `long:"bake-super-macaroon" description:"Bake a super macaroon on startup if it doesn't exist." choice:"none" choice:"read-only" choice:"read-write"` + SuperMacaroonPath string `long:"super-macaroon-path" description:"Path to write the auto-baked super macaroon. This must include both the directory and the name of the macaroon file itself (which must end with the .macaroon suffix)."` + FirstLNCConnDeadline time.Duration `long:"firstlncconndeadline" description:"The duration after a new LNC session will be revoked if no connection is made with it. This only applies for the first connection which is made using the pairing phrase. "` // DatabaseBackend is the database backend we will use for storing all @@ -543,6 +569,8 @@ func defaultConfig() *Config { LetsEncryptListen: defaultLetsEncryptListen, LetsEncryptDir: defaultLetsEncryptDir, MacaroonPath: DefaultMacaroonPath, + SuperMacaroonPath: DefaultSuperMacaroonPath, + BakeSuperMacaroon: defaultBakeSuperMacaroon, DatabaseBackend: DatabaseBackendSqlite, Sqlite: &db.SqliteConfig{ DatabaseFileName: defaultSqliteDatabasePath, @@ -732,6 +760,32 @@ func loadAndValidateConfig(ctx context.Context, return nil, err } + if cfg.BakeSuperMacaroon != defaultBakeSuperMacaroon { + if cfg.SuperMacaroonPath == DefaultSuperMacaroonPath { + cfg.SuperMacaroonPath = filepath.Join( + litDir, cfg.Network, + DefaultSuperMacaroonFilename, + ) + } + + err = macaroons.HasMacaroonSuffix( + cfg.SuperMacaroonPath, + ) + if err != nil { + return nil, err + } + + // Clean and expand the super macaroon path + cfg.SuperMacaroonPath = lncfg.CleanAndExpandPath( + cfg.SuperMacaroonPath, + ) + dir := filepath.Dir(cfg.SuperMacaroonPath) + if err := makeDirectories(dir); err != nil { + return nil, fmt.Errorf("unable to create super "+ + "macaroon directory %v: %w", dir, err) + } + } + err = cfg.DevConfig.Validate() if err != nil { return nil, err diff --git a/docs/release-notes/release-notes-0.17.1.md b/docs/release-notes/release-notes-0.17.1.md index ce3ca83d..7046ef8a 100644 --- a/docs/release-notes/release-notes-0.17.1.md +++ b/docs/release-notes/release-notes-0.17.1.md @@ -39,6 +39,15 @@ an account, supporting pagination (sorted in ascending lexicographical order of their payment hash) and counting of total payments. +* [Auto-bake super macaroon on startup](https://github.com/lightninglabs/lightning-terminal/pull/1324): + Added config options `--bake-super-macaroon` (choice: `none`, `read-only`, + `read-write`) and `--super-macaroon-path` to automatically bake a super + macaroon on startup and keep its permissions in sync. When set to `read-only` + or `read-write`, the daemon will automatically bake a super macaroon + containing read-only or read-write permissions, respectively, for all active + sub-servers on startup. If the macaroon already exists but has different + permissions, it will be automatically regenerated. + ### Technical and Architectural Updates * [Report litd's own version for `litd diff --git a/itest/litd_mode_integrated_test.go b/itest/litd_mode_integrated_test.go index 889fca0b..831ec07a 100644 --- a/itest/litd_mode_integrated_test.go +++ b/itest/litd_mode_integrated_test.go @@ -11,6 +11,7 @@ import ( "io/ioutil" "net/http" "os" + "path/filepath" "strings" "testing" "time" @@ -1530,3 +1531,217 @@ func bakeSuperMacaroon(t *testing.T, cfg *LitNodeConfig, return tempFile.Name() } + +// testSuperMacaroonOnStartup tests that the super macaroon is successfully +// baked on startup if configured. +func testSuperMacaroonOnStartup(ctx context.Context, net *NetworkHarness, + t *harnessTest) { + + superMacPath := filepath.Join( + net.Alice.Cfg.LitDir, "startup-super.macaroon", + ) + + verifyMacaroonPermissions := func(path string, expectWrite bool) { + ctxTimeout, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + rawConn, err := connectRPC( + ctxTimeout, net.Alice.Cfg.LitAddr(), + net.Alice.Cfg.LitTLSCertPath, + ) + require.NoError(t.t, err) + defer rawConn.Close() + + macBytes, err := os.ReadFile(path) + require.NoError(t.t, err) + + ctxm := macaroonContext(ctxTimeout, macBytes) + lnrpcConn := lnrpc.NewLightningClient(rawConn) + + _, err = lnrpcConn.GetInfo(ctxm, &lnrpc.GetInfoRequest{}) + require.NoError(t.t, err) + + _, err = lnrpcConn.NewAddress(ctxm, &lnrpc.NewAddressRequest{ + Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH, + }) + if expectWrite { + require.NoError(t.t, err) + } else { + require.Error(t.t, err) + require.Contains(t.t, err.Error(), "permission denied") + } + } + + verifyMacaroonSubserverPermissions := func(path string, + expectSubservers bool) { + + macBytes, err := os.ReadFile(path) + require.NoError(t.t, err) + + mac := &macaroon.Macaroon{} + err = mac.UnmarshalBinary(macBytes) + require.NoError(t.t, err) + + rawID := mac.Id() + require.NotEmpty(t.t, rawID) + + decodedID := &lnrpc.MacaroonId{} + err = proto.Unmarshal(rawID[1:], decodedID) + require.NoError(t.t, err) + + hasSubserver := false + for _, op := range decodedID.Ops { + if op == nil { + continue + } + if op.Entity == "loop" || op.Entity == "pool" || + op.Entity == "faraday" { + + hasSubserver = true + } + } + + if expectSubservers { + require.True( + t.t, hasSubserver, + "expected macaroon to contain "+ + "subserver permissions", + ) + } else { + require.False( + t.t, hasSubserver, + "expected macaroon NOT to contain "+ + "subserver permissions", + ) + } + } + + // Ensure any old file is removed first. + _ = os.Remove(superMacPath) + + // Test that starting Alice with an invalid super-macaroon-path (not + // ending with .macaroon) fails. + invalidMacPath := filepath.Join( + net.Alice.Cfg.LitDir, "invalid-path.mac", + ) + err := net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "read-only"), + WithLitArg("super-macaroon-path", invalidMacPath), + }, + ) + require.Error(t.t, err) + + // Drain the expected process exit error from the error channel to + // prevent it from failing the test runner at the end of the test. + select { + case <-net.lndErrorChan: + case <-time.After(defaultTimeout): + t.t.Fatalf("expected process exit error in lndErrorChan") + } + + // Restart Alice with read-only super macaroon baking enabled. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "read-only"), + WithLitArg("super-macaroon-path", superMacPath), + }, + ) + require.NoError(t.t, err) + + // Verify that the super macaroon was created on startup. + require.FileExists(t.t, superMacPath) + + // Verify permissions: write should be blocked. + verifyMacaroonPermissions(superMacPath, false) + + // Restart Alice with a read-write super macaroon, WITHOUT + // deleting the file. This will test the overwrite behavior when + // permissions differ. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "read-write"), + WithLitArg("super-macaroon-path", superMacPath), + }, + ) + require.NoError(t.t, err) + + require.FileExists(t.t, superMacPath) + + // Verify permissions: write should succeed. + verifyMacaroonPermissions(superMacPath, true) + + // Restart Alice back with a read-only super macaroon, WITHOUT + // deleting the file. This will test the overwrite behavior when + // switching back to read-only. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "read-only"), + WithLitArg("super-macaroon-path", superMacPath), + }, + ) + require.NoError(t.t, err) + + require.FileExists(t.t, superMacPath) + + // Verify permissions: write should be blocked again. + verifyMacaroonPermissions(superMacPath, false) + + // Restart Alice with a sub-server disabled to bake a super macaroon + // with a subset of permissions. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "read-write"), + WithLitArg("super-macaroon-path", superMacPath), + WithLitArg("loop-mode", "disable"), + WithLitArg("pool-mode", "disable"), + WithLitArg("faraday-mode", "disable"), + }, + ) + require.NoError(t.t, err) + + require.FileExists(t.t, superMacPath) + verifyMacaroonSubserverPermissions(superMacPath, false) + + // Restart Alice with the sub-servers re-enabled. This will add + // permissions and trigger macaroon regeneration on startup. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "read-write"), + WithLitArg("super-macaroon-path", superMacPath), + }, + ) + require.NoError(t.t, err) + + require.FileExists(t.t, superMacPath) + verifyMacaroonSubserverPermissions(superMacPath, true) + + // Verify permissions: write should succeed. + verifyMacaroonPermissions(superMacPath, true) + + // Clean up the super macaroon file. + _ = os.Remove(superMacPath) + + // Restart Alice with super macaroon baking disabled and verify that + // no super macaroon is created. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithLitArg("bake-super-macaroon", "none"), + WithLitArg("super-macaroon-path", superMacPath), + }, + ) + require.NoError(t.t, err) + + _, err = os.Stat(superMacPath) + require.True(t.t, os.IsNotExist(err)) + + // Clean up after ourselves. + err = net.RestartNode( + net.Alice, nil, []LitArgOption{ + WithoutLitArg("bake-super-macaroon"), + WithoutLitArg("super-macaroon-path"), + }, + ) + require.NoError(t.t, err) + net.ConnectNodes(t.t, net.Alice, net.Bob) +} diff --git a/itest/litd_test_list_on_test.go b/itest/litd_test_list_on_test.go index eaf0f77f..ee0ba932 100644 --- a/itest/litd_test_list_on_test.go +++ b/itest/litd_test_list_on_test.go @@ -31,4 +31,8 @@ var allTestCases = []*testCase{ name: "kvdb to sql migration", test: testKvdbSQLMigration, }, + { + name: "terminal super macaroon on startup", + test: testSuperMacaroonOnStartup, + }, } diff --git a/macaroons/super_mac.go b/macaroons/super_mac.go index 32cad9ff..d103306e 100644 --- a/macaroons/super_mac.go +++ b/macaroons/super_mac.go @@ -6,8 +6,12 @@ import ( "encoding/binary" "encoding/hex" "errors" + "fmt" + "os" + "strings" "github.com/lightningnetwork/lnd/lnrpc" + "google.golang.org/protobuf/proto" "gopkg.in/macaroon-bakery.v2/bakery" "gopkg.in/macaroon.v2" ) @@ -118,3 +122,127 @@ func BakeSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient, return hex.EncodeToString(macBytes), err } + +// SuperMacaroonExists determines whether a macaroon file exists at the given +// path. +func SuperMacaroonExists(path string) bool { + if _, err := os.Stat(path); err != nil { + return false + } + + return true +} + +// MacaroonMatchesPermissions checks if the macaroon at the given path contains +// exactly the expected permissions (no more and no less). +func MacaroonMatchesPermissions(path string, + expectedPerms []bakery.Op) (bool, error) { + + macBytes, err := os.ReadFile(path) + if err != nil { + return false, err + } + + mac := &macaroon.Macaroon{} + if err := mac.UnmarshalBinary(macBytes); err != nil { + return false, err + } + + rawID := mac.Id() + if len(rawID) == 0 || rawID[0] != byte(bakery.LatestVersion) { + return false, errors.New("invalid macaroon version") + } + + decodedID := &lnrpc.MacaroonId{} + if err := proto.Unmarshal(rawID[1:], decodedID); err != nil { + return false, err + } + + // Map expected permissions for easy lookup: entity -> action -> true. + expectedMap := make(map[string]map[string]bool) + for _, op := range expectedPerms { + if expectedMap[op.Entity] == nil { + expectedMap[op.Entity] = make(map[string]bool) + } + + expectedMap[op.Entity][op.Action] = true + } + + // Map actual permissions from decoded macaroon ID: + // entity -> action -> true. + actualMap := make(map[string]map[string]bool) + for _, op := range decodedID.Ops { + if op == nil { + continue + } + if actualMap[op.Entity] == nil { + actualMap[op.Entity] = make(map[string]bool) + } + for _, action := range op.Actions { + actualMap[op.Entity][action] = true + } + } + + // Compare the mapped sets for exact equality. + if len(expectedMap) != len(actualMap) { + return false, nil + } + + for entity, actions := range expectedMap { + actualActions, ok := actualMap[entity] + + if !ok || len(actions) != len(actualActions) { + return false, nil + } + for action := range actions { + if !actualActions[action] { + return false, nil + } + } + } + + return true, nil +} + +// BakeAndWriteSuperMacaroon bakes a super macaroon and writes it to disk. +func BakeAndWriteSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient, + path string, perms []bakery.Op) error { + + var suffixBytes [4]byte + rootKeyID := NewSuperMacaroonRootKeyID(suffixBytes) + + superMacHex, err := BakeSuperMacaroon( + ctx, lnd, rootKeyID, perms, nil, + ) + if err != nil { + return fmt.Errorf("unable to bake super macaroon: %w", err) + } + + superMacBytes, err := hex.DecodeString(superMacHex) + if err != nil { + return fmt.Errorf("unable to decode baked "+ + "super macaroon: %w", err) + } + + if err := os.WriteFile(path, superMacBytes, 0600); err != nil { + return fmt.Errorf("unable to write super macaroon to %v: %w", + path, err) + } + + return nil +} + +// HasMacaroonSuffix checks that the super macaroon path is not empty and ends +// with the expected suffix. +func HasMacaroonSuffix(path string) error { + if path == "" { + return fmt.Errorf("super-macaroon-path cannot be empty") + } + + if !strings.HasSuffix(path, ".macaroon") { + return fmt.Errorf("super-macaroon-path must end with the " + + ".macaroon suffix") + } + + return nil +} diff --git a/macaroons/super_mac_test.go b/macaroons/super_mac_test.go index d4b91aea..a751683f 100644 --- a/macaroons/super_mac_test.go +++ b/macaroons/super_mac_test.go @@ -1,9 +1,13 @@ package macaroons import ( + "encoding/hex" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" + "gopkg.in/macaroon-bakery.v2/bakery" ) var ( @@ -43,3 +47,121 @@ func TestIsSuperMacaroon(t *testing.T) { require.True(t, IsSuperMacaroon(testMacHex)) } + +// TestSuperMacaroonHelpers tests that SuperMacaroonExists and +// MacaroonMatchesPermissions behave correctly. +func TestSuperMacaroonHelpers(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + path := filepath.Join(tempDir, "test.macaroon") + + // Verify that it doesn't exist yet. + require.False(t, SuperMacaroonExists(path)) + + // Write the test macaroon. + macBytes, err := hex.DecodeString(testMacHex) + require.NoError(t, err) + err = os.WriteFile(path, macBytes, 0600) + require.NoError(t, err) + + // Now it should exist. + require.True(t, SuperMacaroonExists(path)) + + // The macaroon matches this expected list of permissions. + expectedPerms := []bakery.Op{ + {Entity: "account", Action: "read"}, + {Entity: "auction", Action: "read"}, + {Entity: "audit", Action: "read"}, + {Entity: "auth", Action: "read"}, + {Entity: "info", Action: "read"}, + {Entity: "insights", Action: "read"}, + {Entity: "invoices", Action: "read"}, + {Entity: "loop", Action: "in"}, + {Entity: "loop", Action: "out"}, + {Entity: "macaroon", Action: "read"}, + {Entity: "message", Action: "read"}, + {Entity: "offchain", Action: "read"}, + {Entity: "onchain", Action: "read"}, + {Entity: "order", Action: "read"}, + {Entity: "peers", Action: "read"}, + {Entity: "rates", Action: "read"}, + {Entity: "recommendation", Action: "read"}, + {Entity: "report", Action: "read"}, + {Entity: "suggestions", Action: "read"}, + {Entity: "swap", Action: "read"}, + {Entity: "terms", Action: "read"}, + } + + matches, err := MacaroonMatchesPermissions(path, expectedPerms) + require.NoError(t, err) + require.True(t, matches) + + // A subset of permissions should NOT match exactly. + matches, err = MacaroonMatchesPermissions(path, expectedPerms[:5]) + require.NoError(t, err) + require.False(t, matches) + + // Extra/different permissions should NOT match exactly. + differentPerms := append( + expectedPerms, + bakery.Op{Entity: "invalid", Action: "write"}, + ) + matches, err = MacaroonMatchesPermissions(path, differentPerms) + require.NoError(t, err) + require.False(t, matches) +} + +// TestHasMacaroonSuffix tests that HasMacaroonSuffix correctly +// checks the suffix of the super macaroon path. +func TestHasMacaroonSuffix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr bool + errStr string + }{ + { + name: "empty path", + path: "", + wantErr: true, + errStr: "super-macaroon-path cannot be empty", + }, + { + name: "valid path", + path: "/tmp/test.macaroon", + wantErr: false, + }, + { + name: "invalid path - missing suffix", + path: "/tmp/test.mac", + wantErr: true, + errStr: "super-macaroon-path must end " + + "with the .macaroon suffix", + }, + { + name: "invalid path - wrong suffix", + path: "/tmp/test.macaroon.tmp", + wantErr: true, + errStr: "super-macaroon-path must end " + + "with the .macaroon suffix", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := HasMacaroonSuffix(tt.path) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errStr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/terminal.go b/terminal.go index c2098bd6..36746444 100644 --- a/terminal.go +++ b/terminal.go @@ -822,6 +822,13 @@ func (g *LightningTerminal) start(ctx context.Context) error { return fmt.Errorf("could not start litd sub-servers: %v", err) } + // Bake the super macaroon on startup if configured, now that all local + // and remote sub-servers have been started and active permissions are + // fully known. + if err := g.setupSuperMacaroon(ctx); err != nil { + return fmt.Errorf("could not setup super macaroon: %w", err) + } + // We can now set the status of LiT as running. g.statusMgr.SetRunning(subservers.LIT) @@ -2192,3 +2199,64 @@ func randId(n int) string { return string(b) } + +// setupSuperMacaroon bakes a super macaroon and writes it to disk if needed. +func (g *LightningTerminal) setupSuperMacaroon(ctx context.Context) error { + // If the bake-super-macaroon option is set to none, we don't bake a + // macaroon. + if g.cfg.BakeSuperMacaroon == noneChoice { + return nil + } + + // If the super macaroon baking option is enabled, we cannot run in + // stateless initialization mode as it won't write any macaroons to the + // filesystem. + if g.cfg.statelessInitMode { + return fmt.Errorf("cannot use bake-super-macaroon " + + "with stateless-init mode") + } + + path := g.cfg.SuperMacaroonPath + + readOnly := g.cfg.BakeSuperMacaroon == readOnlyChoice + activePerms := g.permsMgr.ActivePermissions(readOnly) + + if litmac.SuperMacaroonExists(path) { + matches, err := litmac.MacaroonMatchesPermissions( + path, activePerms, + ) + + if err == nil && matches { + log.Debugf("Super macaroon already exists at "+ + "%v and matches configuration, "+ + "skipping bake", path) + + return nil + } + if err != nil { + return fmt.Errorf( + "unable to verify super macaroon "+ + "permissions at %v, please delete "+ + "it if the issue persists: %w", + path, err, + ) + } + + log.Infof("Super macaroon permissions " + + "differ from configuration, " + + "regenerating...") + } + + log.Infof("Baking super macaroon on startup...") + + // Bake the super macaroon and write it to disk. + if err := litmac.BakeAndWriteSuperMacaroon( + ctx, g.basicClient, path, activePerms, + ); err != nil { + return err + } + + log.Infof("Successfully baked and wrote super macaroon to %v", path) + + return nil +}