macOS Keychain through security -i (the key goes in on stdin, hex encoded), the Secret Service through secret-tool (stdin), and the Windows Credential Manager through advapi32 with golang.org/x/sys. No new modules; tests use a recording runner and never touch a real keychain.
- commit
- 3605d716fe42da84fbfea814371197bac1ae002b
- parent
- 93d4cf726abdb8d498c050937310c14b260740c9
- author
- Dami <dami.etoile@gmail.com>
| M | go.mod | 2 | +- |
| A | internal/ | 78 | ++++++++++++++++ |
| A | internal/ | 122 | ++++++++++++++++++++++++++ |
| A | internal/ | 4 | + |
| A | internal/ | 6 | + |
| A | internal/ | 102 | ++++++++++++++++++++++ |
| A | internal/ | 142 | ++++++++++++++++++++++++++++++ |
@@ -12,6 +12,7 @@ require (
1212 github.com/parquet-go/parquet-go v0.32.01313 github.com/sahilm/fuzzy v0.1.31414 github.com/xuri/excelize/v2 v2.11.015+ golang.org/x/sys v0.47.01516 golang.org/x/text v0.38.01617 modernc.org/sqlite v1.59.01718 )@@ -47,7 +48,6 @@ require (
4748 golang.org/x/crypto v0.53.0 // indirect4849 golang.org/x/net v0.56.0 // indirect4950 golang.org/x/sync v0.22.0 // indirect50- golang.org/x/sys v0.47.0 // indirect5151 google.golang.org/protobuf v1.34.2 // indirect5252 modernc.org/libc v1.75.7 // indirect5353 modernc.org/mathutil v1.7.1 // indirect@@ -0,0 +1,78 @@
1+// Package keyring keeps the JEV API key in the operating system's2+// credential store: the macOS Keychain, the Windows Credential Manager,3+// or the freedesktop Secret Service on Linux and the BSDs (GNOME4+// Keyring, KWallet, KeePassXC). It needs nothing beyond the standard5+// library and golang.org/x/sys: on macOS and Linux it runs the system's6+// own command line tools (security, secret-tool), handing them the7+// secret on stdin so it never appears in a process list; on Windows it8+// calls the Credential Manager API directly. See docs/architecture.md.9+package keyring10+11+import (12+ "context"13+ "errors"14+ "sync"15+)16+17+// Service and Account name the stored item.18+const (19+ Service = "012"20+ Account = "jev-api-key"21+ Label = "012 JEV API key"22+)23+24+// ErrNotFound means no key is stored.25+var ErrNotFound = errors.New("no key in the credential store")26+27+// Store keeps one secret.28+type Store interface {29+ Get(ctx context.Context) (string, error)30+ Set(ctx context.Context, secret string) error31+ Delete(ctx context.Context) error32+ // Name is what the store is called, e.g. "macOS Keychain".33+ Name() string34+}35+36+// Memory is a Store in memory, for tests.37+type Memory struct {38+ mu sync.Mutex39+ secret string40+ Err error // returned by every call when set41+}42+43+func (m *Memory) Get(context.Context) (string, error) {44+ m.mu.Lock()45+ defer m.mu.Unlock()46+ if m.Err != nil {47+ return "", m.Err48+ }49+ if m.secret == "" {50+ return "", ErrNotFound51+ }52+ return m.secret, nil53+}54+55+func (m *Memory) Set(_ context.Context, s string) error {56+ m.mu.Lock()57+ defer m.mu.Unlock()58+ if m.Err != nil {59+ return m.Err60+ }61+ m.secret = s62+ return nil63+}64+65+func (m *Memory) Delete(context.Context) error {66+ m.mu.Lock()67+ defer m.mu.Unlock()68+ if m.Err != nil {69+ return m.Err70+ }71+ if m.secret == "" {72+ return ErrNotFound73+ }74+ m.secret = ""75+ return nil76+}77+78+func (m *Memory) Name() string { return "test store" }@@ -0,0 +1,122 @@
1+package keyring2+3+import (4+ "context"5+ "encoding/hex"6+ "errors"7+ "strings"8+ "testing"9+)10+11+// These tests never run security or secret-tool: a fake runner records12+// what would run, so the real keychain is never touched.13+14+const secret = "ts-secret-key-123"15+16+type call struct {17+ name string18+ args []string19+ stdin string20+}21+22+type fakeRunner struct {23+ calls []call24+ out string25+ err error26+}27+28+func (f *fakeRunner) run(_ context.Context, name string, args []string, stdin string) (string, error) {29+ f.calls = append(f.calls, call{name, args, stdin})30+ return f.out, f.err31+}32+33+// noSecretInArgs fails if the secret is in any argument, where other34+// users could see it in the process list.35+func noSecretInArgs(t *testing.T, calls []call) {36+ t.Helper()37+ for _, c := range calls {38+ for _, a := range c.args {39+ if strings.Contains(a, secret) || strings.Contains(a, hex.EncodeToString([]byte(secret))) {40+ t.Errorf("secret in argv: %s %q", c.name, c.args)41+ }42+ }43+ }44+}45+46+func TestKeychain(t *testing.T) {47+ ctx := context.Background()48+ f := &fakeRunner{}49+ k := keychain{run: f.run}50+ if err := k.Set(ctx, secret); err != nil {51+ t.Fatal(err)52+ }53+ noSecretInArgs(t, f.calls)54+ c := f.calls[0]55+ if c.name != "/usr/bin/security" || len(c.args) != 1 || c.args[0] != "-i" {56+ t.Errorf("set ran %s %q", c.name, c.args)57+ }58+ if !strings.Contains(c.stdin, "add-generic-password -U -s 012 -a jev-api-key") || !strings.Contains(c.stdin, "-X "+hex.EncodeToString([]byte(secret))) {59+ t.Errorf("set wrote %q", c.stdin)60+ }61+62+ f.out = secret + "\n"63+ if got, err := k.Get(ctx); err != nil || got != secret {64+ t.Errorf("get: %q %v", got, err)65+ }66+ f.out, f.err = "", &exitError{code: 44, stderr: "The specified item could not be found in the keychain."}67+ if _, err := k.Get(ctx); !errors.Is(err, ErrNotFound) {68+ t.Errorf("missing item: %v", err)69+ }70+ if err := k.Delete(ctx); !errors.Is(err, ErrNotFound) {71+ t.Errorf("delete missing: %v", err)72+ }73+ f.err = &exitError{code: 51, stderr: "user interaction is not allowed"}74+ if _, err := k.Get(ctx); err == nil || errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "interaction") {75+ t.Errorf("other failure: %v", err)76+ }77+}78+79+func TestSecretService(t *testing.T) {80+ ctx := context.Background()81+ f := &fakeRunner{}82+ s := secretService{run: f.run}83+ if err := s.Set(ctx, secret); err != nil {84+ t.Fatal(err)85+ }86+ noSecretInArgs(t, f.calls)87+ c := f.calls[0]88+ if c.name != "secret-tool" || c.args[0] != "store" || c.stdin != secret {89+ t.Errorf("set ran %s %q with stdin %q", c.name, c.args, c.stdin)90+ }91+ if strings.Join(c.args[2:], " ") != "service 012 account jev-api-key" {92+ t.Errorf("attributes %q", c.args)93+ }94+95+ f.out = secret96+ if got, err := s.Get(ctx); err != nil || got != secret {97+ t.Errorf("get: %q %v", got, err)98+ }99+ f.out, f.err = "", &exitError{code: 1}100+ if _, err := s.Get(ctx); !errors.Is(err, ErrNotFound) {101+ t.Errorf("missing: %v", err)102+ }103+ f.err = &exitError{code: 1, stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY"}104+ if _, err := s.Get(ctx); err == nil || errors.Is(err, ErrNotFound) {105+ t.Errorf("no D-Bus should be an error, not a missing key: %v", err)106+ }107+}108+109+func TestMemory(t *testing.T) {110+ ctx := context.Background()111+ m := &Memory{}112+ if _, err := m.Get(ctx); !errors.Is(err, ErrNotFound) {113+ t.Error(err)114+ }115+ m.Set(ctx, "k")116+ if got, _ := m.Get(ctx); got != "k" {117+ t.Error(got)118+ }119+ if err := m.Delete(ctx); err != nil {120+ t.Error(err)121+ }122+}@@ -0,0 +1,102 @@
1+package keyring2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "unsafe"8+9+ "golang.org/x/sys/windows"10+)11+12+// System is the Windows Credential Manager, as a generic credential13+// named "012:jev-api-key", through advapi32's CredReadW, CredWriteW and14+// CredDeleteW.15+func System() Store { return credManager{} }16+17+var (18+ advapi32 = windows.NewLazySystemDLL("advapi32.dll")19+ credRead = advapi32.NewProc("CredReadW")20+ credWrite = advapi32.NewProc("CredWriteW")21+ credDelete = advapi32.NewProc("CredDeleteW")22+ credFree = advapi32.NewProc("CredFree")23+)24+25+const (26+ credTypeGeneric = 127+ credPersistLocalMachine = 228+ target = Service + ":" + Account29+)30+31+// credential is CREDENTIALW.32+type credential struct {33+ Flags uint3234+ Type uint3235+ TargetName *uint1636+ Comment *uint1637+ LastWritten windows.Filetime38+ CredentialBlobSize uint3239+ CredentialBlob *byte40+ Persist uint3241+ AttributeCount uint3242+ Attributes uintptr43+ TargetAlias *uint1644+ UserName *uint1645+}46+47+type credManager struct{}48+49+func (credManager) Name() string { return "Windows Credential Manager" }50+51+func (credManager) Get(context.Context) (string, error) {52+ name, err := windows.UTF16PtrFromString(target)53+ if err != nil {54+ return "", err55+ }56+ var c *credential57+ r, _, callErr := credRead.Call(uintptr(unsafe.Pointer(name)), credTypeGeneric, 0, uintptr(unsafe.Pointer(&c)))58+ if r == 0 {59+ return "", wrap(callErr)60+ }61+ defer credFree.Call(uintptr(unsafe.Pointer(c)))62+ return string(unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize)), nil63+}64+65+func (credManager) Set(_ context.Context, secret string) error {66+ if len(secret) == 0 {67+ return errors.New("empty key")68+ }69+ name, err := windows.UTF16PtrFromString(target)70+ if err != nil {71+ return err72+ }73+ user, _ := windows.UTF16PtrFromString(Account)74+ comment, _ := windows.UTF16PtrFromString(Label)75+ blob := []byte(secret)76+ c := credential{77+ Type: credTypeGeneric, TargetName: name, Comment: comment, UserName: user,78+ CredentialBlobSize: uint32(len(blob)), CredentialBlob: &blob[0], Persist: credPersistLocalMachine,79+ }80+ if r, _, callErr := credWrite.Call(uintptr(unsafe.Pointer(&c)), 0); r == 0 {81+ return wrap(callErr)82+ }83+ return nil84+}85+86+func (credManager) Delete(context.Context) error {87+ name, err := windows.UTF16PtrFromString(target)88+ if err != nil {89+ return err90+ }91+ if r, _, callErr := credDelete.Call(uintptr(unsafe.Pointer(name)), credTypeGeneric, 0); r == 0 {92+ return wrap(callErr)93+ }94+ return nil95+}96+97+func wrap(err error) error {98+ if errors.Is(err, windows.ERROR_NOT_FOUND) {99+ return ErrNotFound100+ }101+ return fmt.Errorf("credential manager: %w", err)102+}@@ -0,0 +1,142 @@
1+package keyring2+3+import (4+ "bytes"5+ "context"6+ "encoding/hex"7+ "errors"8+ "fmt"9+ "os/exec"10+ "strings"11+)12+13+// runFunc runs a program with args, writing stdin to it, and returns its14+// stdout. Tests replace it to see exactly what would run.15+type runFunc func(ctx context.Context, name string, args []string, stdin string) (string, error)16+17+// exitError is a program that ran and failed.18+type exitError struct {19+ code int20+ stderr string21+}22+23+func (e *exitError) Error() string {24+ if e.stderr != "" {25+ return fmt.Sprintf("exit status %d: %s", e.code, e.stderr)26+ }27+ return fmt.Sprintf("exit status %d", e.code)28+}29+30+func run(ctx context.Context, name string, args []string, stdin string) (string, error) {31+ cmd := exec.CommandContext(ctx, name, args...)32+ cmd.Stdin = strings.NewReader(stdin)33+ var out, errOut bytes.Buffer34+ cmd.Stdout, cmd.Stderr = &out, &errOut35+ err := cmd.Run()36+ var ee *exec.ExitError37+ if errors.As(err, &ee) {38+ return out.String(), &exitError{code: ee.ExitCode(), stderr: firstLine(errOut.String())}39+ }40+ return out.String(), err41+}42+43+func firstLine(s string) string {44+ s, _, _ = strings.Cut(strings.TrimSpace(s), "\n")45+ return strings.TrimSpace(s)46+}47+48+func exitCode(err error) int {49+ var ee *exitError50+ if errors.As(err, &ee) {51+ return ee.code52+ }53+ return -154+}55+56+// keychain is the macOS Keychain, through /usr/bin/security. The secret57+// is written with "security -i", which reads its command from stdin, as58+// hex (-X), so it's never an argument of any process.59+type keychain struct{ run runFunc }60+61+// errItemNotFound is security's exit status for a missing item.62+const errItemNotFound = 4463+64+const securityPath = "/usr/bin/security"65+66+func (k keychain) Name() string { return "macOS Keychain" }67+68+func (k keychain) Get(ctx context.Context) (string, error) {69+ out, err := k.run(ctx, securityPath, []string{"find-generic-password", "-s", Service, "-a", Account, "-w"}, "")70+ if exitCode(err) == errItemNotFound {71+ return "", ErrNotFound72+ }73+ if err != nil {74+ return "", fmt.Errorf("keychain: %w", err)75+ }76+ return strings.TrimRight(out, "\r\n"), nil77+}78+79+func (k keychain) Set(ctx context.Context, secret string) error {80+ cmd := fmt.Sprintf("add-generic-password -U -s %s -a %s -l %q -X %s\n", Service, Account, Label, hex.EncodeToString([]byte(secret)))81+ if _, err := k.run(ctx, securityPath, []string{"-i"}, cmd); err != nil {82+ return fmt.Errorf("keychain: %w", err)83+ }84+ return nil85+}86+87+func (k keychain) Delete(ctx context.Context) error {88+ _, err := k.run(ctx, securityPath, []string{"delete-generic-password", "-s", Service, "-a", Account}, "")89+ if exitCode(err) == errItemNotFound {90+ return ErrNotFound91+ }92+ if err != nil {93+ return fmt.Errorf("keychain: %w", err)94+ }95+ return nil96+}97+98+// secretService is the freedesktop Secret Service (GNOME Keyring,99+// KWallet, KeePassXC), through libsecret's secret-tool, which reads the100+// secret to store from stdin.101+type secretService struct{ run runFunc }102+103+func (s secretService) Name() string { return "Secret Service" }104+105+func attrs(verb string, extra ...string) []string {106+ return append(append([]string{verb}, extra...), "service", Service, "account", Account)107+}108+109+func (s secretService) Get(ctx context.Context) (string, error) {110+ out, err := s.run(ctx, "secret-tool", attrs("lookup"), "")111+ var ee *exitError112+ switch {113+ case err == nil && out != "":114+ return strings.TrimRight(out, "\r\n"), nil115+ case err == nil, errors.As(err, &ee) && ee.code == 1 && ee.stderr == "":116+ return "", ErrNotFound // secret-tool exits 1, silently, when nothing matches117+ }118+ return "", s.wrap(err)119+}120+121+func (s secretService) Set(ctx context.Context, secret string) error {122+ _, err := s.run(ctx, "secret-tool", attrs("store", "--label="+Label), secret)123+ return s.wrap(err)124+}125+126+func (s secretService) Delete(ctx context.Context) error {127+ if _, err := s.Get(ctx); err != nil {128+ return err129+ }130+ _, err := s.run(ctx, "secret-tool", attrs("clear"), "")131+ return s.wrap(err)132+}133+134+func (s secretService) wrap(err error) error {135+ if errors.Is(err, exec.ErrNotFound) {136+ return errors.New("secret-tool isn't installed (libsecret-tools on Debian and Ubuntu, libsecret on Fedora and Arch)")137+ }138+ if err != nil {139+ return fmt.Errorf("secret service: %w", err)140+ }141+ return nil142+}