@@ -17,8 +17,10 @@ package keychain
1717import (
1818 "context"
1919 "errors"
20+ "fmt"
2021 "iter"
2122 "maps"
23+ "strconv"
2224 "strings"
2325
2426 "github.com/danieljoos/wincred"
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
4258func 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+
68106type 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+
74146func (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
126217func 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 }
0 commit comments