Skip to content
37 changes: 35 additions & 2 deletions internal/keyring/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
// through `security -i` (interactive mode), whose command parser is line-based
// with a fixed 4096-byte line buffer, so Set rejects secrets containing
// newlines or exceeding that budget rather than silently corrupting them;
// Linux's secret-tool has no such restriction.
// Linux's secret-tool has no such restriction. MaxSecretLen reports that budget
// so a caller holding a larger value can split it across entries instead of
// being turned away.
package keyring

import (
Expand Down Expand Up @@ -82,7 +84,7 @@ func (k *Keyring) Set(service, account, secret string) error {
}
}
// -U updates the item if it already exists rather than failing.
line := "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
line := securityAddLine(service, account, secret)
if len(line) > securityMaxLine {
return wrap("set", fmt.Errorf("secret too large for the macOS security tool's %d-byte command line; use the file backend instead", securityMaxLine))
}
Expand Down Expand Up @@ -162,11 +164,42 @@ func (k *Keyring) Delete(service, account string) (bool, error) {
}
}

// MaxSecretLen reports the largest secret, in bytes, that Set accepts under
// (service, account) on this platform. ok is false when the backend imposes no
// practical limit, which is the case for Linux's secret-tool: it reads the
// secret from stdin rather than a command line. On macOS the secret shares the
// `security -i` command line with the service and account, so the budget
// depends on both and a caller that splits an oversized value must size each
// part against the exact account name it will be stored under.
//
// The budget assumes the secret needs no quote escaping. securityQuote only
// expands `\` and `"`, neither of which appears in base64 or hex, so a caller
// storing encoded data gets the exact figure.
func (k *Keyring) MaxSecretLen(service, account string) (int, bool) {
if k.goos != "darwin" {
return 0, false
}
// Measuring the real command with an empty secret counts the surrounding
// quotes too, so a secret of exactly this length lands on securityMaxLine.
budget := securityMaxLine - len(securityAddLine(service, account, ""))
if budget < 0 {
budget = 0
}
return budget, true
}

// securityMaxLine is the most `security -i` can read as one command: its line
// buffer is MAX_LINE_LEN (4096) bytes in Apple's SecurityTool, one of which the
// terminating NUL consumes.
const securityMaxLine = 4095

// securityAddLine renders the `security -i` command that stores secret under
// (service, account). Set writes it and MaxSecretLen measures it, so the two
// cannot disagree about how much of the line the overhead consumes.
func securityAddLine(service, account, secret string) string {
return "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
}

// securityQuote wraps s for one argument of a `security -i` command line. The
// tool's parser (split_line in Apple's SecurityTool) treats a backslash inside
// double quotes as escaping the next character and the matching quote as the
Expand Down
43 changes: 43 additions & 0 deletions internal/keyring/keyring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,46 @@ func TestAvailable(t *testing.T) {
t.Fatal("linux should be available")
}
}

// TestMaxSecretLenMatchesTheDarwinSetBoundary pins the budget to the boundary
// Set actually enforces. A caller that splits an oversized value depends on the
// two agreeing exactly: one byte of drift either wastes an entry or produces a
// chunk security silently splits into two garbage commands.
func TestMaxSecretLenMatchesTheDarwinSetBoundary(t *testing.T) {
k := newFake("darwin").keyring()
budget, bounded := k.MaxSecretLen("zero", "oauth-tokens")
if !bounded {
t.Fatal("MaxSecretLen reported darwin as unbounded")
}
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens", strings.Repeat("a", budget)); err != nil {
t.Errorf("Set(budget=%d bytes): %v, want acceptance at the boundary", budget, err)
}
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens", strings.Repeat("a", budget+1)); err == nil {
t.Errorf("Set(%d bytes) succeeded, want rejection one byte past the budget", budget+1)
}
}

// TestMaxSecretLenShrinksWithTheAccountName covers why the budget takes the
// account: on macOS the account and the secret share one command line, so a
// longer account name leaves less room for the secret.
func TestMaxSecretLenShrinksWithTheAccountName(t *testing.T) {
k := newFake("darwin").keyring()
short, _ := k.MaxSecretLen("zero", "oauth-tokens")
long, _ := k.MaxSecretLen("zero", "oauth-tokens.a.63")
if want := short - len(".a.63"); long != want {
t.Fatalf("MaxSecretLen for the longer account = %d, want %d", long, want)
}
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens.a.63", strings.Repeat("a", long+1)); err == nil {
t.Error("Set one byte past the longer account's budget succeeded")
}
}

// TestMaxSecretLenUnboundedOffDarwin keeps the limit platform-specific:
// secret-tool reads the secret from stdin, so there is no command line to fill.
func TestMaxSecretLenUnboundedOffDarwin(t *testing.T) {
for _, goos := range []string{"linux", "windows"} {
if n, bounded := newFake(goos).keyring().MaxSecretLen("zero", "oauth-tokens"); bounded || n != 0 {
t.Errorf("MaxSecretLen on %s = (%d, %v), want (0, false)", goos, n, bounded)
}
}
}
Loading
Loading