@solana/keychain-gcp-kms 0.0.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ import { address } from '@solana/addresses';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { assertIsSolanaSigner } from '@solana/keychain-core';
4
+
5
+ import { GcpKmsSigner } from '../gcp-kms-signer.js';
6
+ import type { GcpKmsSignerConfig } from '../types.js';
7
+
8
+ // Mock GCP KMS SDK
9
+ const mockAsymmetricSign = vi.fn();
10
+ const mockGetPublicKey = vi.fn();
11
+
12
+ vi.mock('@google-cloud/kms', () => {
13
+ return {
14
+ v1: {
15
+ KeyManagementServiceClient: class {
16
+ asymmetricSign = mockAsymmetricSign;
17
+ getPublicKey = mockGetPublicKey;
18
+ },
19
+ },
20
+ };
21
+ });
22
+
23
+ describe('GcpKmsSigner', () => {
24
+ const TEST_KEY_NAME =
25
+ 'projects/test-project/locations/us-east1/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1';
26
+ const TEST_PUBLIC_KEY = address('11111111111111111111111111111111');
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ describe('constructor', () => {
33
+ it('creates a GcpKmsSigner with valid config', () => {
34
+ const config: GcpKmsSignerConfig = {
35
+ keyName: TEST_KEY_NAME,
36
+ publicKey: TEST_PUBLIC_KEY,
37
+ };
38
+
39
+ const signer = new GcpKmsSigner(config);
40
+
41
+ expect(signer.address).toBe(TEST_PUBLIC_KEY);
42
+ assertIsSolanaSigner(signer);
43
+ expect(signer.signMessages).toBeDefined();
44
+ expect(signer.signTransactions).toBeDefined();
45
+ expect(signer.isAvailable).toBeDefined();
46
+ });
47
+
48
+ it('should throw error for missing keyName', () => {
49
+ expect(() => {
50
+ new GcpKmsSigner({
51
+ keyName: '',
52
+ publicKey: TEST_PUBLIC_KEY,
53
+ });
54
+ }).toThrow('Missing required keyName field');
55
+ });
56
+
57
+ it('should throw error for missing publicKey', () => {
58
+ expect(() => {
59
+ new GcpKmsSigner({
60
+ keyName: TEST_KEY_NAME,
61
+ publicKey: '',
62
+ });
63
+ }).toThrow('Missing required publicKey field');
64
+ });
65
+ it('should throw error for invalid public key', () => {
66
+ expect(() => {
67
+ new GcpKmsSigner({
68
+ keyName: TEST_KEY_NAME,
69
+ publicKey: 'invalid-key',
70
+ });
71
+ }).toThrow('Invalid Solana public key format');
72
+ });
73
+
74
+ it('should validate requestDelayMs', () => {
75
+ expect(() => {
76
+ new GcpKmsSigner({
77
+ keyName: TEST_KEY_NAME,
78
+ publicKey: TEST_PUBLIC_KEY,
79
+ requestDelayMs: -1,
80
+ });
81
+ }).toThrow('requestDelayMs must not be negative');
82
+ });
83
+
84
+ it('should warn for high requestDelayMs', () => {
85
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
86
+
87
+ new GcpKmsSigner({
88
+ keyName: TEST_KEY_NAME,
89
+ publicKey: TEST_PUBLIC_KEY,
90
+ requestDelayMs: 5000,
91
+ });
92
+
93
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('requestDelayMs is greater than 3000ms'));
94
+
95
+ warnSpy.mockRestore();
96
+ });
97
+ });
98
+
99
+ describe('signMessages', () => {
100
+ it('should sign a message successfully', async () => {
101
+ mockAsymmetricSign.mockResolvedValue([
102
+ {
103
+ signature: new Uint8Array(64).fill(0x42),
104
+ },
105
+ ]);
106
+
107
+ const signer = new GcpKmsSigner({
108
+ keyName: TEST_KEY_NAME,
109
+ publicKey: TEST_PUBLIC_KEY,
110
+ });
111
+
112
+ const message = {
113
+ content: new Uint8Array([1, 2, 3, 4]),
114
+ signatures: {},
115
+ };
116
+ const result = await signer.signMessages([message]);
117
+
118
+ expect(result).toHaveLength(1);
119
+ expect(result[0]?.[signer.address]).toBeDefined();
120
+ expect(mockAsymmetricSign).toHaveBeenCalledTimes(1);
121
+ expect(mockAsymmetricSign).toHaveBeenCalledWith({
122
+ data: message.content,
123
+ name: TEST_KEY_NAME,
124
+ });
125
+ });
126
+
127
+ it('should handle multiple messages with delay', async () => {
128
+ mockAsymmetricSign.mockResolvedValue([
129
+ {
130
+ signature: new Uint8Array(64).fill(0x42),
131
+ },
132
+ ]);
133
+
134
+ const signer = new GcpKmsSigner({
135
+ keyName: TEST_KEY_NAME,
136
+ publicKey: TEST_PUBLIC_KEY,
137
+ requestDelayMs: 10,
138
+ });
139
+
140
+ const messages = [
141
+ { content: new Uint8Array([1]), signatures: {} },
142
+ { content: new Uint8Array([2]), signatures: {} },
143
+ { content: new Uint8Array([3]), signatures: {} },
144
+ ] as any;
145
+
146
+ const startTime = Date.now();
147
+ const result = await signer.signMessages(messages);
148
+ const endTime = Date.now();
149
+
150
+ expect(result).toHaveLength(3);
151
+ expect(mockAsymmetricSign).toHaveBeenCalledTimes(3);
152
+ // Should have some delay (at least 15ms for 2 delays of 10ms each)
153
+ expect(endTime - startTime).toBeGreaterThanOrEqual(15);
154
+ });
155
+
156
+ it('should throw error on invalid signature length', async () => {
157
+ mockAsymmetricSign.mockResolvedValue([
158
+ {
159
+ signature: new Uint8Array(32), // Wrong length
160
+ },
161
+ ]);
162
+
163
+ const signer = new GcpKmsSigner({
164
+ keyName: TEST_KEY_NAME,
165
+ publicKey: TEST_PUBLIC_KEY,
166
+ });
167
+
168
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
169
+
170
+ await expect(signer.signMessages([message])).rejects.toThrow('Invalid signature length');
171
+ });
172
+
173
+ it('should throw error on missing signature', async () => {
174
+ mockAsymmetricSign.mockResolvedValue([{}]);
175
+
176
+ const signer = new GcpKmsSigner({
177
+ keyName: TEST_KEY_NAME,
178
+ publicKey: TEST_PUBLIC_KEY,
179
+ });
180
+
181
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
182
+
183
+ await expect(signer.signMessages([message])).rejects.toThrow('No signature in GCP KMS response');
184
+ });
185
+
186
+ it('should handle GCP KMS API errors', async () => {
187
+ const apiError = new Error('GCP Error');
188
+ (apiError as any).code = 403;
189
+ mockAsymmetricSign.mockRejectedValue(apiError);
190
+
191
+ const signer = new GcpKmsSigner({
192
+ keyName: TEST_KEY_NAME,
193
+ publicKey: TEST_PUBLIC_KEY,
194
+ });
195
+
196
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
197
+
198
+ await expect(signer.signMessages([message])).rejects.toThrow('GCP KMS Sign operation failed: GCP Error');
199
+ });
200
+ });
201
+
202
+ describe('signTransactions', () => {
203
+ it('should sign a transaction successfully', async () => {
204
+ mockAsymmetricSign.mockResolvedValue([
205
+ {
206
+ signature: new Uint8Array(64).fill(0x42),
207
+ },
208
+ ]);
209
+
210
+ const signer = new GcpKmsSigner({
211
+ keyName: TEST_KEY_NAME,
212
+ publicKey: TEST_PUBLIC_KEY,
213
+ });
214
+
215
+ const transaction = {
216
+ messageBytes: new Uint8Array([1, 2, 3, 4]),
217
+ signatures: {},
218
+ } as any;
219
+
220
+ const result = await signer.signTransactions([transaction]);
221
+
222
+ expect(result).toHaveLength(1);
223
+ expect(result[0]).toHaveProperty(signer.address);
224
+ expect(mockAsymmetricSign).toHaveBeenCalledTimes(1);
225
+ });
226
+
227
+ it('should sign multiple transactions successfully', async () => {
228
+ mockAsymmetricSign.mockResolvedValue([
229
+ {
230
+ signature: new Uint8Array(64).fill(0x42),
231
+ },
232
+ ]);
233
+
234
+ const signer = new GcpKmsSigner({
235
+ keyName: TEST_KEY_NAME,
236
+ publicKey: TEST_PUBLIC_KEY,
237
+ });
238
+
239
+ const transactions = [
240
+ { messageBytes: new Uint8Array([1]), signatures: {} },
241
+ { messageBytes: new Uint8Array([2]), signatures: {} },
242
+ ] as any;
243
+
244
+ const result = await signer.signTransactions(transactions);
245
+
246
+ expect(result).toHaveLength(2);
247
+ expect(mockAsymmetricSign).toHaveBeenCalledTimes(2);
248
+ });
249
+
250
+ it('should throw error on invalid signature length', async () => {
251
+ mockAsymmetricSign.mockResolvedValue([
252
+ {
253
+ signature: new Uint8Array(32),
254
+ },
255
+ ]);
256
+
257
+ const signer = new GcpKmsSigner({
258
+ keyName: TEST_KEY_NAME,
259
+ publicKey: TEST_PUBLIC_KEY,
260
+ });
261
+
262
+ const transaction = { messageBytes: new Uint8Array([1, 2, 3, 4]), signatures: {} } as any;
263
+
264
+ await expect(signer.signTransactions([transaction])).rejects.toThrow('Invalid signature length');
265
+ });
266
+
267
+ it('should throw error on missing signature', async () => {
268
+ mockAsymmetricSign.mockResolvedValue([{}]);
269
+
270
+ const signer = new GcpKmsSigner({
271
+ keyName: TEST_KEY_NAME,
272
+ publicKey: TEST_PUBLIC_KEY,
273
+ });
274
+
275
+ const transaction = { messageBytes: new Uint8Array([1, 2, 3, 4]), signatures: {} } as any;
276
+
277
+ await expect(signer.signTransactions([transaction])).rejects.toThrow('No signature in GCP KMS response');
278
+ });
279
+
280
+ it('should handle GCP KMS API errors', async () => {
281
+ const apiError = new Error('GCP Error');
282
+ (apiError as any).code = 403;
283
+ mockAsymmetricSign.mockRejectedValue(apiError);
284
+
285
+ const signer = new GcpKmsSigner({
286
+ keyName: TEST_KEY_NAME,
287
+ publicKey: TEST_PUBLIC_KEY,
288
+ });
289
+
290
+ const transaction = { messageBytes: new Uint8Array([1, 2, 3, 4]), signatures: {} } as any;
291
+
292
+ await expect(signer.signTransactions([transaction])).rejects.toThrow(
293
+ 'GCP KMS Sign operation failed: GCP Error',
294
+ );
295
+ });
296
+ });
297
+
298
+ describe('isAvailable', () => {
299
+ it('should return true for valid Ed25519 key', async () => {
300
+ mockGetPublicKey.mockResolvedValue([
301
+ {
302
+ name: TEST_KEY_NAME,
303
+ algorithm: 'EC_SIGN_ED25519',
304
+ },
305
+ ]);
306
+
307
+ const signer = new GcpKmsSigner({
308
+ keyName: TEST_KEY_NAME,
309
+ publicKey: TEST_PUBLIC_KEY,
310
+ });
311
+
312
+ const available = await signer.isAvailable();
313
+
314
+ expect(available).toBe(true);
315
+ });
316
+
317
+ it('should return false for wrong algorithm', async () => {
318
+ mockGetPublicKey.mockResolvedValue([
319
+ {
320
+ name: TEST_KEY_NAME,
321
+ algorithm: 'RSA_SIGN_PKCS1_2048_SHA256',
322
+ },
323
+ ]);
324
+
325
+ const signer = new GcpKmsSigner({
326
+ keyName: TEST_KEY_NAME,
327
+ publicKey: TEST_PUBLIC_KEY,
328
+ });
329
+
330
+ const available = await signer.isAvailable();
331
+
332
+ expect(available).toBe(false);
333
+ });
334
+
335
+ it('should return false for missing public key response', async () => {
336
+ mockGetPublicKey.mockResolvedValue([undefined]);
337
+
338
+ const signer = new GcpKmsSigner({
339
+ keyName: TEST_KEY_NAME,
340
+ publicKey: TEST_PUBLIC_KEY,
341
+ });
342
+
343
+ const available = await signer.isAvailable();
344
+
345
+ expect(available).toBe(false);
346
+ });
347
+
348
+ it('should return false on error', async () => {
349
+ mockGetPublicKey.mockRejectedValue(new Error('GCP error'));
350
+
351
+ const signer = new GcpKmsSigner({
352
+ keyName: TEST_KEY_NAME,
353
+ publicKey: TEST_PUBLIC_KEY,
354
+ });
355
+
356
+ const available = await signer.isAvailable();
357
+
358
+ expect(available).toBe(false);
359
+ });
360
+ });
361
+ });
@@ -0,0 +1,191 @@
1
+ import { v1 } from '@google-cloud/kms';
2
+ import { Address, assertIsAddress } from '@solana/addresses';
3
+ import { createSignatureDictionary, SignerErrorCode, SolanaSigner, throwSignerError } from '@solana/keychain-core';
4
+ import { SignatureBytes } from '@solana/keys';
5
+ import { SignableMessage, SignatureDictionary } from '@solana/signers';
6
+ import { Transaction, TransactionWithinSizeLimit, TransactionWithLifetime } from '@solana/transactions';
7
+
8
+ import type { GcpKmsSignerConfig } from './types.js';
9
+
10
+ /**
11
+ * Google Cloud KMS-based signer using EdDSA (Ed25519) signing
12
+ *
13
+ * The GCP KMS key must be created with:
14
+ * - Algorithm: EC_SIGN_ED25519
15
+ * - Purpose: ASYMMETRIC_SIGN
16
+ *
17
+ * Example gcloud CLI command to create a key:
18
+ * ```bash
19
+ * gcloud kms keys create my-key \
20
+ * --keyring=my-keyring \
21
+ * --location=us-east1 \
22
+ * --purpose=asymmetric-signing \
23
+ * --default-algorithm=ec-sign-ed25519
24
+ * ```
25
+ */
26
+ export class GcpKmsSigner<TAddress extends string = string> implements SolanaSigner<TAddress> {
27
+ readonly address: Address<TAddress>;
28
+ private readonly keyName: string;
29
+ private readonly client: v1.KeyManagementServiceClient;
30
+ private readonly requestDelayMs: number;
31
+
32
+ constructor(config: GcpKmsSignerConfig) {
33
+ if (!config.keyName) {
34
+ throwSignerError(SignerErrorCode.CONFIG_ERROR, {
35
+ message: 'Missing required keyName field',
36
+ });
37
+ }
38
+
39
+ if (!config.publicKey) {
40
+ throwSignerError(SignerErrorCode.CONFIG_ERROR, {
41
+ message: 'Missing required publicKey field',
42
+ });
43
+ }
44
+
45
+ try {
46
+ assertIsAddress(config.publicKey);
47
+ this.address = config.publicKey as Address<TAddress>;
48
+ } catch (error) {
49
+ throwSignerError(SignerErrorCode.CONFIG_ERROR, {
50
+ cause: error,
51
+ message: 'Invalid Solana public key format',
52
+ });
53
+ }
54
+
55
+ this.keyName = config.keyName;
56
+ this.requestDelayMs = config.requestDelayMs || 0;
57
+ this.validateRequestDelayMs(this.requestDelayMs);
58
+ this.client = new v1.KeyManagementServiceClient();
59
+ }
60
+
61
+ /**
62
+ * Validate request delay ms
63
+ */
64
+ private validateRequestDelayMs(requestDelayMs: number): void {
65
+ if (requestDelayMs < 0) {
66
+ throwSignerError(SignerErrorCode.CONFIG_ERROR, {
67
+ message: 'requestDelayMs must not be negative',
68
+ });
69
+ }
70
+ if (requestDelayMs > 3000) {
71
+ console.warn(
72
+ 'requestDelayMs is greater than 3000ms, this may result in blockhash expiration errors for signing messages/transactions',
73
+ );
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Add delay between concurrent requests
79
+ */
80
+ private async delay(index: number): Promise<void> {
81
+ if (this.requestDelayMs > 0 && index > 0) {
82
+ await new Promise(resolve => setTimeout(resolve, index * this.requestDelayMs));
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Sign message bytes using GCP KMS EdDSA signing
88
+ */
89
+ private async signBytes(messageBytes: Uint8Array): Promise<SignatureBytes> {
90
+ try {
91
+ // GCP KMS AsymmetricSign expects the raw message for Ed25519 (PureEdDSA)
92
+ const [response] = await this.client.asymmetricSign({
93
+ data: messageBytes,
94
+ name: this.keyName,
95
+ });
96
+
97
+ if (!response.signature) {
98
+ throwSignerError(SignerErrorCode.REMOTE_API_ERROR, {
99
+ message: 'No signature in GCP KMS response',
100
+ });
101
+ }
102
+
103
+ // Ed25519 signatures are 64 bytes
104
+ const signature = response.signature as Uint8Array;
105
+ if (signature.length !== 64) {
106
+ throwSignerError(SignerErrorCode.SIGNING_FAILED, {
107
+ message: `Invalid signature length: expected 64 bytes, got ${signature.length}`,
108
+ });
109
+ }
110
+
111
+ return signature as SignatureBytes;
112
+ } catch (error: unknown) {
113
+ // Re-throw SignerError as-is
114
+ if (error instanceof Error && error.name === 'SignerError') {
115
+ throw error;
116
+ }
117
+ if (error instanceof Error) {
118
+ // GCP SDK errors
119
+ const gcpError = error as { code?: number; message?: string };
120
+ throwSignerError(SignerErrorCode.REMOTE_API_ERROR, {
121
+ cause: error,
122
+ message: `GCP KMS Sign operation failed: ${gcpError.message || error.message}`,
123
+ status: gcpError.code,
124
+ });
125
+ }
126
+ throwSignerError(SignerErrorCode.REMOTE_API_ERROR, {
127
+ cause: error,
128
+ message: 'GCP KMS Sign operation failed',
129
+ });
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Sign multiple messages using GCP KMS
135
+ */
136
+ async signMessages(messages: readonly SignableMessage[]): Promise<readonly SignatureDictionary[]> {
137
+ return await Promise.all(
138
+ messages.map(async (message, index) => {
139
+ await this.delay(index);
140
+ const messageBytes =
141
+ message.content instanceof Uint8Array
142
+ ? message.content
143
+ : new Uint8Array(Array.from(message.content));
144
+ const signatureBytes = await this.signBytes(messageBytes);
145
+ return createSignatureDictionary({
146
+ signature: signatureBytes,
147
+ signerAddress: this.address,
148
+ });
149
+ }),
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Sign multiple transactions using GCP KMS
155
+ */
156
+ async signTransactions(
157
+ transactions: readonly (Transaction & TransactionWithinSizeLimit & TransactionWithLifetime)[],
158
+ ): Promise<readonly SignatureDictionary[]> {
159
+ return await Promise.all(
160
+ transactions.map(async (transaction, index) => {
161
+ await this.delay(index);
162
+ // Sign the transaction message bytes
163
+ const signatureBytes = await this.signBytes(new Uint8Array(transaction.messageBytes));
164
+ return createSignatureDictionary({
165
+ signature: signatureBytes,
166
+ signerAddress: this.address,
167
+ });
168
+ }),
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Check if GCP KMS is available and the key is accessible
174
+ */
175
+ async isAvailable(): Promise<boolean> {
176
+ try {
177
+ const [publicKey] = await this.client.getPublicKey({
178
+ name: this.keyName,
179
+ });
180
+
181
+ if (!publicKey) {
182
+ return false;
183
+ }
184
+
185
+ // Verify the algorithm is EC_SIGN_ED25519
186
+ return publicKey.algorithm === 'EC_SIGN_ED25519';
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { GcpKmsSigner } from './gcp-kms-signer.js';
2
+ export type { GcpKmsSignerConfig } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Configuration for creating a GcpKmsSigner
3
+ */
4
+ export interface GcpKmsSignerConfig {
5
+ /** Full resource name of the crypto key version */
6
+ keyName: string;
7
+ /** Solana public key (base58-encoded) */
8
+ publicKey: string;
9
+ /** Optional delay in ms between concurrent signing requests to avoid rate limits (default: 0) */
10
+ requestDelayMs?: number;
11
+ }