From f12d279fc209819d7d29d2bea13ca01bae310a97 Mon Sep 17 00:00:00 2001 From: AlbertSun Date: Tue, 26 May 2026 16:20:33 +0800 Subject: [PATCH] feat: add config keychain-downgrade subcommand (macOS) (#1085) * feat(config): add command to explicitly dowgrade keychain storage to use file * feat(config): add command to explicitly dowgrade keychain storage to use file * fix(lint): use the corresponding vfs.Xxx() from internal/vfs * fix: optimize scanError && osReadDir * opt: remove CmdConfigKeychainDowngrade wrapper & runF * fix: add downgrade hint on keychain blocked * opt: remove redundant ErrOrphanedCredentials * opt: fix suggested concurrent platformSet issue --- cmd/config/config.go | 1 + cmd/config/keychain_downgrade.go | 73 ++++++ cmd/config/keychain_downgrade_other.go | 28 ++ internal/keychain/keychain.go | 1 + internal/keychain/keychain_darwin.go | 129 +++++++++- internal/keychain/keychain_darwin_test.go | 301 ++++++++++++++++++++++ internal/keychain/keychain_hint_other.go | 10 + 7 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 cmd/config/keychain_downgrade.go create mode 100644 cmd/config/keychain_downgrade_other.go create mode 100644 internal/keychain/keychain_hint_other.go diff --git a/cmd/config/config.go b/cmd/config/config.go index c99f6b48..f3c643fd 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdConfigStrictMode(f)) cmd.AddCommand(NewCmdConfigPolicy(f)) cmd.AddCommand(NewCmdConfigPlugins(f)) + cmd.AddCommand(NewCmdConfigKeychainDowngrade(f)) return cmd } diff --git a/cmd/config/keychain_downgrade.go b/cmd/config/keychain_downgrade.go new file mode 100644 index 00000000..58c179d2 --- /dev/null +++ b/cmd/config/keychain_downgrade.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins +// the master key to the local file fallback (master.key.file) so subsequent +// operations bypass the OS Keychain. Useful inside sandboxes like Codex +// where the system Keychain is unreachable. +func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "keychain-downgrade", + Short: "Downgrade keychain storage to a local file (macOS only)", + Long: `Materialize the master key from the macOS system Keychain into a local file +under ~/Library/Application Support/lark-cli/master.key.file, then pin all +subsequent reads to that file. + +Intended workflow: run this once from an interactive Terminal session on +macOS (where the system Keychain is reachable). After it finishes, +sandboxed / automation / CI runs of lark-cli on the same machine will read +the master key from the local file and no longer need the OS Keychain. + +This is the supported fix for environments like the Codex sandbox where the +system Keychain is blocked. Running keychain-downgrade from inside such a +sandbox will itself fail with "keychain access blocked" — that is expected; +run it from an interactive macOS session instead. + +The OS Keychain entry is preserved as a cold backup; nothing is deleted there. +The command is idempotent: re-running it on an already-downgraded install +reports "already downgraded" and exits 0.`, + RunE: func(cmd *cobra.Command, args []string) error { + return configKeychainDowngradeRun(f) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func configKeychainDowngradeRun(f *cmdutil.Factory) error { + service := keychain.LarkCliService + keyPath := keychain.MasterKeyFilePath(service) + + result, err := keychain.DowngradeMasterKeyToFile(service) + if err != nil { + return output.ErrWithHint( + output.ExitAPI, + "config", + fmt.Sprintf("keychain downgrade failed: %v", err), + "This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.", + ) + } + + switch result { + case keychain.DowngradeAlreadyDone: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath)) + case keychain.DowngradeUsedKeychainKey: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath)) + case keychain.DowngradeCreatedNewKey: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath)) + } + return nil +} diff --git a/cmd/config/keychain_downgrade_other.go b/cmd/config/keychain_downgrade_other.go new file mode 100644 index 00000000..6255aee4 --- /dev/null +++ b/cmd/config/keychain_downgrade_other.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !darwin + +package config + +import ( + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigKeychainDowngrade is registered on all platforms so that +// `lark-cli config --help` reads the same everywhere. On non-macOS it +// refuses with a clear message. +func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { + _ = f + cmd := &cobra.Command{ + Use: "keychain-downgrade", + Short: "Downgrade keychain storage to a local file (macOS only)", + Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`, + RunE: func(cmd *cobra.Command, args []string) error { + return output.ErrValidation("keychain-downgrade is only supported on macOS") + }, + } + return cmd +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index e2cdecc1..3af04ca5 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -41,6 +41,7 @@ func wrapError(op string, err error) error { if errors.Is(err, errNotInitialized) { hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init." } + hint += extraHint(err) func() { defer func() { recover() }() diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 8e92e2bc..d92a0556 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -43,6 +43,12 @@ var keyringGet = keyring.Get // keyringSet is overridden in tests to simulate system keychain writes. var keyringSet = keyring.Set +// errKeychainBlocked is returned when the OS Keychain is reachable but +// denies access — sandbox restriction, user-denied prompt, or a 5-second +// timeout (typically caused by an ignored permission dialog). Distinct +// from errNotInitialized (master key entry genuinely absent). +var errKeychainBlocked = errors.New("keychain access blocked") + // StorageDir returns the storage directory for a given service name on macOS. func StorageDir(service string) string { home, err := vfs.UserHomeDir() @@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) { return } else if !errors.Is(err, keyring.ErrNotFound) { // Not ErrNotFound, which means access was denied or blocked by the system - resCh <- result{key: nil, err: errors.New("keychain access blocked")} + resCh <- result{key: nil, err: errKeychainBlocked} return } @@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) { return res.key, res.err case <-ctx.Done(): // Timeout is usually caused by ignored/blocked permission prompts - return nil, errors.New("keychain access blocked") + return nil, errKeychainBlocked } } @@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) { if err != nil { return "", err } - plaintext, err := decryptData(data, key) - if err != nil { - return "", err - } - return plaintext, nil + return decryptData(data, key) } // platformSet stores a value in the macOS keychain. @@ -316,3 +318,116 @@ func platformRemove(service, account string) error { } return nil } + +// DowngradeResult reports what DowngradeMasterKeyToFile did. The command +// never writes to or removes from the OS Keychain — it only reads from it +// and only writes to the local file fallback. +type DowngradeResult int + +const ( + // DowngradeAlreadyDone means master.key.file was already present and valid. + DowngradeAlreadyDone DowngradeResult = iota + // DowngradeUsedKeychainKey means the existing OS Keychain master key was + // copied verbatim into the local file fallback. Existing .enc credentials + // remain readable via the file path. + DowngradeUsedKeychainKey + // DowngradeCreatedNewKey means the OS Keychain held no master key, so a + // fresh random key was generated and written to the file fallback only. + // The OS Keychain was not touched. + DowngradeCreatedNewKey +) + +// MasterKeyFilePath returns the absolute path of the file fallback master key +// for the given service. +func MasterKeyFilePath(service string) string { + return filepath.Join(StorageDir(service), fileMasterKeyName) +} + +// DowngradeMasterKeyToFile materializes the OS Keychain master key into the +// local file fallback so that subsequent platformGet calls take the file-first +// path and bypass the OS Keychain entirely. The Keychain entry itself is kept +// as a cold backup; nothing is removed there. +// +// Idempotent: if master.key.file is already present and valid, returns +// DowngradeAlreadyDone without touching anything. +func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) { + dir := StorageDir(service) + keyPath := filepath.Join(dir, fileMasterKeyName) + + existing, statErr := vfs.ReadFile(keyPath) + if statErr == nil { + if len(existing) == masterKeyBytes { + return DowngradeAlreadyDone, nil + } + return 0, errors.New("keychain is corrupted") + } + if !errors.Is(statErr, os.ErrNotExist) { + return 0, statErr + } + + result := DowngradeUsedKeychainKey + key, err := getMasterKey(service, false) + if err != nil { + if !errors.Is(err, errNotInitialized) { + return 0, err + } + // Keychain has no master key. Generate a fresh one *locally* — do + // NOT call getMasterKey(service, true), which would write the new + // key into the OS Keychain as a side effect. keychain-downgrade + // must never modify the OS Keychain; it only ever reads from it. + key = make([]byte, masterKeyBytes) + if _, err := rand.Read(key); err != nil { + return 0, err + } + result = DowngradeCreatedNewKey + } + + if err := vfs.MkdirAll(dir, 0700); err != nil { + return 0, err + } + file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + concurrent, readErr := vfs.ReadFile(keyPath) + if readErr == nil && len(concurrent) == masterKeyBytes { + return DowngradeAlreadyDone, nil + } + if readErr != nil { + return 0, readErr + } + return 0, errors.New("keychain is corrupted") + } + return 0, err + } + writeFailed := true + defer func() { + if writeFailed { + _ = vfs.Remove(keyPath) + } + }() + if _, err := file.Write(key); err != nil { + _ = file.Close() + return 0, err + } + if err := file.Close(); err != nil { + return 0, err + } + writeFailed = false + return result, nil +} + +// extraHint appends a darwin-specific suggestion to wrapError's hint message +// when the failure is one keychain-downgrade can recover from: either the +// master key is missing (errNotInitialized) or the OS Keychain is reachable +// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout). +// In both cases the user can run keychain-downgrade from an interactive +// Terminal session, after which the file fallback is readable from any +// context (sandbox, automation, CI, etc.). Corruption errors are +// deliberately excluded — downgrade would re-read the same bad bytes and +// fail; the right fix there is to delete the corrupt Keychain entry first. +func extraHint(err error) string { + if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) { + return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)." + } + return "" +} diff --git a/internal/keychain/keychain_darwin_test.go b/internal/keychain/keychain_darwin_test.go index 5dc9ddb9..3d24ca75 100644 --- a/internal/keychain/keychain_darwin_test.go +++ b/internal/keychain/keychain_darwin_test.go @@ -10,8 +10,10 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" + "github.com/larksuite/cli/internal/output" "github.com/zalando/go-keyring" ) @@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) { } } +// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade +// when master.key.file already exists is a no-op and reports AlreadyDone +// without touching the system keychain. +func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + t.Fatalf("keyringGet should not be called when master.key.file is already valid") + return "", nil + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet should not be called when master.key.file is already valid") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + preExisting := make([]byte, masterKeyBytes) + for i := range preExisting { + preExisting[i] = byte(i + 7) + } + keyPath := filepath.Join(dir, fileMasterKeyName) + if err := os.WriteFile(keyPath, preExisting, 0600); err != nil { + t.Fatalf("WriteFile(master key) error = %v", err) + } + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeAlreadyDone { + t.Fatalf("result = %v, want DowngradeAlreadyDone", result) + } + + after, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytesEqual(after, preExisting) { + t.Fatalf("master.key.file content changed; want preserved") + } +} + +// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain +// key exists, the file does not, and downgrade copies the bytes verbatim +// so that existing .enc files (encrypted with the keychain key) remain +// readable via the file fallback. +func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + keychainKey := make([]byte, masterKeyBytes) + for i := range keychainKey { + keychainKey[i] = byte(i + 11) + } + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return base64.StdEncoding.EncodeToString(keychainKey), nil + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet should not be called when keychain already has a master key") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeUsedKeychainKey { + t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result) + } + + got, err := os.ReadFile(MasterKeyFilePath(service)) + if err != nil { + t.Fatalf("ReadFile(master.key.file) error = %v", err) + } + if !bytesEqual(got, keychainKey) { + t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable") + } +} + +// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user" +// path: keychain is empty and no .enc files exist, so we generate a new +// random key and write it to the file fallback. The OS Keychain is NOT +// modified (regression guard for the side-effecting getMasterKey(_, true) +// call we used to make). +func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return "", keyring.ErrNotFound + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeCreatedNewKey { + t.Fatalf("result = %v, want DowngradeCreatedNewKey", result) + } + + fileKey, err := os.ReadFile(MasterKeyFilePath(service)) + if err != nil { + t.Fatalf("ReadFile(master.key.file) error = %v", err) + } + if len(fileKey) != masterKeyBytes { + t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes) + } +} + +// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard +// for the TOCTOU between the initial existence check and the final write. +// Race trace the fix closes: +// +// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes) +// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B +// then writes .enc encrypted with K_B +// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc +// +// We simulate proc B's interleaving by performing the concurrent file write +// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back +// to the final OpenFile call, the file already exists, the O_EXCL branch +// fires, and the concurrent key is preserved verbatim. +func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + service := "test-service" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + concurrentKey := make([]byte, masterKeyBytes) + for i := range concurrentKey { + concurrentKey[i] = byte(i + 77) + } + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(svc, user string) (string, error) { + if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil { + t.Fatalf("simulated concurrent write failed: %v", err) + } + return "", keyring.ErrNotFound + } + keyringSet = func(svc, user, password string) error { + t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeAlreadyDone { + t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result) + } + got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName)) + if err != nil { + t.Fatalf("ReadFile error = %v", err) + } + if !bytesEqual(got, concurrentKey) { + t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned") + } +} + +// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked" +// (the sandbox case) propagates as errKeychainBlocked through platformGet, so +// the wrapError hint chain can attach the keychain-downgrade suggestion. +func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return "", errors.New("sandbox denied keychain access") + } + keyringSet = func(service, user, password string) error { + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + account := "test-account" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + lostKey := make([]byte, masterKeyBytes) + for i := range lostKey { + lostKey[i] = byte(i + 55) + } + encrypted, err := encryptData("secret", lostKey) + if err != nil { + t.Fatalf("encryptData() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil { + t.Fatalf("WriteFile(.enc) error = %v", err) + } + + _, err = platformGet(service, account) + if !errors.Is(err, errKeychainBlocked) { + t.Fatalf("err = %v, want errKeychainBlocked", err) + } +} + +// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression +// guard for the bug where `lark-cli api ...` inside a sandbox surfaced +// "keychain access blocked" but the hint did NOT mention keychain-downgrade +// — the very command meant to recover from that exact situation. Root cause: +// the blocked path used an anonymous errors.New string, so the extraHint +// `errors.Is` check (only matched errNotInitialized) couldn't recognize it. +// +// Asserts the full wrapError → ExitError.Detail.Hint pipeline: +// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade +// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention +// - generic errors → no mention +// +// Add new cases here whenever extraHint's matcher widens, to keep the +// promise that the hint is suggested iff downgrade can actually help. +func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) { + cases := []struct { + name string + err error + wantHint bool + }{ + {"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true}, + {"not initialized (missing master key)", errNotInitialized, true}, + {"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false}, + {"unrelated generic error", errors.New("something else entirely"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := wrapError("Get", tc.err) + var ee *output.ExitError + if !errors.As(err, &ee) || ee.Detail == nil { + t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err) + } + got := strings.Contains(ee.Detail.Hint, "keychain-downgrade") + if got != tc.wantHint { + t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint) + } + }) + } +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based // master key path once the fallback master key already exists. func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) { diff --git a/internal/keychain/keychain_hint_other.go b/internal/keychain/keychain_hint_other.go new file mode 100644 index 00000000..7e3d4fe3 --- /dev/null +++ b/internal/keychain/keychain_hint_other.go @@ -0,0 +1,10 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !darwin + +package keychain + +// extraHint is a no-op on non-darwin platforms. The keychain-downgrade +// command is macOS-only, so there is no extra suggestion to surface. +func extraHint(err error) string { return "" }