Skip to content

Commit 86498ad

Browse files
authored
fix(credstore): stabilize macOS Keychain reads across rebuilds
Route macOS Keychain secret reads through the system-signed security executable so access grants can persist across CLI rebuilds. Preserve native Keychain writes and management operations, and clarify the dedicated ACL-repair procedure.
1 parent d1d6045 commit 86498ad

5 files changed

Lines changed: 143 additions & 2 deletions

File tree

‎credstore/osbackend_byteness.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package credstore
55
import (
66
"errors"
77
"fmt"
8+
"runtime"
89

910
"github.com/byteness/keyring"
1011
)
@@ -15,7 +16,11 @@ func openKeyringBackend(kind Backend, cfg backendConfig) (keyringBackend, error)
1516
if err != nil {
1617
return nil, fmt.Errorf("keyring open %s: %w", kind, err)
1718
}
18-
return bytenessBackend{kr: kr}, nil
19+
backend := bytenessBackend{kr: kr}
20+
if kind == BackendKeychain && runtime.GOOS == "darwin" {
21+
return keychainStableReadBackend{keyringBackend: backend, service: cfg.serviceName, read: readKeychainPassword}, nil
22+
}
23+
return backend, nil
1924
}
2025

2126
func keyringConfigFromBackendConfig(cfg backendConfig) keyring.Config {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//go:build cgo || windows
2+
3+
package credstore
4+
5+
import (
6+
"context"
7+
"errors"
8+
"fmt"
9+
"os/exec"
10+
"time"
11+
)
12+
13+
const keychainReadTimeout = 60 * time.Second
14+
15+
// keychainStableReadBackend retains native writes, metadata, enumeration and
16+
// deletion. Only secret reads use the system-signed security executable, so
17+
// a grant can survive replacement of an ad-hoc-signed CLI binary.
18+
type keychainStableReadBackend struct {
19+
keyringBackend
20+
service string
21+
read func(string, string) ([]byte, error)
22+
}
23+
24+
func (b keychainStableReadBackend) get(itemKey string) (keyringItem, error) {
25+
data, err := b.read(b.service, itemKey)
26+
if err != nil {
27+
return keyringItem{}, err
28+
}
29+
return keyringItem{key: itemKey, data: data}, nil
30+
}
31+
32+
func readKeychainPassword(service, account string) ([]byte, error) {
33+
ctx, cancel := context.WithTimeout(context.Background(), keychainReadTimeout)
34+
defer cancel()
35+
return readKeychainPasswordWith(ctx, service, account, func(ctx context.Context, args ...string) ([]byte, error) {
36+
// #nosec G204 -- fixed absolute executable and argv; no shell evaluates service or account.
37+
return exec.CommandContext(ctx, "/usr/bin/security", args...).Output()
38+
})
39+
}
40+
41+
func readKeychainPasswordWith(ctx context.Context, service, account string, run func(context.Context, ...string) ([]byte, error)) ([]byte, error) {
42+
output, err := run(ctx, "find-generic-password", "-s", service, "-a", account, "-w")
43+
if err != nil {
44+
var status interface{ ExitCode() int }
45+
if errors.As(err, &status) && status.ExitCode() == 44 {
46+
return nil, errKeyringItemNotFound
47+
}
48+
if ctx.Err() != nil {
49+
return nil, fmt.Errorf("macOS Keychain read timed out or was canceled: %w", ctx.Err())
50+
}
51+
return nil, fmt.Errorf("macOS Keychain read failed: %w", err)
52+
}
53+
// security -w prints one terminal newline after the credential.
54+
if len(output) > 0 && output[len(output)-1] == '\n' {
55+
output = output[:len(output)-1]
56+
}
57+
return output, nil
58+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//go:build cgo || windows
2+
3+
package credstore
4+
5+
import (
6+
"context"
7+
"errors"
8+
"reflect"
9+
"testing"
10+
)
11+
12+
type keychainExitCode int
13+
14+
func (e keychainExitCode) Error() string { return "security failed" }
15+
func (e keychainExitCode) ExitCode() int { return int(e) }
16+
17+
func TestStableKeychainReadUsesServiceAndAccount(t *testing.T) {
18+
var args []string
19+
want := []byte("secret\nwith trailing newline\n")
20+
got, err := readKeychainPasswordWith(context.Background(), "example-cli", "default/bundle", func(_ context.Context, command ...string) ([]byte, error) {
21+
args = command
22+
return append(append([]byte(nil), want...), '\n'), nil
23+
})
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
if !reflect.DeepEqual(args, []string{"find-generic-password", "-s", "example-cli", "-a", "default/bundle", "-w"}) {
28+
t.Fatalf("security args = %q", args)
29+
}
30+
if !reflect.DeepEqual(got, want) {
31+
t.Fatal("credential changed during read")
32+
}
33+
}
34+
35+
func TestStableKeychainReadErrorMapping(t *testing.T) {
36+
for _, tc := range []struct {
37+
name string
38+
err error
39+
want error
40+
}{
41+
{"missing", keychainExitCode(44), errKeyringItemNotFound},
42+
{"denied", keychainExitCode(36), keychainExitCode(36)},
43+
} {
44+
t.Run(tc.name, func(t *testing.T) {
45+
_, err := readKeychainPasswordWith(context.Background(), "svc", "acct", func(context.Context, ...string) ([]byte, error) {
46+
return nil, tc.err
47+
})
48+
if !errors.Is(err, tc.want) {
49+
t.Fatalf("read error = %v, want %v", err, tc.want)
50+
}
51+
})
52+
}
53+
}
54+
55+
func TestStableKeychainReadLeavesOtherOperationsNative(t *testing.T) {
56+
kr := &captureBytenessKeyring{}
57+
b := keychainStableReadBackend{
58+
keyringBackend: bytenessBackend{kr: kr},
59+
service: "svc",
60+
read: func(service, account string) ([]byte, error) {
61+
if service != "svc" || account != "default/token" {
62+
t.Fatalf("read %q/%q", service, account)
63+
}
64+
return []byte("secret"), nil
65+
},
66+
}
67+
item, err := b.get("default/token")
68+
if err != nil || string(item.data) != "secret" || kr.getCalls != 0 {
69+
t.Fatalf("get = %q, %v; native get calls = %d", item.data, err, kr.getCalls)
70+
}
71+
if err := b.set(keyringItem{key: "default/token", data: []byte("new")}); err != nil || kr.setCalls != 1 {
72+
t.Fatalf("native set error = %v, calls = %d", err, kr.setCalls)
73+
}
74+
}

‎docs/distribution.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ literal content hash — so every `brew upgrade` produces a new hash, the DR no
8484
matches, and the user is re-prompted. Any CLI whose `keychain_probe` (§8) selects the
8585
macOS Keychain backend has this problem.
8686

87+
`credstore` reads Keychain secrets through the fixed, system-signed `/usr/bin/security` executable while retaining native writes and management. That reduces rebuild prompts for consumers that adopt this version of the library; release signing remains required for direct Keychain users and for other native accesses by a CLI. A newly trusted helper may require one grant per existing item. Normal CLI operation must not rewrite credentials with `security -i`; the dedicated [`repair-macos-keychain-credentials.sh`](../scripts/repair-macos-keychain-credentials.sh) script uses it only when repairing an existing item's ACL.
88+
8789
**Standard: sign every darwin binary at release time with the family's single
8890
self-signed code-signing cert and a constant per-tool identifier.** That makes the DR
8991
`identifier "org.open-cli-collective.<binary>" and certificate leaf = H"<cert hash>"`

‎docs/working-with-secrets.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ The standard is a *better default*, not a security boundary. It removes the most
5151

5252
All Collective CLIs use **`github.com/byteness/keyring`** as the credential-store abstraction. (Migrated from `github.com/99designs/keyring` in #23 — ByteNess is an active fork that picks up CVE fixes and ongoing maintenance.)
5353

54-
- macOS → Keychain (Security framework, no shelling out).
54+
- macOS → Keychain (native writes, metadata, enumeration, and deletion; secret reads use the fixed system `/usr/bin/security` executable so Keychain grants can survive CLI rebuilds). Normal CLI credential writes use the native backend; never pass a secret on a command line or use `security -i` for those writes. The dedicated [`repair-macos-keychain-credentials.sh`](../scripts/repair-macos-keychain-credentials.sh) script uses `security -i` only to recreate existing items with repaired ACLs.
5555
- Windows → Credential Manager (`wincred`).
5656
- Linux → Secret Service (D-Bus), then file fallback (see §1.4).
5757
- A shared internal package, `cli-common/credstore`, wraps the library so every CLI uses the same backend priority, error messages, and config layout. CLIs do not depend on `byteness/keyring` directly.
@@ -138,6 +138,8 @@ deterministic user-facing label and description derived from the same mapping:
138138
application by default (`KeychainTrustApplication` / implied self). Existing
139139
items are not migrated in place: if an older item has blank metadata or older
140140
ACL state, it keeps that state until the CLI rewrites or overwrites it.
141+
The stable read path retains the same service/account mapping and strips only
142+
the terminal newline printed by `security -w`; it does not change stored items.
141143

142144
Because `/` is structural in this mapping, **`/` is forbidden inside any segment.** Allowed characters within `service`, `profile`, and `key` are `[A-Za-z0-9_-]`. The shared package rejects anything else at write time with a clear error. CLIs that need a richer identifier (e.g. an email address as a profile) must escape it; the shared package provides helpers.
143145

0 commit comments

Comments
 (0)