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,61 @@
1
+ /**
2
+ * Custom error class for AWS KMS API-related errors.
3
+ *
4
+ * Thrown when AWS KMS operations fail, including:
5
+ * - GetPublicKey failures (AccessDeniedException, KeyNotFoundException, etc.)
6
+ * - Sign failures (ThrottlingException, KeyUnavailableException, etc.)
7
+ * - Network timeouts and service unavailability
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * throw new KmsClientError('Failed to get public key from KMS', originalError);
12
+ * ```
13
+ */
14
+ export class KmsClientError extends Error {
15
+ constructor(message: string, public readonly cause?: unknown) {
16
+ super(message);
17
+ this.name = 'KmsClientError';
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Custom error class for DER-encoded public key extraction errors.
23
+ *
24
+ * Thrown when extracting ED25519 public key from DER format fails, including:
25
+ * - Invalid DER structure (missing SEQUENCE tag)
26
+ * - Missing BIT STRING in DER encoding
27
+ * - Unexpected BIT STRING length (not 33 bytes)
28
+ * - Malformed SubjectPublicKeyInfo structure
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * throw new PublicKeyExtractionError('Invalid DER encoding: missing SEQUENCE tag');
33
+ * ```
34
+ */
35
+ export class PublicKeyExtractionError extends Error {
36
+ constructor(message: string, public readonly cause?: unknown) {
37
+ super(message);
38
+ this.name = 'PublicKeyExtractionError';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Custom error class for ED25519 signature verification errors.
44
+ *
45
+ * Thrown when signature validation fails, including:
46
+ * - Signature verification returns false (invalid signature)
47
+ * - Incorrect signature length (not 64 bytes)
48
+ * - Signature does not match public key and message
49
+ * - Cryptographic verification failure
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * throw new SignatureVerificationError('Signature verification failed', { signature, publicKey });
54
+ * ```
55
+ */
56
+ export class SignatureVerificationError extends Error {
57
+ constructor(message: string, public readonly cause?: unknown) {
58
+ super(message);
59
+ this.name = 'SignatureVerificationError';
60
+ }
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Solana KMS Signer
3
+ *
4
+ * A TypeScript library for signing Solana transactions using AWS KMS with ED25519 keys.
5
+ *
6
+ * @module solana-kms-signer
7
+ */
8
+
9
+ // Core classes
10
+ export { KmsClient } from './kms/client.js';
11
+ export { SolanaKmsSigner } from './kms/signer.js';
12
+
13
+ // Utility functions
14
+ export { extractEd25519PublicKey } from './utils/publicKey.js';
15
+
16
+ // Type definitions
17
+ export type { KmsConfig, SolanaKmsSignerConfig } from './types/index.js';
18
+
19
+ // Error classes
20
+ export {
21
+ KmsClientError,
22
+ PublicKeyExtractionError,
23
+ SignatureVerificationError,
24
+ } from './errors/index.js';
25
+
26
+ // Re-export commonly used Solana types for convenience
27
+ export { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js';
@@ -0,0 +1,285 @@
1
+ import { vi, beforeEach, describe, it, expect } from 'vitest';
2
+ import { KmsClient } from './client.js';
3
+ import { KmsClientError } from '../errors/index.js';
4
+ import type { KmsConfig } from '../types/index.js';
5
+
6
+ /**
7
+ * Mock DER-encoded ED25519 public key matching AWS KMS GetPublicKey format.
8
+ * Reuses the same structure as publicKey.test.ts for consistency.
9
+ */
10
+ const MOCK_DER_PUBLIC_KEY = new Uint8Array([
11
+ 0x30, 0x2a, // SEQUENCE, 42 bytes
12
+ 0x30, 0x05, // AlgorithmIdentifier SEQUENCE, 5 bytes
13
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID: 1.3.101.112 (Ed25519)
14
+ 0x03, 0x21, 0x00, // BIT STRING, 33 bytes (32 + 1 padding)
15
+ // Mock 32-byte ED25519 public key
16
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
17
+ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
18
+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
19
+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
20
+ ]);
21
+
22
+ /**
23
+ * Mock ED25519 signature (64 bytes)
24
+ * ED25519 signatures are always exactly 64 bytes
25
+ */
26
+ const MOCK_SIGNATURE = new Uint8Array(64).fill(0xab);
27
+
28
+ /**
29
+ * Mock KMS configuration for testing
30
+ */
31
+ const MOCK_KMS_CONFIG: KmsConfig = {
32
+ region: 'us-east-1',
33
+ keyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012',
34
+ };
35
+
36
+ // Mock AWS SDK client and commands
37
+ const mockSend = vi.fn();
38
+
39
+ vi.mock('@aws-sdk/client-kms', () => {
40
+ return {
41
+ KMSClient: class {
42
+ send = mockSend;
43
+ },
44
+ GetPublicKeyCommand: class {
45
+ constructor(public params: any) {}
46
+ },
47
+ SignCommand: class {
48
+ constructor(public params: any) {}
49
+ },
50
+ };
51
+ });
52
+
53
+ describe('KmsClient', () => {
54
+ beforeEach(() => {
55
+ // Reset mock state before each test
56
+ mockSend.mockReset();
57
+ });
58
+
59
+ describe('Happy Path', () => {
60
+ it('should successfully retrieve public key as DER-encoded Uint8Array', async () => {
61
+ // given: KmsClient instance and mocked successful KMS response
62
+ const client = new KmsClient(MOCK_KMS_CONFIG);
63
+ mockSend.mockResolvedValueOnce({
64
+ PublicKey: MOCK_DER_PUBLIC_KEY,
65
+ });
66
+
67
+ // when: Getting public key from KMS
68
+ const publicKey = await client.getPublicKey();
69
+
70
+ // then: Should return DER-encoded public key as Uint8Array
71
+ expect(publicKey).toBeInstanceOf(Uint8Array);
72
+ expect(publicKey.length).toBe(MOCK_DER_PUBLIC_KEY.length);
73
+ expect(publicKey).toEqual(MOCK_DER_PUBLIC_KEY);
74
+
75
+ // then: Should have called KMS with correct parameters
76
+ expect(mockSend).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('should successfully sign message and return 64-byte signature', async () => {
80
+ // given: KmsClient instance and message to sign
81
+ const client = new KmsClient(MOCK_KMS_CONFIG);
82
+ const message = new Uint8Array([1, 2, 3, 4, 5]);
83
+
84
+ mockSend.mockResolvedValueOnce({
85
+ Signature: MOCK_SIGNATURE,
86
+ });
87
+
88
+ // when: Signing message with KMS
89
+ const signature = await client.sign(message);
90
+
91
+ // then: Should return 64-byte signature
92
+ expect(signature).toBeInstanceOf(Uint8Array);
93
+ expect(signature.length).toBe(64);
94
+ expect(signature).toEqual(MOCK_SIGNATURE);
95
+
96
+ // then: Should have called KMS with correct SigningAlgorithm
97
+ expect(mockSend).toHaveBeenCalledTimes(1);
98
+ const signCommand = mockSend.mock.calls[0][0];
99
+ expect(signCommand.params.SigningAlgorithm).toBe('ED25519_SHA_512');
100
+ expect(signCommand.params.MessageType).toBe('RAW');
101
+ expect(signCommand.params.Message).toEqual(message);
102
+ });
103
+ });
104
+
105
+ describe('Failure Paths', () => {
106
+ it('should throw KmsClientError when getPublicKey receives AccessDeniedException', async () => {
107
+ // given: KmsClient and mocked AccessDeniedException
108
+ const client = new KmsClient(MOCK_KMS_CONFIG);
109
+ const awsError = new Error('User: arn:aws:iam::123456789012:user/test is not authorized');
110
+ awsError.name = 'AccessDeniedException';
111
+ mockSend.mockRejectedValueOnce(awsError);
112
+
113
+ // when: Getting public key with insufficient permissions
114
+ const getPublicKeyPromise = client.getPublicKey();
115
+
116
+ // then: Should throw KmsClientError with cause
117
+ await expect(getPublicKeyPromise).rejects.toThrow(KmsClientError);
118
+ await expect(getPublicKeyPromise).rejects.toThrow('Failed to get public key from KMS');
119
+
120
+ // then: Verify cause is preserved in error
121
+ mockSend.mockRejectedValueOnce(awsError);
122
+ try {
123
+ await client.getPublicKey();
124
+ } catch (error) {
125
+ expect(error).toBeInstanceOf(KmsClientError);
126
+ expect((error as KmsClientError).cause).toBe(awsError);
127
+ }
128
+ });
129
+
130
+ it('should throw KmsClientError when getPublicKey receives KeyNotFoundException', async () => {
131
+ // given: KmsClient and mocked KeyNotFoundException
132
+ const client = new KmsClient(MOCK_KMS_CONFIG);
133
+ const awsError = new Error('Key arn:aws:kms:us-east-1:123456789012:key/nonexistent is not found');
134
+ awsError.name = 'KeyNotFoundException';
135
+ mockSend.mockRejectedValueOnce(awsError);
136
+
137
+ // when: Getting public key for non-existent key
138
+ const getPublicKeyPromise = client.getPublicKey();
139
+
140
+ // then: Should throw KmsClientError with cause
141
+ await expect(getPublicKeyPromise).rejects.toThrow(KmsClientError);
142
+ await expect(getPublicKeyPromise).rejects.toThrow('Failed to get public key from KMS');
143
+ });
144
+
145
+ it('should throw KmsClientError when getPublicKey receives InvalidKeyUsageException', async () => {
146
+ // given: KmsClient and mocked InvalidKeyUsageException
147
+ const client = new KmsClient(MOCK_KMS_CONFIG);
148
+ const awsError = new Error('The request is not valid for the specified key usage');
149
+ awsError.name = 'InvalidKeyUsageException';
150
+ mockSend.mockRejectedValueOnce(awsError);
151
+
152
+ // when: Getting public key for key with wrong usage (e.g., ENCRYPT_DECRYPT)
153
+ const getPublicKeyPromise = client.getPublicKey();
154
+
155
+ // then: Should throw KmsClientError with cause
156
+ await expect(getPublicKeyPromise).rejects.toThrow(KmsClientError);
157
+ await expect(getPublicKeyPromise).rejects.toThrow('Failed to get public key from KMS');
158
+ });
159
+
160
+ it('should throw KmsClientError when sign receives ThrottlingException', async () => {
161
+ // given: KmsClient and mocked ThrottlingException
162
+ const client = new KmsClient(MOCK_KMS_CONFIG);
163
+ const message = new Uint8Array([1, 2, 3]);
164
+ const awsError = new Error('Rate exceeded');
165
+ awsError.name = 'ThrottlingException';
166
+ mockSend.mockRejectedValueOnce(awsError);
167
+
168
+ // when: Signing message with rate limit exceeded
169
+ const signPromise = client.sign(message);
170
+
171
+ // then: Should throw KmsClientError with cause
172
+ await expect(signPromise).rejects.toThrow(KmsClientError);
173
+ await expect(signPromise).rejects.toThrow('Failed to sign message with KMS');
174
+ });
175
+
176
+ it('should throw KmsClientError when sign receives KeyUnavailableException', async () => {
177
+ // given: KmsClient and mocked KeyUnavailableException
178
+ const client = new KmsClient(MOCK_KMS_CONFIG);
179
+ const message = new Uint8Array([1, 2, 3]);
180
+ const awsError = new Error('The request was rejected because the specified KMS key is not available');
181
+ awsError.name = 'KeyUnavailableException';
182
+ mockSend.mockRejectedValueOnce(awsError);
183
+
184
+ // when: Signing message with unavailable key
185
+ const signPromise = client.sign(message);
186
+
187
+ // then: Should throw KmsClientError with cause
188
+ await expect(signPromise).rejects.toThrow(KmsClientError);
189
+ await expect(signPromise).rejects.toThrow('Failed to sign message with KMS');
190
+ });
191
+
192
+ it('should handle signing empty message appropriately', async () => {
193
+ // given: KmsClient and empty message
194
+ const client = new KmsClient(MOCK_KMS_CONFIG);
195
+ const emptyMessage = new Uint8Array(0);
196
+
197
+ mockSend.mockResolvedValueOnce({
198
+ Signature: MOCK_SIGNATURE,
199
+ });
200
+
201
+ // when: Signing empty message
202
+ const signature = await client.sign(emptyMessage);
203
+
204
+ // then: Should successfully return signature (AWS KMS allows empty messages)
205
+ expect(signature).toBeInstanceOf(Uint8Array);
206
+ expect(signature.length).toBe(64);
207
+ expect(mockSend).toHaveBeenCalledTimes(1);
208
+ });
209
+
210
+ it('should throw KmsClientError on network timeout simulation', async () => {
211
+ // given: KmsClient and mocked network timeout
212
+ const client = new KmsClient(MOCK_KMS_CONFIG);
213
+ const message = new Uint8Array([1, 2, 3]);
214
+ const timeoutError = new Error('Connection timeout after 10000ms');
215
+ timeoutError.name = 'TimeoutError';
216
+ mockSend.mockRejectedValueOnce(timeoutError);
217
+
218
+ // when: Signing message with network timeout
219
+ const signPromise = client.sign(message);
220
+
221
+ // then: Should throw KmsClientError with timeout cause
222
+ await expect(signPromise).rejects.toThrow(KmsClientError);
223
+ await expect(signPromise).rejects.toThrow('Failed to sign message with KMS');
224
+
225
+ // then: Verify cause is preserved in error
226
+ mockSend.mockRejectedValueOnce(timeoutError);
227
+ try {
228
+ await client.sign(message);
229
+ } catch (error) {
230
+ expect(error).toBeInstanceOf(KmsClientError);
231
+ expect((error as KmsClientError).cause).toBe(timeoutError);
232
+ }
233
+ });
234
+ });
235
+
236
+ describe('Edge Cases', () => {
237
+ it('should throw KmsClientError when getPublicKey response has null PublicKey', async () => {
238
+ // given: KmsClient and response with null PublicKey
239
+ const client = new KmsClient(MOCK_KMS_CONFIG);
240
+ mockSend.mockResolvedValueOnce({
241
+ PublicKey: null,
242
+ });
243
+
244
+ // when: Getting public key with null response
245
+ const getPublicKeyPromise = client.getPublicKey();
246
+
247
+ // then: Should throw KmsClientError
248
+ await expect(getPublicKeyPromise).rejects.toThrow(KmsClientError);
249
+ await expect(getPublicKeyPromise).rejects.toThrow('GetPublicKey response missing PublicKey field');
250
+ });
251
+
252
+ it('should throw KmsClientError when sign response has null Signature', async () => {
253
+ // given: KmsClient and response with null Signature
254
+ const client = new KmsClient(MOCK_KMS_CONFIG);
255
+ const message = new Uint8Array([1, 2, 3]);
256
+ mockSend.mockResolvedValueOnce({
257
+ Signature: null,
258
+ });
259
+
260
+ // when: Signing message with null response
261
+ const signPromise = client.sign(message);
262
+
263
+ // then: Should throw KmsClientError
264
+ await expect(signPromise).rejects.toThrow(KmsClientError);
265
+ await expect(signPromise).rejects.toThrow('Sign response missing Signature field');
266
+ });
267
+
268
+ it('should throw KmsClientError when signature length is not 64 bytes', async () => {
269
+ // given: KmsClient and invalid signature length (e.g., 32 bytes)
270
+ const client = new KmsClient(MOCK_KMS_CONFIG);
271
+ const message = new Uint8Array([1, 2, 3]);
272
+ const invalidSignature = new Uint8Array(32); // Wrong length
273
+ mockSend.mockResolvedValueOnce({
274
+ Signature: invalidSignature,
275
+ });
276
+
277
+ // when: Receiving signature with wrong length
278
+ const signPromise = client.sign(message);
279
+
280
+ // then: Should throw KmsClientError with length validation message
281
+ await expect(signPromise).rejects.toThrow(KmsClientError);
282
+ await expect(signPromise).rejects.toThrow('Invalid signature length: expected 64 bytes, got 32 bytes');
283
+ });
284
+ });
285
+ });
@@ -0,0 +1,132 @@
1
+ import {
2
+ KMSClient,
3
+ GetPublicKeyCommand,
4
+ SignCommand,
5
+ } from '@aws-sdk/client-kms';
6
+ import type { KmsConfig } from '../types/index.js';
7
+ import { KmsClientError } from '../errors/index.js';
8
+
9
+ /**
10
+ * AWS KMS client wrapper for ED25519 key operations.
11
+ *
12
+ * Provides methods to retrieve public keys and sign messages using
13
+ * AWS KMS ED25519 keys (KeySpec: ECC_ED25519).
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const client = new KmsClient({
18
+ * region: 'us-east-1',
19
+ * keyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
20
+ * });
21
+ *
22
+ * const publicKey = await client.getPublicKey();
23
+ * const signature = await client.sign(message);
24
+ * ```
25
+ */
26
+ export class KmsClient {
27
+ private readonly kmsClient: KMSClient;
28
+ private readonly config: KmsConfig;
29
+
30
+ /**
31
+ * Creates a new KmsClient instance.
32
+ *
33
+ * @param config - Configuration for AWS KMS connection
34
+ */
35
+ constructor(config: KmsConfig) {
36
+ this.config = config;
37
+ this.kmsClient = new KMSClient({
38
+ region: config.region,
39
+ credentials: config.credentials,
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Retrieves the public key from AWS KMS.
45
+ *
46
+ * Returns DER-encoded SubjectPublicKeyInfo (X.509 format).
47
+ * Use `extractEd25519PublicKey` to extract the raw 32-byte public key.
48
+ *
49
+ * @returns DER-encoded public key as Uint8Array (typically 42-44 bytes)
50
+ * @throws {KmsClientError} If KMS API call fails
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * const derPublicKey = await client.getPublicKey();
55
+ * const rawPublicKey = extractEd25519PublicKey(derPublicKey);
56
+ * ```
57
+ */
58
+ async getPublicKey(): Promise<Uint8Array> {
59
+ try {
60
+ const command = new GetPublicKeyCommand({
61
+ KeyId: this.config.keyId,
62
+ });
63
+
64
+ const response = await this.kmsClient.send(command);
65
+
66
+ if (!response.PublicKey) {
67
+ throw new KmsClientError('GetPublicKey response missing PublicKey field');
68
+ }
69
+
70
+ // Convert Buffer/Uint8Array to Uint8Array
71
+ return new Uint8Array(response.PublicKey);
72
+ } catch (error) {
73
+ if (error instanceof KmsClientError) {
74
+ throw error;
75
+ }
76
+ throw new KmsClientError(
77
+ `Failed to get public key from KMS: ${error instanceof Error ? error.message : String(error)}`,
78
+ error
79
+ );
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Signs a message using the ED25519 key in AWS KMS.
85
+ *
86
+ * @param message - Message to sign as Uint8Array
87
+ * @returns ED25519 signature (64 bytes)
88
+ * @throws {KmsClientError} If KMS API call fails or signature is invalid
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const message = new TextEncoder().encode('Hello, Solana!');
93
+ * const signature = await client.sign(message);
94
+ * console.log('Signature length:', signature.length); // 64
95
+ * ```
96
+ */
97
+ async sign(message: Uint8Array): Promise<Uint8Array> {
98
+ try {
99
+ const command = new SignCommand({
100
+ KeyId: this.config.keyId,
101
+ Message: message,
102
+ MessageType: 'RAW',
103
+ SigningAlgorithm: 'ED25519_SHA_512',
104
+ });
105
+
106
+ const response = await this.kmsClient.send(command);
107
+
108
+ if (!response.Signature) {
109
+ throw new KmsClientError('Sign response missing Signature field');
110
+ }
111
+
112
+ const signature = new Uint8Array(response.Signature);
113
+
114
+ // Validate signature length (ED25519 signatures are always 64 bytes)
115
+ if (signature.length !== 64) {
116
+ throw new KmsClientError(
117
+ `Invalid signature length: expected 64 bytes, got ${signature.length} bytes`
118
+ );
119
+ }
120
+
121
+ return signature;
122
+ } catch (error) {
123
+ if (error instanceof KmsClientError) {
124
+ throw error;
125
+ }
126
+ throw new KmsClientError(
127
+ `Failed to sign message with KMS: ${error instanceof Error ? error.message : String(error)}`,
128
+ error
129
+ );
130
+ }
131
+ }
132
+ }