Skip to content

Commit 918c7a0

Browse files
authored
Merge pull request #67 from docker/keychain-windows
store/keychain: windows support
2 parents ca3e869 + 6c380cb commit 918c7a0

24 files changed

Lines changed: 4274 additions & 38 deletions

File tree

.github/workflows/keychain.yml

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,27 +40,27 @@ jobs:
4040
install: true
4141
- name: Test
4242
run: DOCKER_TARGET=${{ matrix.subtest }} make keychain-linux-unit-tests
43-
# tests-windows:
44-
# permissions:
45-
# id-token: write
46-
# contents: read
47-
# name: WindowsKeychainTests
48-
# runs-on: ${{ matrix.os }}
49-
# strategy:
50-
# fail-fast: false
51-
# matrix:
52-
# os:
53-
# - windows-2022
54-
# - windows-2025
55-
# steps:
56-
# - name: Checkout
57-
# uses: actions/checkout@v4
58-
# - name: Setup Go
59-
# uses: actions/setup-go@v5
60-
# with:
61-
# go-version-file: ./store/go.mod
62-
# - name: Test keychain
63-
# run: make keychain-unit-tests
43+
tests-windows:
44+
permissions:
45+
id-token: write
46+
contents: read
47+
name: WindowsKeychainTests
48+
runs-on: ${{ matrix.os }}
49+
strategy:
50+
fail-fast: false
51+
matrix:
52+
os:
53+
- windows-2022
54+
- windows-2025
55+
steps:
56+
- name: Checkout
57+
uses: actions/checkout@v4
58+
- name: Setup Go
59+
uses: actions/setup-go@v5
60+
with:
61+
go-version-file: ./store/go.mod
62+
- name: Test keychain
63+
run: make keychain-unit-tests
6464
tests-macos:
6565
permissions:
6666
id-token: write

store/go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,20 @@ go 1.24.3
55
replace github.com/docker/secrets-engine => ../
66

77
require (
8+
github.com/danieljoos/wincred v1.2.2
89
github.com/docker/secrets-engine v0.0.0-00010101000000-000000000000
910
github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a
1011
github.com/keybase/go-keychain v0.0.1
11-
github.com/spf13/cobra v1.9.1
1212
github.com/stretchr/testify v1.10.0
13+
golang.org/x/sys v0.29.0
14+
golang.org/x/text v0.21.0
1315
)
1416

