solana-kms-signer 0.1.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,446 @@
1
+ import { vi, beforeEach, describe, it, expect, Mock } from 'vitest';
2
+ import nacl from 'tweetnacl';
3
+ import {
4
+ PublicKey,
5
+ Transaction,
6
+ VersionedTransaction,
7
+ SystemProgram,
8
+ TransactionMessage,
9
+ Blockhash,
10
+ } from '@solana/web3.js';
11
+ import { SignatureVerificationError, KmsClientError } from '../errors/index.js';
12
+ import type { KmsConfig } from '../types/index.js';
13
+
14
+ // Mock KmsClient before importing SolanaKmsSigner
15
+ const mockGetPublicKey = vi.fn();
16
+ const mockSign = vi.fn();
17
+
18
+ vi.mock('./client.js', () => ({
19
+ KmsClient: class {
20
+ getPublicKey = mockGetPublicKey;
21
+ sign = mockSign;
22
+ },
23
+ }));
24
+
25
+ // Import after mocking
26
+ import { SolanaKmsSigner } from './signer.js';
27
+
28
+ /**
29
+ * Generate cryptographically valid test data using tweetnacl.
30
+ * This ensures our mock signatures can pass nacl.sign.detached.verify().
31
+ */
32
+ const testKeyPair = nacl.sign.keyPair();
33
+ const TEST_PUBLIC_KEY = testKeyPair.publicKey; // 32 bytes
34
+ const TEST_SECRET_KEY = testKeyPair.secretKey; // 64 bytes
35
+
36
+ /**
37
+ * Create valid DER-encoded public key matching AWS KMS format.
38
+ * Uses actual test public key for signature verification.
39
+ */
40
+ const createDerPublicKey = (publicKey: Uint8Array): Uint8Array => {
41
+ return new Uint8Array([
42
+ 0x30, 0x2a, // SEQUENCE, 42 bytes
43
+ 0x30, 0x05, // AlgorithmIdentifier SEQUENCE, 5 bytes
44
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID: 1.3.101.112 (Ed25519)
45
+ 0x03, 0x21, 0x00, // BIT STRING, 33 bytes (32 + 1 padding)
46
+ ...publicKey, // 32-byte ED25519 public key
47
+ ]);
48
+ };
49
+
50
+ const MOCK_DER_PUBLIC_KEY = createDerPublicKey(TEST_PUBLIC_KEY);
51
+
52
+ /**
53
+ * Mock KMS configuration
54
+ */
55
+ const MOCK_KMS_CONFIG: KmsConfig = {
56
+ region: 'us-east-1',
57
+ keyId: 'arn:aws:kms:us-east-1:123456789012:key/test-key-id',
58
+ };
59
+
60
+ /**
61
+ * Create cryptographically valid signature for a message.
62
+ * Uses the test secret key to generate signatures that will pass verification.
63
+ */
64
+ const createValidSignature = (message: Uint8Array): Uint8Array => {
65
+ return nacl.sign.detached(message, TEST_SECRET_KEY);
66
+ };
67
+
68
+ /**
69
+ * Mock recentBlockhash for transaction testing
70
+ */
71
+ const MOCK_RECENT_BLOCKHASH: Blockhash = 'GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC';
72
+
73
+ describe('SolanaKmsSigner', () => {
74
+ beforeEach(() => {
75
+ // Reset mock state before each test
76
+ mockGetPublicKey.mockReset();
77
+ mockSign.mockReset();
78
+ });
79
+
80
+ describe('Happy Path', () => {
81
+ it('should successfully retrieve PublicKey object and convert to Base58', async () => {
82
+ // given: SolanaKmsSigner with mocked KmsClient
83
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
84
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
85
+
86
+ // when: Getting public key
87
+ const publicKey = await signer.getPublicKey();
88
+
89
+ // then: Should return valid PublicKey object
90
+ expect(publicKey).toBeInstanceOf(PublicKey);
91
+ expect(publicKey.toBytes()).toEqual(TEST_PUBLIC_KEY);
92
+
93
+ // then: Should be able to convert to Base58 format
94
+ const base58 = publicKey.toBase58();
95
+ expect(typeof base58).toBe('string');
96
+ expect(base58.length).toBeGreaterThan(0);
97
+
98
+ // then: Should have called KMS getPublicKey once
99
+ expect(mockGetPublicKey).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ it('should cache public key and not call KMS API on second call', async () => {
103
+ // given: SolanaKmsSigner with mocked KmsClient
104
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
105
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
106
+
107
+ // when: Getting public key twice
108
+ const publicKey1 = await signer.getPublicKey();
109
+ const publicKey2 = await signer.getPublicKey();
110
+
111
+ // then: Should return same PublicKey instance
112
+ expect(publicKey1).toBe(publicKey2);
113
+
114
+ // then: KMS API should be called only once (cached after first call)
115
+ expect(mockGetPublicKey).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it('should successfully retrieve raw 32-byte public key', async () => {
119
+ // given: SolanaKmsSigner with mocked KmsClient
120
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
121
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
122
+
123
+ // when: Getting raw public key
124
+ const rawPublicKey = await signer.getRawPublicKey();
125
+
126
+ // then: Should return 32-byte Uint8Array
127
+ expect(rawPublicKey).toBeInstanceOf(Uint8Array);
128
+ expect(rawPublicKey.length).toBe(32);
129
+ expect(rawPublicKey).toEqual(TEST_PUBLIC_KEY);
130
+ });
131
+
132
+ it('should successfully sign message and return 64-byte signature', async () => {
133
+ // given: SolanaKmsSigner and message to sign
134
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
135
+ const message = new TextEncoder().encode('Hello, Solana!');
136
+ const validSignature = createValidSignature(message);
137
+ mockSign.mockResolvedValueOnce(validSignature);
138
+
139
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
140
+
141
+ // when: Signing message
142
+ const signature = await signer.signMessage(message);
143
+
144
+ // then: Should return 64-byte signature
145
+ expect(signature).toBeInstanceOf(Uint8Array);
146
+ expect(signature.length).toBe(64);
147
+
148
+ // then: Signature should be cryptographically valid
149
+ const isValid = nacl.sign.detached.verify(message, signature, TEST_PUBLIC_KEY);
150
+ expect(isValid).toBe(true);
151
+
152
+ // then: Should have called KMS sign
153
+ expect(mockSign).toHaveBeenCalledWith(message);
154
+ });
155
+
156
+ it('should successfully sign Transaction and validate signatures array', async () => {
157
+ // given: SolanaKmsSigner and valid Transaction
158
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
159
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
160
+
161
+ // Create valid transaction
162
+ const publicKey = await signer.getPublicKey();
163
+ const transaction = new Transaction().add(
164
+ SystemProgram.transfer({
165
+ fromPubkey: publicKey,
166
+ toPubkey: publicKey,
167
+ lamports: 1000,
168
+ })
169
+ );
170
+ transaction.recentBlockhash = MOCK_RECENT_BLOCKHASH;
171
+ transaction.feePayer = publicKey;
172
+
173
+ // Mock signature generation
174
+ const messageBytes = transaction.serializeMessage();
175
+ const validSignature = createValidSignature(messageBytes);
176
+ mockSign.mockResolvedValueOnce(validSignature);
177
+
178
+ // when: Signing transaction
179
+ const signedTx = await signer.signTransaction(transaction);
180
+
181
+ // then: Should return signed Transaction
182
+ expect(signedTx).toBeInstanceOf(Transaction);
183
+
184
+ // then: Signatures array should contain valid signature
185
+ expect(signedTx.signatures).toHaveLength(1);
186
+ expect(signedTx.signatures[0].publicKey.equals(publicKey)).toBe(true);
187
+ expect(signedTx.signatures[0].signature).not.toBeNull();
188
+ expect(signedTx.signatures[0].signature?.length).toBe(64);
189
+ });
190
+
191
+ it('should successfully sign VersionedTransaction', async () => {
192
+ // given: SolanaKmsSigner and valid VersionedTransaction
193
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
194
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
195
+
196
+ // Create valid versioned transaction
197
+ const publicKey = await signer.getPublicKey();
198
+ const instruction = SystemProgram.transfer({
199
+ fromPubkey: publicKey,
200
+ toPubkey: publicKey,
201
+ lamports: 1000,
202
+ });
203
+
204
+ const message = new TransactionMessage({
205
+ payerKey: publicKey,
206
+ recentBlockhash: MOCK_RECENT_BLOCKHASH,
207
+ instructions: [instruction],
208
+ }).compileToV0Message();
209
+
210
+ const transaction = new VersionedTransaction(message);
211
+
212
+ // Mock signature generation
213
+ const messageBytes = transaction.message.serialize();
214
+ const validSignature = createValidSignature(messageBytes);
215
+ mockSign.mockResolvedValueOnce(validSignature);
216
+
217
+ // when: Signing versioned transaction
218
+ const signedTx = await signer.signVersionedTransaction(transaction);
219
+
220
+ // then: Should return signed VersionedTransaction
221
+ expect(signedTx).toBeInstanceOf(VersionedTransaction);
222
+
223
+ // then: Signatures array should contain valid signature
224
+ expect(signedTx.signatures).toHaveLength(1);
225
+ expect(signedTx.signatures[0].length).toBe(64);
226
+ });
227
+
228
+ it('should successfully sign all transactions using Promise.all', async () => {
229
+ // given: SolanaKmsSigner and multiple transactions
230
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
231
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
232
+
233
+ // Create multiple valid transactions
234
+ const publicKey = await signer.getPublicKey();
235
+ const transactions = [1, 2, 3].map(() => {
236
+ const tx = new Transaction().add(
237
+ SystemProgram.transfer({
238
+ fromPubkey: publicKey,
239
+ toPubkey: publicKey,
240
+ lamports: 1000,
241
+ })
242
+ );
243
+ tx.recentBlockhash = MOCK_RECENT_BLOCKHASH;
244
+ tx.feePayer = publicKey;
245
+ return tx;
246
+ });
247
+
248
+ // Mock signature generation for all transactions
249
+ transactions.forEach((tx) => {
250
+ const messageBytes = tx.serializeMessage();
251
+ const validSignature = createValidSignature(messageBytes);
252
+ mockSign.mockResolvedValueOnce(validSignature);
253
+ });
254
+
255
+ // when: Signing all transactions
256
+ const signedTxs = await signer.signAllTransactions(transactions);
257
+
258
+ // then: Should return array of signed transactions
259
+ expect(signedTxs).toHaveLength(3);
260
+ signedTxs.forEach((signedTx) => {
261
+ expect(signedTx).toBeInstanceOf(Transaction);
262
+ expect(signedTx.signatures).toHaveLength(1);
263
+ expect(signedTx.signatures[0].signature).not.toBeNull();
264
+ });
265
+
266
+ // then: Should have called sign for each transaction
267
+ expect(mockSign).toHaveBeenCalledTimes(3);
268
+ });
269
+ });
270
+
271
+ describe('Failure Paths', () => {
272
+ it('should throw SignatureVerificationError when signature is invalid', async () => {
273
+ // given: SolanaKmsSigner and invalid signature from KMS
274
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
275
+ const message = new TextEncoder().encode('Hello, Solana!');
276
+
277
+ // Create invalid signature (random bytes that won't verify)
278
+ const invalidSignature = new Uint8Array(64).fill(0xff);
279
+ mockSign.mockResolvedValueOnce(invalidSignature);
280
+
281
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
282
+
283
+ // when: Signing message with invalid signature
284
+ const signPromise = signer.signMessage(message);
285
+
286
+ // then: Should throw SignatureVerificationError
287
+ await expect(signPromise).rejects.toThrow(SignatureVerificationError);
288
+ await expect(signPromise).rejects.toThrow('Signature verification failed');
289
+ });
290
+
291
+ it('should throw error when signature length is not 64 bytes', async () => {
292
+ // given: SolanaKmsSigner and wrong length signature from KMS
293
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
294
+ const message = new TextEncoder().encode('Hello, Solana!');
295
+
296
+ // Create wrong length signature (32 bytes instead of 64)
297
+ const wrongLengthSignature = new Uint8Array(32).fill(0xaa);
298
+ mockSign.mockResolvedValueOnce(wrongLengthSignature);
299
+
300
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
301
+
302
+ // when: Signing message with wrong length signature
303
+ const signPromise = signer.signMessage(message);
304
+
305
+ // then: Should throw error (nacl throws "bad signature size" Error)
306
+ await expect(signPromise).rejects.toThrow('bad signature size');
307
+ });
308
+
309
+ it('should throw error when signTransaction receives transaction without recentBlockhash', async () => {
310
+ // given: SolanaKmsSigner and transaction without recentBlockhash
311
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
312
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
313
+
314
+ const publicKey = await signer.getPublicKey();
315
+ const transaction = new Transaction().add(
316
+ SystemProgram.transfer({
317
+ fromPubkey: publicKey,
318
+ toPubkey: publicKey,
319
+ lamports: 1000,
320
+ })
321
+ );
322
+ // Note: not setting recentBlockhash or feePayer
323
+
324
+ // when: Attempting to sign invalid transaction
325
+ const signPromise = signer.signTransaction(transaction);
326
+
327
+ // then: Should throw error during serializeMessage()
328
+ await expect(signPromise).rejects.toThrow();
329
+ });
330
+
331
+ it('should propagate error when signTransaction message serialization fails', async () => {
332
+ // given: SolanaKmsSigner and transaction that will fail serialization
333
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
334
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
335
+
336
+ const publicKey = await signer.getPublicKey();
337
+ const transaction = new Transaction();
338
+ transaction.recentBlockhash = MOCK_RECENT_BLOCKHASH;
339
+ transaction.feePayer = publicKey;
340
+ // Empty transaction with no instructions
341
+
342
+ // Mock sign to throw error
343
+ mockSign.mockRejectedValueOnce(new Error('Message serialization error'));
344
+
345
+ // when: Attempting to sign with serialization error
346
+ const signPromise = signer.signTransaction(transaction);
347
+
348
+ // then: Error should propagate to caller
349
+ await expect(signPromise).rejects.toThrow();
350
+ });
351
+
352
+ it('should propagate KmsClientError when KMS getPublicKey fails', async () => {
353
+ // given: SolanaKmsSigner with KMS that fails getPublicKey
354
+ const kmsError = new KmsClientError('Failed to get public key from KMS');
355
+ mockGetPublicKey.mockRejectedValueOnce(kmsError);
356
+
357
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
358
+
359
+ // when: Getting public key from failing KMS
360
+ const getPublicKeyPromise = signer.getPublicKey();
361
+
362
+ // then: KmsClientError should propagate to caller
363
+ await expect(getPublicKeyPromise).rejects.toThrow(KmsClientError);
364
+ await expect(getPublicKeyPromise).rejects.toThrow('Failed to get public key from KMS');
365
+ });
366
+
367
+ it('should propagate KmsClientError when KMS sign fails', async () => {
368
+ // given: SolanaKmsSigner with KMS that fails sign
369
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
370
+ const kmsError = new KmsClientError('Failed to sign message with KMS');
371
+ mockSign.mockRejectedValueOnce(kmsError);
372
+
373
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
374
+ const message = new TextEncoder().encode('Hello, Solana!');
375
+
376
+ // when: Signing message with failing KMS
377
+ const signPromise = signer.signMessage(message);
378
+
379
+ // then: KmsClientError should propagate to caller
380
+ await expect(signPromise).rejects.toThrow(KmsClientError);
381
+ await expect(signPromise).rejects.toThrow('Failed to sign message with KMS');
382
+ });
383
+
384
+ it('should successfully create SolanaKmsSigner with KmsConfig', () => {
385
+ // given: Valid KmsConfig
386
+ const config: KmsConfig = MOCK_KMS_CONFIG;
387
+
388
+ // when: Creating signer with KmsConfig
389
+ const signer = new SolanaKmsSigner(config);
390
+
391
+ // then: Should create signer successfully
392
+ expect(signer).toBeInstanceOf(SolanaKmsSigner);
393
+ });
394
+
395
+ it('should return empty array when signAllTransactions receives empty array', async () => {
396
+ // given: SolanaKmsSigner and empty transaction array
397
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
398
+
399
+ // when: Signing empty array
400
+ const signedTxs = await signer.signAllTransactions([]);
401
+
402
+ // then: Should return empty array
403
+ expect(signedTxs).toEqual([]);
404
+ expect(signedTxs).toHaveLength(0);
405
+
406
+ // then: Should not call any KMS methods
407
+ expect(mockSign).not.toHaveBeenCalled();
408
+ });
409
+
410
+ it('should reject Promise.all when one transaction fails in signAllTransactions', async () => {
411
+ // given: SolanaKmsSigner and multiple transactions where one will fail
412
+ mockGetPublicKey.mockResolvedValueOnce(MOCK_DER_PUBLIC_KEY);
413
+ const signer = new SolanaKmsSigner(MOCK_KMS_CONFIG);
414
+
415
+ const publicKey = await signer.getPublicKey();
416
+ const transactions = [1, 2, 3].map(() => {
417
+ const tx = new Transaction().add(
418
+ SystemProgram.transfer({
419
+ fromPubkey: publicKey,
420
+ toPubkey: publicKey,
421
+ lamports: 1000,
422
+ })
423
+ );
424
+ tx.recentBlockhash = MOCK_RECENT_BLOCKHASH;
425
+ tx.feePayer = publicKey;
426
+ return tx;
427
+ });
428
+
429
+ // Mock first signature to succeed, second to fail
430
+ const firstMessage = transactions[0].serializeMessage();
431
+ const validSignature = createValidSignature(firstMessage);
432
+ mockSign.mockResolvedValueOnce(validSignature);
433
+
434
+ // Second call fails
435
+ const signError = new KmsClientError('KMS service unavailable');
436
+ mockSign.mockRejectedValueOnce(signError);
437
+
438
+ // when: Signing all transactions with one failure
439
+ const signAllPromise = signer.signAllTransactions(transactions);
440
+
441
+ // then: Promise.all should reject with the first error
442
+ await expect(signAllPromise).rejects.toThrow(KmsClientError);
443
+ await expect(signAllPromise).rejects.toThrow('KMS service unavailable');
444
+ });
445
+ });
446
+ });