Skip to content

Commit a98ffc5

Browse files
committed
fix(keychain): store large blobs on windows
Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
1 parent ba825c1 commit a98ffc5

2 files changed

Lines changed: 215 additions & 7 deletions

File tree

store/keychain/keychain_windows.go

Lines changed: 148 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ package keychain
1717
import (
1818
"context"
1919
"errors"
20+
"fmt"
2021
"iter"
2122
"maps"
23+
"strconv"
2224
"strings"
2325

2426
"github.com/danieljoos/wincred"
@@ -38,6 +40,20 @@ var (
3840
sysErrNoSuchLogonSession = windows.ERROR_NO_SUCH_LOGON_SESSION
3941
)
4042

43+
const (
44+
// maxBlobSize is the maximum size of a Windows Credential Manager blob
45+
// (CRED_MAX_CREDENTIAL_BLOB_SIZE = 5 * 512 bytes).
46+
maxBlobSize = 2560
47+
48+
// chunkCountKey is stored in the primary credential's attributes when a
49+
// secret's encoded blob exceeds maxBlobSize and must be split.
50+
chunkCountKey = "chunk:count"
51+
52+
// chunkIndexKey is stored in each chunk credential's attributes to
53+
// identify it as a chunk and record its position.
54+
chunkIndexKey = "chunk:index"
55+
)
56+
4157
// encodeSecret marshals the secret into a slice of bytes in UTF16 format
4258
func encodeSecret(secret store.Secret) ([]byte, error) {
4359
data, err := secret.Marshal()
@@ -65,13 +81,72 @@ func decodeSecret(blob []byte, secret store.Secret) error {
6581
return secret.Unmarshal(val)
6682
}
6783

84+
// chunkBlob splits blob into consecutive slices each at most size bytes long.
85+
func chunkBlob(blob []byte, size int) [][]byte {
86+
var chunks [][]byte
87+
for len(blob) > 0 {
88+
n := min(size, len(blob))
89+
chunks = append(chunks, blob[:n])
90+
blob = blob[n:]
91+
}
92+
return chunks
93+
}
94+
95+
// isChunkCredential reports whether the given attributes belong to a chunk
96+
// credential (as opposed to a primary credential).
97+
func isChunkCredential(attrs []wincred.CredentialAttribute) bool {
98+
for _, attr := range attrs {
99+
if attr.Keyword == chunkIndexKey {
100+
return true
101+
}
102+
}
103+
return false
104+
}
105+
68106
type keychainStore[T store.Secret] struct {
69107
serviceGroup string
70108
serviceName string
71109
factory store.Factory[T]
72110
}
73111

112+
// itemChunkLabel returns the target name for the i-th chunk of a secret.
113+
func (k *keychainStore[T]) itemChunkLabel(id store.ID, index int) string {
114+
return fmt.Sprintf("%s:chunk:%d", k.itemLabel(id.String()), index)
115+
}
116+
117+
// readChunks fetches count chunk credentials for id and concatenates their
118+
// raw CredentialBlob bytes in order.
119+
func (k *keychainStore[T]) readChunks(id store.ID, count int) ([]byte, error) {
120+
var blob []byte
121+
for i := range count {
122+
gc, err := wincred.GetGenericCredential(k.itemChunkLabel(id, i))
123+
if err != nil {
124+
return nil, mapWindowsCredentialError(err)
125+
}
126+
blob = append(blob, gc.CredentialBlob...)
127+
}
128+
return blob, nil
129+
}
130+
131+
// deleteChunks removes chunk credentials for id until none remain.
132+
// It is safe to call when no chunks exist.
133+
func (k *keychainStore[T]) deleteChunks(id store.ID) error {
134+
for i := 0; ; i++ {
135+
g := wincred.NewGenericCredential(k.itemChunkLabel(id, i))
136+
err := g.Delete()
137+
if err != nil {
138+
if errors.Is(err, wincred.ErrElementNotFound) {
139+
return nil
140+
}
141+
return mapWindowsCredentialError(err)
142+
}
143+
}
144+
}
145+
74146
func (k *keychainStore[T]) Delete(_ context.Context, id store.ID) error {
147+
if err := k.deleteChunks(id); err != nil {
148+
return err
149+
}
75150
g := wincred.NewGenericCredential(k.itemLabel(id.String()))
76151
err := g.Delete()
77152
if err != nil && !errors.Is(err, wincred.ErrElementNotFound) {
@@ -87,13 +162,29 @@ func (k *keychainStore[T]) Get(ctx context.Context, id store.ID) (store.Secret,
87162
}
88163

89164
attributes := mapFromWindowsAttributes(gc.Attributes)
165+
166+
// Determine the raw UTF-16 blob before safelyCleanMetadata strips chunkCountKey.
167+
var rawBlob []byte
168+
if countStr, ok := attributes[chunkCountKey]; ok {
169+
count, err := strconv.Atoi(countStr)
170+
if err != nil {
171+
return nil, fmt.Errorf("invalid chunk count %q: %w", countStr, err)
172+
}
173+
rawBlob, err = k.readChunks(id, count)
174+
if err != nil {
175+
return nil, err
176+
}
177+
} else {
178+
rawBlob = gc.CredentialBlob
179+
}
180+
90181
safelyCleanMetadata(attributes)
91182

92183
secret := k.factory(ctx, id)
93184
if err := secret.SetMetadata(attributes); err != nil {
94185
return nil, err
95186
}
96-
if err := decodeSecret(gc.CredentialBlob, secret); err != nil {
187+
if err := decodeSecret(rawBlob, secret); err != nil {
97188
return nil, err
98189
}
99190
return secret, nil
@@ -126,6 +217,9 @@ func isServiceCredential[T store.Secret](k *keychainStore[T], attrs []wincred.Cr
126217
func findServiceCredentials[T store.Secret](k *keychainStore[T], pattern store.Pattern, credentials []*wincred.Credential) iter.Seq[*wincred.Credential] {
127218
return func(yield func(cred *wincred.Credential) bool) {
128219
for _, c := range credentials {
220+
if isChunkCredential(c.Attributes) {
221+
continue
222+
}
129223
if !isServiceCredential(k, c.Attributes) {
130224
continue
131225
}
@@ -204,10 +298,40 @@ func (k *keychainStore[T]) Save(_ context.Context, id store.ID, secret store.Sec
204298
safelySetMetadata(k.serviceGroup, k.serviceName, attributes)
205299
safelySetID(id, attributes)
206300

301+
// Always remove stale chunk credentials before writing, so that a
302+
// previously-chunked secret that now fits in a single blob leaves no
303+
// orphaned chunk credentials behind (and vice-versa).
304+
if err := k.deleteChunks(id); err != nil {
305+
return err
306+
}
307+
207308
g := wincred.NewGenericCredential(k.itemLabel(id.String()))
208309
g.UserName = id.String()
209-
g.CredentialBlob = blob
210310
g.Persist = wincred.PersistLocalMachine
311+
312+
// the blob is too large, we will chunk it across multiple entries
313+
if len(blob) > maxBlobSize {
314+
// Write chunk credentials for the oversized blob.
315+
chunks := chunkBlob(blob, maxBlobSize)
316+
for i, chunk := range chunks {
317+
gc := wincred.NewGenericCredential(k.itemChunkLabel(id, i))
318+
gc.UserName = id.String()
319+
gc.CredentialBlob = chunk
320+
gc.Persist = wincred.PersistLocalMachine
321+
gc.Attributes = mapToWindowsAttributes(map[string]string{
322+
chunkIndexKey: strconv.Itoa(i),
323+
})
324+
if err := mapWindowsCredentialError(gc.Write()); err != nil {
325+
return err
326+
}
327+
}
328+
// Write the primary credential with metadata and the chunk count.
329+
// The blob is stored in chunk credentials only.
330+
attributes[chunkCountKey] = strconv.Itoa(len(chunks))
331+
} else {
332+
g.CredentialBlob = blob
333+
}
334+
211335
g.Attributes = mapToWindowsAttributes(attributes)
212336
return mapWindowsCredentialError(g.Write())
213337
}
@@ -246,19 +370,36 @@ func (k *keychainStore[T]) Filter(ctx context.Context, pattern store.Pattern) (m
246370
return nil, mapWindowsCredentialError(err)
247371
}
248372

249-
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
250-
blob, _, err := transform.Bytes(decoder, gc.CredentialBlob)
251-
if err != nil {
252-
return nil, err
373+
gcAttributes := mapFromWindowsAttributes(gc.Attributes)
374+
375+
// Determine the raw UTF-16 blob before safelyCleanMetadata strips chunkCountKey.
376+
var rawBlob []byte
377+
if countStr, ok := gcAttributes[chunkCountKey]; ok {
378+
count, err := strconv.Atoi(countStr)
379+
if err != nil {
380+
return nil, fmt.Errorf("invalid chunk count %q: %w", countStr, err)
381+
}
382+
rawBlob, err = k.readChunks(id, count)
383+
if err != nil {
384+
return nil, err
385+
}
386+
} else {
387+
rawBlob = gc.CredentialBlob
253388
}
254389

255-
gcAttributes := mapFromWindowsAttributes(gc.Attributes)
256390
safelyCleanMetadata(gcAttributes)
257391

258392
secret := k.factory(ctx, id)
259393
if err := secret.SetMetadata(gcAttributes); err != nil {
260394
return nil, err
261395
}
396+
397+
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
398+
blob, _, err := transform.Bytes(decoder, rawBlob)
399+
if err != nil {
400+
return nil, err
401+
}
402+
262403
if err := secret.Unmarshal(blob); err != nil {
263404
return nil, err
264405
}

store/keychain/keychain_windows_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,73 @@ import (
2525
"github.com/stretchr/testify/assert"
2626
)
2727

28+
func TestChunkBlob(t *testing.T) {
29+
t.Run("empty blob returns no chunks", func(t *testing.T) {
30+
assert.Empty(t, chunkBlob(nil, 4))
31+
assert.Empty(t, chunkBlob([]byte{}, 4))
32+
})
33+
t.Run("blob smaller than size is a single chunk", func(t *testing.T) {
34+
blob := []byte{1, 2, 3}
35+
chunks := chunkBlob(blob, 4)
36+
assert.Len(t, chunks, 1)
37+
assert.Equal(t, blob, chunks[0])
38+
})
39+
t.Run("blob exactly size is a single chunk", func(t *testing.T) {
40+
blob := []byte{1, 2, 3, 4}
41+
chunks := chunkBlob(blob, 4)
42+
assert.Len(t, chunks, 1)
43+
assert.Equal(t, blob, chunks[0])
44+
})
45+
t.Run("blob splits into equal chunks", func(t *testing.T) {
46+
blob := []byte{1, 2, 3, 4, 5, 6, 7, 8}
47+
chunks := chunkBlob(blob, 4)
48+
assert.Len(t, chunks, 2)
49+
assert.Equal(t, []byte{1, 2, 3, 4}, chunks[0])
50+
assert.Equal(t, []byte{5, 6, 7, 8}, chunks[1])
51+
})
52+
t.Run("blob splits with remainder in last chunk", func(t *testing.T) {
53+
blob := []byte{1, 2, 3, 4, 5}
54+
chunks := chunkBlob(blob, 4)
55+
assert.Len(t, chunks, 2)
56+
assert.Equal(t, []byte{1, 2, 3, 4}, chunks[0])
57+
assert.Equal(t, []byte{5}, chunks[1])
58+
})
59+
t.Run("reassembled chunks equal original blob", func(t *testing.T) {
60+
blob := make([]byte, 2560*3+100)
61+
for i := range blob {
62+
blob[i] = byte(i % 256)
63+
}
64+
chunks := chunkBlob(blob, maxBlobSize)
65+
assert.Len(t, chunks, 4)
66+
67+
var reassembled []byte
68+
for _, c := range chunks {
69+
reassembled = append(reassembled, c...)
70+
}
71+
assert.Equal(t, blob, reassembled)
72+
})
73+
}
74+
75+
func TestIsChunkCredential(t *testing.T) {
76+
t.Run("returns true when chunk:index attribute is present", func(t *testing.T) {
77+
attrs := []wincred.CredentialAttribute{
78+
{Keyword: chunkIndexKey, Value: []byte("0")},
79+
}
80+
assert.True(t, isChunkCredential(attrs))
81+
})
82+
t.Run("returns false when chunk:index attribute is absent", func(t *testing.T) {
83+
attrs := []wincred.CredentialAttribute{
84+
{Keyword: serviceGroupKey, Value: []byte("group")},
85+
{Keyword: serviceNameKey, Value: []byte("name")},
86+
}
87+
assert.False(t, isChunkCredential(attrs))
88+
})
89+
t.Run("returns false for empty attributes", func(t *testing.T) {
90+
assert.False(t, isChunkCredential(nil))
91+
assert.False(t, isChunkCredential([]wincred.CredentialAttribute{}))
92+
})
93+
}
94+
2895
func TestMapWindowsAttributes(t *testing.T) {
2996
t.Run("can map to windows attributes", func(t *testing.T) {
3097
attributes := map[string]string{

0 commit comments

Comments
 (0)