1517
require (
1618
github.com/davecgh/go-spew v1.1.1 // indirect
17-
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1819
github.com/kr/text v0.2.0 // indirect
1920
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
2021
github.com/pmezard/go-difflib v1.0.0 // indirect
21-
github.com/spf13/pflag v1.0.6 // indirect
2222
golang.org/x/crypto v0.32.0 // indirect
2323
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
2424
gopkg.in/yaml.v3 v3.0.1 // indirect

store/go.sum

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
1-
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
2+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
3+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
24
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
35
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4-
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
5-
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
66
github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a h1:K0EAzgzEQHW4Y5lxrmvPMltmlRDzlhLfGmots9EHUTI=
77
github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a/go.mod h1:YPNKjjE7Ubp9dTbnWvsP3HT+hYnY6TfXzubYTBeUxc8=
88
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
99
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
10+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
11+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
1012
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
13+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
1114
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
15+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
1216
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1317
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
14-
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
15-
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
16-
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
17-
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
18-
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
18+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
19+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
1920
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
2021
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2122
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
2223
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
24+
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
25+
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
26+
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
27+
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
2328
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2429
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
30+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2531
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2632
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

store/keychain/keychain_windows.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package keychain
2+
3+
import (
4+
"context"
5+
"errors"
6+
"iter"
7+
"strings"
8+
9+
"github.com/danieljoos/wincred"
10+
"github.com/docker/secrets-engine/store"
11+
"golang.org/x/sys/windows"
12+
"golang.org/x/text/encoding/unicode"
13+
"golang.org/x/text/transform"
14+
)
15+
16+
var (
17+
ErrCredentialBadUsername = errors.New("credential username is invalid")
18+
ErrInvalidCredentialFlags = errors.New("an invalid flag was specified for the flags parameter")
19+
ErrInvalidCredentialParameter = errors.New("protected field does not match provided value for an existing credential")
20+
ErrNoLogonSession = errors.New("logon session does not exist or there is no credential set associated with this logon session")
21+
sysErrInvalidCredentialFlags = windows.Errno(windows.ERROR_INVALID_FLAGS)
22+
sysErrNoSuchLogonSession = windows.Errno(windows.ERROR_NO_SUCH_LOGON_SESSION)
23+
)
24+
25+
// encodeSecret marshals the secret into a slice of bytes in UTF16 format
26+
func encodeSecret(secret store.Secret) ([]byte, error) {
27+
data, err := secret.Marshal()
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
33+
blob, _, err := transform.Bytes(encoder, data)
34+
if err != nil {
35+
return nil, err
36+
}
37+
return blob, nil
38+
}
39+
40+
// decodeSecret unmarshals the secret from UTF16 format to UTF8
41+
// secret will contain the unmarshaled value.
42+
func decodeSecret(blob []byte, secret store.Secret) error {
43+
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
44+
val, _, err := transform.Bytes(decoder, blob)
45+
if err != nil {
46+
return err
47+
}
48+
49+
return secret.Unmarshal(val)
50+
}
51+
52+
func (k *keychainStore[T]) Delete(ctx context.Context, id store.ID) error {
53+
if err := id.Valid(); err != nil {
54+
return err
55+
}
56+
57+
g := wincred.NewGenericCredential(k.itemLabel(id))
58+
err := g.Delete()
59+
if err != nil && !errors.Is(err, wincred.ErrElementNotFound) {
60+
return mapWindowsCredentialError(err)
61+
}
62+
return nil
63+
}
64+
65+
func (k *keychainStore[T]) Get(ctx context.Context, id store.ID) (store.Secret, error) {
66+
if err := id.Valid(); err != nil {
67+
return nil, err
68+
}
69+
70+
gc, err := wincred.GetGenericCredential(k.itemLabel(id))
71+
if err != nil {
72+
return nil, mapWindowsCredentialError(err)
73+
}
74+
75+
secret := k.factory()
76+
if err := decodeSecret(gc.CredentialBlob, secret); err != nil {
77+
return nil, err
78+
}
79+
return secret, nil
80+
}
81+
82+
// isServiceCredential checks if a credential attribute contains the
83+
// `service:group` and `service:name` attribute.
84+
//
85+
// The keychainStore serviceGroup and serviceName should match what is stored
86+
// in the attributes.
87+
func isServiceCredential[T store.Secret](k *keychainStore[T], attrs []wincred.CredentialAttribute) bool {
88+
// must have both serviceGroup and serviceName
89+
var (
90+
serviceName string
91+
serviceGroup string
92+
)
93+
for _, attr := range attrs {
94+
switch attr.Keyword {
95+
case "service:group":
96+
serviceGroup = string(attr.Value)
97+
case "service:name":
98+
serviceName = string(attr.Value)
99+
}
100+
}
101+
return strings.EqualFold(serviceGroup, k.serviceGroup) && strings.EqualFold(serviceName, k.serviceName)
102+
}
103+
104+
// findServiceCredentials is an iterator that yields credentials that match the
105+
// service group and service name.
106+
func findServiceCredentials[T store.Secret](k *keychainStore[T], credentials []*wincred.Credential) iter.Seq[*wincred.Credential] {
107+
return func(yield func(cred *wincred.Credential) bool) {
108+
for _, c := range credentials {
109+
if isServiceCredential(k, c.Attributes) {
110+
if !yield(c) {
111+
return
112+
}
113+
}
114+
}
115+
}
116+
}
117+
118+
func (k *keychainStore[T]) GetAll(ctx context.Context) (map[store.ID]store.Secret, error) {
119+
credentials, err := wincred.List()
120+
if err != nil {
121+
return nil, mapWindowsCredentialError(err)
122+
}
123+
124+
onlyLabelPrefix := k.itemLabel(store.ID(""))
125+
126+
secrets := make(map[store.ID]store.Secret, len(credentials))
127+
for cred := range findServiceCredentials(k, credentials) {
128+
secret := k.factory()
129+
id, err := store.ParseID(strings.ReplaceAll(cred.TargetName, onlyLabelPrefix, ""))
130+
if err != nil {
131+
return nil, err
132+
}
133+
134+
gc, err := wincred.GetGenericCredential(cred.TargetName)
135+
if err != nil {
136+
return nil, mapWindowsCredentialError(err)
137+
}
138+
139+
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
140+
blob, _, err := transform.Bytes(decoder, gc.CredentialBlob)
141+
if err != nil {
142+
return nil, err
143+
}
144+
if err := secret.Unmarshal(blob); err != nil {
145+
return nil, err
146+
}
147+
secrets[id] = secret
148+
}
149+
150+
return secrets, nil
151+
}
152+
153+
func (k *keychainStore[T]) Save(ctx context.Context, id store.ID, secret store.Secret) error {
154+
if err := id.Valid(); err != nil {
155+
return err
156+
}
157+
158+
blob, err := encodeSecret(secret)
159+
if err != nil {
160+
return err
161+
}
162+
163+
g := wincred.NewGenericCredential(k.itemLabel(id))
164+
g.UserName = id.String()
165+
g.CredentialBlob = blob
166+
g.Persist = wincred.PersistLocalMachine
167+
g.Attributes = []wincred.CredentialAttribute{
168+
{
169+
Keyword: "id",
170+
Value: []byte(id.String()),
171+
},
172+
{
173+
Keyword: "service:group",
174+
Value: []byte(k.serviceGroup),
175+
},
176+
{
177+
Keyword: "service:name",
178+
Value: []byte(k.serviceName),
179+
},
180+
}
181+
return mapWindowsCredentialError(g.Write())
182+
}
183+
184+
func mapWindowsCredentialError(err error) error {
185+
switch err {
186+
case wincred.ErrElementNotFound:
187+
return store.ErrCredentialNotFound
188+
case wincred.ErrBadUsername:
189+
return ErrCredentialBadUsername
190+
case wincred.ErrInvalidParameter:
191+
return ErrInvalidCredentialParameter
192+
case sysErrInvalidCredentialFlags:
193+
return ErrInvalidCredentialFlags
194+
case sysErrNoSuchLogonSession:
195+
return ErrNoLogonSession
196+
}
197+
return err
198+
}

vendor/github.com/danieljoos/wincred/.gitattributes

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/danieljoos/wincred/.gitignore

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/danieljoos/wincred/LICENSE

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)