From 0fe35542f2013ae0eae90584593248f499dc5a50 Mon Sep 17 00:00:00 2001 From: Roger Date: Sat, 16 May 2026 20:24:19 +0800 Subject: [PATCH] feat(keys): add cross-host master key export (#599) --- browser/browser.go | 2 + browser/chromium/chromium.go | 82 +++++++---- browser/firefox/firefox.go | 1 + browser/keydump.go | 71 ++++++++++ browser/keydump_test.go | 228 ++++++++++++++++++++++++++++++ browser/safari/safari.go | 1 + cmd/hack-browser-data/keys.go | 63 +++++++++ cmd/hack-browser-data/main.go | 2 +- crypto/keyretriever/dump.go | 80 +++++++++++ crypto/keyretriever/masterkeys.go | 12 +- 10 files changed, 507 insertions(+), 35 deletions(-) create mode 100644 browser/keydump.go create mode 100644 browser/keydump_test.go create mode 100644 cmd/hack-browser-data/keys.go create mode 100644 crypto/keyretriever/dump.go diff --git a/browser/browser.go b/browser/browser.go index 556e6a7..99f19e5 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -20,6 +20,7 @@ type Browser interface { BrowserName() string ProfileName() string ProfileDir() string + UserDataDir() string Extract(categories []types.Category) (*types.BrowserData, error) CountEntries(categories []types.Category) (map[types.Category]int, error) } @@ -114,6 +115,7 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser // KeyManager is implemented by engines that accept externally-provided master-key retrievers (Chromium family only). type KeyManager interface { SetKeyRetrievers(keyretriever.Retrievers) + ExportKeys() (keyretriever.MasterKeys, error) } // KeychainPasswordReceiver is implemented by engines that need the macOS login password (Safari only). diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index 710cd45..e16ec03 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -59,6 +59,7 @@ func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) { func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) ProfileDir() string { return b.profileDir } +func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } func (b *Browser) ProfileName() string { if b.profileDir == "" { return "" @@ -66,6 +67,54 @@ func (b *Browser) ProfileName() string { return filepath.Base(b.profileDir) } +// ExportKeys derives this profile's master keys without performing extraction. +// Returns whatever tiers succeeded plus a joined error describing any failed +// tiers; callers preserve partial results because a Chrome 127+ profile mixes +// v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key. +// Used by cross-host workflows where keys are produced on one host and consumed +// on another. +func (b *Browser) ExportKeys() (keyretriever.MasterKeys, error) { + session, err := filemanager.NewSession() + if err != nil { + return keyretriever.MasterKeys{}, err + } + defer session.Cleanup() + + return keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session)) +} + +// buildHints discovers Local State (acquiring it into session.TempDir so Windows DPAPI/ABE retrievers can +// read it from a path the process owns) and assembles per-tier retriever hints. Shared by Extract and +// ExportKeys so the two stay in lockstep. Multi-profile layout: Local State lives in the parent of +// profileDir. Flat layout (Opera): Local State sits alongside data files inside profileDir. +func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints { + label := b.BrowserName() + "/" + b.ProfileName() + var localStateDst string + for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} { + candidate := filepath.Join(dir, "Local State") + if !fileutil.FileExists(candidate) { + continue + } + dst := filepath.Join(session.TempDir(), "Local State") + if err := session.Acquire(candidate, dst, false); err != nil { + log.Debugf("acquire Local State for %s: %v", label, err) + break + } + localStateDst = dst + break + } + + abeKey := "" + if b.cfg.WindowsABE { + abeKey = b.cfg.Key + } + return keyretriever.Hints{ + KeychainLabel: b.cfg.KeychainLabel, + WindowsABEKey: abeKey, + LocalStatePath: localStateDst, + } +} + // Extract copies browser files to a temp directory, retrieves the master key, // and extracts data for the requested categories. func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) { @@ -175,42 +224,13 @@ var warnedMasterKeyFailure sync.Map // getMasterKeys retrieves master keys for all configured cipher tiers. func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys { - label := b.BrowserName() + "/" + b.ProfileName() - - // Locate and copy Local State (needed on Windows, ignored on macOS/Linux). Multi-profile - // layout: Local State is in the parent of profileDir. Flat layout (Opera): Local State is - // alongside data files in profileDir. - var localStateDst string - for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} { - candidate := filepath.Join(dir, "Local State") - if !fileutil.FileExists(candidate) { - continue - } - dst := filepath.Join(session.TempDir(), "Local State") - if err := session.Acquire(candidate, dst, false); err != nil { - log.Debugf("acquire Local State for %s: %v", label, err) - break - } - localStateDst = dst - break - } - - abeKey := "" - if b.cfg.WindowsABE { - abeKey = b.cfg.Key - } - hints := keyretriever.Hints{ - KeychainLabel: b.cfg.KeychainLabel, - WindowsABEKey: abeKey, - LocalStatePath: localStateDst, - } - keys, err := keyretriever.NewMasterKeys(b.retrievers, hints) + keys, err := keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session)) if err != nil { installKey := b.BrowserName() + "|" + b.cfg.UserDataDir if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already { log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err) } else { - log.Debugf("%s: master key retrieval: %v", label, err) + log.Debugf("%s/%s: master key retrieval: %v", b.BrowserName(), b.ProfileName(), err) } } return keys diff --git a/browser/firefox/firefox.go b/browser/firefox/firefox.go index 10f20e9..1f6b091 100644 --- a/browser/firefox/firefox.go +++ b/browser/firefox/firefox.go @@ -49,6 +49,7 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) ProfileDir() string { return b.profileDir } +func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } func (b *Browser) ProfileName() string { if b.profileDir == "" { return "" diff --git a/browser/keydump.go b/browser/keydump.go new file mode 100644 index 0000000..6a4dd30 --- /dev/null +++ b/browser/keydump.go @@ -0,0 +1,71 @@ +package browser + +import ( + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" + "github.com/moond4rk/hackbrowserdata/log" +) + +// BuildDump exports per-installation master keys; profiles sharing (Browser, UserDataDir) collapse into one Vault. +// Browsers without KeyManager (Firefox/Safari) are skipped. ExportKeys is invoked exactly once per installation +// regardless of profile count or success. Partial results (e.g. V10 retrieved, V20 failed) keep the usable tiers +// rather than discarding the vault, matching getMasterKeys' behavior on the extraction path — a Chrome 127+ +// profile mixes v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key. +func BuildDump(browsers []Browser) keyretriever.Dump { + dump := keyretriever.NewDump() + groups, order := groupByInstallation(browsers) + for _, key := range order { + g := groups[key] + keys, err := g.km.ExportKeys() + if err != nil { + status := "partial" + if !keys.HasAny() { + status = "failed" + } + log.Warnf("dump-keys: %s/%s %s: %v", g.browser, g.profiles[0], status, err) + } + if !keys.HasAny() { + continue + } + dump.Vaults = append(dump.Vaults, keyretriever.Vault{ + Browser: g.browser, + UserDataDir: g.userDataDir, + Profiles: g.profiles, + Keys: keys, + }) + } + return dump +} + +type installGroup struct { + browser, userDataDir string + km KeyManager + profiles []string +} + +// groupByInstallation collects browsers into per-installation groups keyed by (BrowserName, UserDataDir), +// preserving the discovery order of the first profile in each group. Non-KeyManager browsers are skipped. +// Doing the grouping up front (rather than checking dump.Vaults profile-by-profile) makes the resulting +// Profiles list complete and order-independent even if the group's ExportKeys later fails. +func groupByInstallation(browsers []Browser) (map[string]*installGroup, []string) { + groups := make(map[string]*installGroup) + var order []string + for _, b := range browsers { + km, ok := b.(KeyManager) + if !ok { + continue + } + key := b.BrowserName() + "|" + b.UserDataDir() + if g, exists := groups[key]; exists { + g.profiles = append(g.profiles, b.ProfileName()) + continue + } + groups[key] = &installGroup{ + browser: b.BrowserName(), + userDataDir: b.UserDataDir(), + km: km, + profiles: []string{b.ProfileName()}, + } + order = append(order, key) + } + return groups, order +} diff --git a/browser/keydump_test.go b/browser/keydump_test.go new file mode 100644 index 0000000..c854fa4 --- /dev/null +++ b/browser/keydump_test.go @@ -0,0 +1,228 @@ +package browser + +import ( + "bytes" + "errors" + "runtime" + "testing" + + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" + "github.com/moond4rk/hackbrowserdata/types" +) + +const ( + testProfileDefault = "Default" + testProfile1 = "Profile 1" + testUDD = "/p" + testEdgeName = "Edge" +) + +type mockBrowser struct { + name, profile, profileDir, userDataDir string +} + +func (m *mockBrowser) BrowserName() string { return m.name } +func (m *mockBrowser) ProfileName() string { return m.profile } +func (m *mockBrowser) ProfileDir() string { return m.profileDir } +func (m *mockBrowser) UserDataDir() string { return m.userDataDir } + +func (m *mockBrowser) Extract(_ []types.Category) (*types.BrowserData, error) { + return &types.BrowserData{}, nil +} + +func (m *mockBrowser) CountEntries(_ []types.Category) (map[types.Category]int, error) { + return nil, nil +} + +type mockChromiumBrowser struct { + mockBrowser + keys keyretriever.MasterKeys + exportErr error + calls int +} + +func (m *mockChromiumBrowser) SetKeyRetrievers(_ keyretriever.Retrievers) {} + +func (m *mockChromiumBrowser) ExportKeys() (keyretriever.MasterKeys, error) { + m.calls++ + return m.keys, m.exportErr +} + +func TestBuildDump_Empty(t *testing.T) { + dump := BuildDump(nil) + if dump.Version != keyretriever.DumpVersion { + t.Errorf("Version = %q, want %q", dump.Version, keyretriever.DumpVersion) + } + if dump.Host.OS != runtime.GOOS { + t.Errorf("Host.OS = %q, want %q", dump.Host.OS, runtime.GOOS) + } + if len(dump.Vaults) != 0 { + t.Errorf("Vaults len = %d, want 0", len(dump.Vaults)) + } +} + +func TestBuildDump_SingleChromium(t *testing.T) { + b := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, profileDir: "/p/Default", userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte("v10-key")}, + } + + dump := BuildDump([]Browser{b}) + + if len(dump.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults)) + } + inst := dump.Vaults[0] + if inst.Browser != chromeName || inst.UserDataDir != testUDD { + t.Errorf("inst metadata = %+v", inst) + } + if len(inst.Profiles) != 1 || inst.Profiles[0] != testProfileDefault { + t.Errorf("Profiles = %v", inst.Profiles) + } + if string(inst.Keys.V10) != "v10-key" { + t.Errorf("Keys.V10 = %q", inst.Keys.V10) + } +} + +func TestBuildDump_MultipleProfilesSameInstallation(t *testing.T) { + p1 := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte("v10")}, + } + p2 := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfile1, userDataDir: testUDD}, + exportErr: errors.New("ExportKeys should not be called for second profile"), + } + + dump := BuildDump([]Browser{p1, p2}) + + if len(dump.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1 (same installation grouping)", len(dump.Vaults)) + } + if len(dump.Vaults[0].Profiles) != 2 { + t.Errorf("Profiles = %v, want both profiles", dump.Vaults[0].Profiles) + } +} + +func TestBuildDump_SkipsNonKeyManager(t *testing.T) { + chrome := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/chrome"}, + keys: keyretriever.MasterKeys{V10: []byte("v10")}, + } + firefox := &mockBrowser{name: firefoxName, profile: "default-release", userDataDir: "/ff"} + + dump := BuildDump([]Browser{chrome, firefox}) + + if len(dump.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1 (firefox skipped)", len(dump.Vaults)) + } + if dump.Vaults[0].Browser != chromeName { + t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName) + } +} + +func TestBuildDump_SkipsExportError(t *testing.T) { + good := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/chrome"}, + keys: keyretriever.MasterKeys{V10: []byte("v10")}, + } + failing := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: testEdgeName, profile: testProfileDefault, userDataDir: "/edge"}, + exportErr: errors.New("retriever failed"), + } + + dump := BuildDump([]Browser{good, failing}) + + if len(dump.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1 (failing browser skipped)", len(dump.Vaults)) + } + if dump.Vaults[0].Browser != chromeName { + t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName) + } +} + +func TestBuildDump_JSONRoundTrip(t *testing.T) { + b := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte{0x01, 0x02, 0x03}, V20: []byte{0xff, 0xee}}, + } + + dump := BuildDump([]Browser{b}) + + var buf bytes.Buffer + if err := dump.WriteJSON(&buf); err != nil { + t.Fatalf("WriteJSON: %v", err) + } + + parsed, err := keyretriever.ReadJSON(&buf) + if err != nil { + t.Fatalf("ReadJSON: %v", err) + } + + if parsed.Version != dump.Version { + t.Errorf("Version round-trip: got %q, want %q", parsed.Version, dump.Version) + } + if len(parsed.Vaults) != 1 { + t.Fatalf("Vaults len = %d", len(parsed.Vaults)) + } + if !bytes.Equal(parsed.Vaults[0].Keys.V10, dump.Vaults[0].Keys.V10) { + t.Errorf("V10 round-trip mismatch") + } + if !bytes.Equal(parsed.Vaults[0].Keys.V20, dump.Vaults[0].Keys.V20) { + t.Errorf("V20 round-trip mismatch") + } + if parsed.Vaults[0].Keys.V11 != nil { + t.Errorf("V11 should be omitted (nil), got %v", parsed.Vaults[0].Keys.V11) + } +} + +func TestBuildDump_PartialKeys(t *testing.T) { + b := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte("v10")}, + exportErr: errors.New("v20: ABE failed"), + } + + dump := BuildDump([]Browser{b}) + + if len(dump.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1 (partial result must be preserved)", len(dump.Vaults)) + } + if string(dump.Vaults[0].Keys.V10) != "v10" { + t.Errorf("V10 should be preserved despite V20 error, got %q", dump.Vaults[0].Keys.V10) + } + if dump.Vaults[0].Keys.V20 != nil { + t.Errorf("V20 should remain nil, got %v", dump.Vaults[0].Keys.V20) + } +} + +func TestBuildDump_GroupingOrderIndependent(t *testing.T) { + for _, name := range []string{"p1 first", "p2 first"} { + t.Run(name, func(t *testing.T) { + p1 := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte("v10")}, + } + p2 := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfile1, userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte("v10")}, + } + list := []Browser{p1, p2} + if name == "p2 first" { + list = []Browser{p2, p1} + } + + dump := BuildDump(list) + + if len(dump.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults)) + } + if len(dump.Vaults[0].Profiles) != 2 { + t.Errorf("Profiles = %v, want 2", dump.Vaults[0].Profiles) + } + if calls := p1.calls + p2.calls; calls != 1 { + t.Errorf("ExportKeys total calls = %d, want 1 (one call per installation)", calls) + } + }) + } +} diff --git a/browser/safari/safari.go b/browser/safari/safari.go index 039e452..e4d8b80 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -45,6 +45,7 @@ func resolveProfilePaths(p profileContext) map[types.Category]resolvedPath { func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) ProfileName() string { return b.profile.name } +func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } func (b *Browser) ProfileDir() string { if b.profile.isDefault() { diff --git a/cmd/hack-browser-data/keys.go b/cmd/hack-browser-data/keys.go new file mode 100644 index 0000000..66b4eea --- /dev/null +++ b/cmd/hack-browser-data/keys.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/moond4rk/hackbrowserdata/browser" + "github.com/moond4rk/hackbrowserdata/log" +) + +func keysCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "keys", + Short: "Manage cross-host master keys", + } + cmd.AddCommand(keysExportCmd()) + return cmd +} + +func keysExportCmd() *cobra.Command { + var ( + browserName string + outputPath string + keychainPw string + ) + + cmd := &cobra.Command{ + Use: "export", + Short: "Export Chromium master keys as JSON for cross-host decryption", + Example: ` hack-browser-data keys export -o dump.json + hack-browser-data keys export -b chrome`, + RunE: func(cmd *cobra.Command, args []string) error { + browsers, err := browser.PickBrowsers(browser.PickOptions{ + Name: browserName, + KeychainPassword: keychainPw, + }) + if err != nil { + return err + } + + dump := browser.BuildDump(browsers) + log.Infof("Exported keys for %d vault(s)", len(dump.Vaults)) + + if outputPath == "" { + return dump.WriteJSON(os.Stdout) + } + f, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create %s: %w", outputPath, err) + } + defer f.Close() + return dump.WriteJSON(f) + }, + } + + cmd.Flags().StringVarP(&browserName, "browser", "b", "all", "target browser: all|"+browser.Names()) + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file (default: stdout)") + cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password") + + return cmd +} diff --git a/cmd/hack-browser-data/main.go b/cmd/hack-browser-data/main.go index d0b9ccd..61a8acf 100644 --- a/cmd/hack-browser-data/main.go +++ b/cmd/hack-browser-data/main.go @@ -31,7 +31,7 @@ GitHub: https://github.com/moonD4rk/HackBrowserData`, root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable debug logging") dump := dumpCmd() - root.AddCommand(dump, listCmd(), versionCmd()) + root.AddCommand(dump, listCmd(), keysCmd(), versionCmd()) // Default to dump when no subcommand is given. // Copy dump flags to root so that `hack-browser-data -b chrome` diff --git a/crypto/keyretriever/dump.go b/crypto/keyretriever/dump.go new file mode 100644 index 0000000..4f1f394 --- /dev/null +++ b/crypto/keyretriever/dump.go @@ -0,0 +1,80 @@ +package keyretriever + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/user" + "runtime" + "time" +) + +const DumpVersion = "1" + +// Dump is the cross-host portable container for Chromium master keys. Producing it on one host lets another host skip +// platform-native retrieval (DPAPI, ABE injection, Keychain prompt, D-Bus query) when decrypting copied profile data. +type Dump struct { + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + Host Host `json:"host"` + Vaults []Vault `json:"vaults"` +} + +// Host OS / Arch always set; Hostname / User best-effort (empty on syscall failure). +type Host struct { + OS string `json:"os"` + Arch string `json:"arch"` + Hostname string `json:"hostname,omitempty"` + User string `json:"user,omitempty"` +} + +// Vault groups profiles sharing master keys (master keys are per-installation, not per-profile). +type Vault struct { + Browser string `json:"browser"` + UserDataDir string `json:"user_data_dir"` + Profiles []string `json:"profiles"` + Keys MasterKeys `json:"keys"` +} + +// NewDump returns a Dump initialized with current host metadata and an empty Vaults slice +func NewDump() Dump { + return Dump{ + Version: DumpVersion, + CreatedAt: time.Now().UTC(), + Host: currentHost(), + Vaults: []Vault{}, + } +} + +// currentHost collects host identification; Hostname/User are best-effort (syscall failure leaves them empty + omitempty). +func currentHost() Host { + h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH} + if name, err := os.Hostname(); err == nil { + h.Hostname = name + } + if u, err := user.Current(); err == nil { + h.User = u.Username + } + return h +} + +// WriteJSON writes the Dump as indented JSON to w. +func (d Dump) WriteJSON(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(d); err != nil { + return fmt.Errorf("encode dump: %w", err) + } + return nil +} + +// ReadJSON parses a Dump from r. +func ReadJSON(r io.Reader) (Dump, error) { + var d Dump + dec := json.NewDecoder(r) + if err := dec.Decode(&d); err != nil { + return Dump{}, fmt.Errorf("decode dump: %w", err) + } + return d, nil +} diff --git a/crypto/keyretriever/masterkeys.go b/crypto/keyretriever/masterkeys.go index f088cda..534fd23 100644 --- a/crypto/keyretriever/masterkeys.go +++ b/crypto/keyretriever/masterkeys.go @@ -9,9 +9,15 @@ import ( // (Chrome 127+ on Windows mixes v10+v20; Linux can mix v10+v11), so each tier must be populated // independently for lossless decryption. A nil tier means that cipher version cannot be decrypted. type MasterKeys struct { - V10 []byte - V11 []byte - V20 []byte + V10 []byte `json:"v10,omitempty"` + V11 []byte `json:"v11,omitempty"` + V20 []byte `json:"v20,omitempty"` +} + +// HasAny reports whether at least one tier carries a usable key. Centralizes the "is this MasterKeys +// worth keeping" check so new tiers (V21, V12, …) only need to be added here, not at every caller. +func (k MasterKeys) HasAny() bool { + return k.V10 != nil || k.V11 != nil || k.V20 != nil } // Retrievers is the per-tier retriever configuration; unused slots are nil.