diff --git a/store/keychain/keychain_test.go b/store/keychain/keychain_test.go index 48028121..027ee8b2 100644 --- a/store/keychain/keychain_test.go +++ b/store/keychain/keychain_test.go @@ -17,6 +17,8 @@ package keychain import ( "context" "errors" + "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -253,6 +255,77 @@ func TestKeychain(t *testing.T) { }) }) + t.Run("save and get large JWT credential", func(t *testing.T) { + ks := setupKeychain(t, nil) + id := store.MustParseID("com.test.test/test/jwt-user") + + // Construct a fake JWT large enough that, when UTF-16 encoded on + // Windows, it exceeds the 2560-byte blob limit and must be chunked. + // Each ASCII character becomes 2 bytes in UTF-16, so we need the + // marshaled string to exceed 1280 characters. + largePayload := strings.Repeat("eyJzdWIiOiJ1c2VyMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0", 20) + largeJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + largePayload + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + creds := &mocks.MockCredential{ + Username: "alice", + Password: largeJWT, + } + t.Cleanup(func() { + require.NoError(t, ks.Delete(context.Background(), id)) + }) + require.NoError(t, ks.Save(t.Context(), id, creds)) + + secret, err := ks.Get(t.Context(), id) + require.NoError(t, err) + + actual, ok := secret.(*mocks.MockCredential) + require.True(t, ok) + actual.Attributes = nil + + assert.Equal(t, creds.Username, actual.Username) + assert.Equal(t, creds.Password, actual.Password) + }) + + t.Run("overwrite small credential with large JWT credential", func(t *testing.T) { + if runtime.GOOS == "darwin" { + // macOS AddItem does not update existing items; saving to an + // already-occupied key returns a duplicate-item error. + t.Skip("macOS does not support overwriting an existing credential") + } + + ks := setupKeychain(t, nil) + id := store.MustParseID("com.test.test/test/overwrite-user") + + smallCreds := &mocks.MockCredential{ + Username: "alice", + Password: "short", + } + t.Cleanup(func() { + require.NoError(t, ks.Delete(context.Background(), id)) + }) + require.NoError(t, ks.Save(t.Context(), id, smallCreds)) + + largePayload := strings.Repeat("eyJzdWIiOiJ1c2VyMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0", 20) + largeJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + largePayload + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + largeCreds := &mocks.MockCredential{ + Username: "alice", + Password: largeJWT, + } + + // On Linux and Windows the store replaces/clobbers the existing entry. + require.NoError(t, ks.Save(t.Context(), id, largeCreds)) + + secret, err := ks.Get(t.Context(), id) + require.NoError(t, err) + + actual, ok := secret.(*mocks.MockCredential) + require.True(t, ok) + actual.Attributes = nil + + assert.Equal(t, largeCreds.Username, actual.Username) + assert.Equal(t, largeCreds.Password, actual.Password) + }) + t.Run("delete credential", func(t *testing.T) { ks := setupKeychain(t, nil) id := store.MustParseID("com.test.test/test/bob") diff --git a/store/keychain/keychain_windows_test.go b/store/keychain/keychain_windows_test.go index ddad6b88..5aad8564 100644 --- a/store/keychain/keychain_windows_test.go +++ b/store/keychain/keychain_windows_test.go @@ -23,6 +23,9 @@ import ( "github.com/danieljoos/wincred" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/secrets-engine/store/mocks" ) func TestChunkBlob(t *testing.T) { @@ -72,6 +75,81 @@ func TestChunkBlob(t *testing.T) { }) } +func TestEncodeDecodeSecret(t *testing.T) { + t.Run("roundtrip small credential", func(t *testing.T) { + cred := &mocks.MockCredential{ + Username: "bob", + Password: "secret", + } + blob, err := encodeSecret(cred) + require.NoError(t, err) + + result := &mocks.MockCredential{} + require.NoError(t, decodeSecret(blob, result)) + assert.Equal(t, cred.Username, result.Username) + assert.Equal(t, cred.Password, result.Password) + }) + + t.Run("roundtrip large JWT credential exceeding maxBlobSize", func(t *testing.T) { + // Construct a fake JWT large enough to exceed maxBlobSize (2560 bytes) + // when UTF-16 encoded. Each ASCII character becomes 2 bytes in UTF-16, + // so the marshaled string must be longer than 1280 characters. + largePayload := strings.Repeat("eyJzdWIiOiJ1c2VyMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0", 20) + largeJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + largePayload + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + cred := &mocks.MockCredential{ + Username: "alice", + Password: largeJWT, + } + blob, err := encodeSecret(cred) + require.NoError(t, err) + assert.Greater(t, len(blob), maxBlobSize, "JWT credential should exceed maxBlobSize when UTF-16 encoded") + + // Verify that chunkBlob properly splits the oversized blob. + chunks := chunkBlob(blob, maxBlobSize) + assert.Greater(t, len(chunks), 1) + + // Reassemble chunks and decode back to verify no data is lost. + var reassembled []byte + for _, chunk := range chunks { + reassembled = append(reassembled, chunk...) + } + result := &mocks.MockCredential{} + require.NoError(t, decodeSecret(reassembled, result)) + assert.Equal(t, cred.Username, result.Username) + assert.Equal(t, cred.Password, result.Password) + }) + + t.Run("roundtrip multiple large JWTs as separate credentials", func(t *testing.T) { + largePayload := strings.Repeat("eyJzdWIiOiJ1c2VyMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0", 40) + veryLargeJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + largePayload + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + for _, tc := range []struct { + username string + password string + }{ + {"user1", veryLargeJWT}, + {"user2", veryLargeJWT}, + } { + cred := &mocks.MockCredential{Username: tc.username, Password: tc.password} + blob, err := encodeSecret(cred) + require.NoError(t, err) + assert.Greater(t, len(blob), maxBlobSize) + + chunks := chunkBlob(blob, maxBlobSize) + var reassembled []byte + for _, chunk := range chunks { + reassembled = append(reassembled, chunk...) + } + + result := &mocks.MockCredential{} + require.NoError(t, decodeSecret(reassembled, result)) + assert.Equal(t, tc.username, result.Username) + assert.Equal(t, tc.password, result.Password) + } + }) +} + func TestIsChunkCredential(t *testing.T) { t.Run("returns true when chunk:index attribute is present", func(t *testing.T) { attrs := []wincred.CredentialAttribute{