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,274 @@
1
+ import {
2
+ PublicKey,
3
+ Transaction,
4
+ VersionedTransaction,
5
+ } from '@solana/web3.js';
6
+ import nacl from 'tweetnacl';
7
+ import { KmsClient } from './client.js';
8
+ import { extractEd25519PublicKey } from '../utils/publicKey.js';
9
+ import type { KmsConfig } from '../types/index.js';
10
+ import { SignatureVerificationError } from '../errors/index.js';
11
+
12
+ /**
13
+ * Solana transaction signer using AWS KMS ED25519 keys.
14
+ *
15
+ * Provides methods to sign Solana transactions and arbitrary messages
16
+ * using AWS KMS-managed ED25519 keys. Caches the public key after
17
+ * first retrieval to minimize KMS API calls.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * // Create with KmsConfig
22
+ * const signer = new SolanaKmsSigner({
23
+ * region: 'us-east-1',
24
+ * keyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
25
+ * });
26
+ *
27
+ * // Or create with existing KmsClient
28
+ * const client = new KmsClient(config);
29
+ * const signer = new SolanaKmsSigner(client);
30
+ *
31
+ * // Get public key
32
+ * const publicKey = await signer.getPublicKey();
33
+ *
34
+ * // Sign message
35
+ * const message = new TextEncoder().encode('Hello, Solana!');
36
+ * const signature = await signer.signMessage(message);
37
+ *
38
+ * // Sign transaction
39
+ * const transaction = new Transaction().add(instruction);
40
+ * transaction.recentBlockhash = recentBlockhash;
41
+ * transaction.feePayer = publicKey;
42
+ * const signedTx = await signer.signTransaction(transaction);
43
+ * ```
44
+ */
45
+ export class SolanaKmsSigner {
46
+ private readonly kmsClient: KmsClient;
47
+ private publicKey?: PublicKey;
48
+ private rawPublicKey?: Uint8Array;
49
+
50
+ /**
51
+ * Creates a new SolanaKmsSigner instance.
52
+ *
53
+ * @param config - Either KmsConfig or an existing KmsClient instance
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * // With KmsConfig
58
+ * const signer = new SolanaKmsSigner({
59
+ * region: 'us-east-1',
60
+ * keyId: 'key-id'
61
+ * });
62
+ *
63
+ * // With KmsClient
64
+ * const client = new KmsClient(config);
65
+ * const signer = new SolanaKmsSigner(client);
66
+ * ```
67
+ */
68
+ constructor(config: KmsConfig | KmsClient) {
69
+ if (config instanceof KmsClient) {
70
+ this.kmsClient = config;
71
+ } else {
72
+ this.kmsClient = new KmsClient(config);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Retrieves the Solana PublicKey associated with the KMS key.
78
+ *
79
+ * The public key is cached after first retrieval to minimize KMS API calls.
80
+ *
81
+ * @returns Solana PublicKey object
82
+ * @throws {KmsClientError} If KMS API call fails
83
+ * @throws {PublicKeyExtractionError} If DER decoding fails
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * const publicKey = await signer.getPublicKey();
88
+ * console.log('Address:', publicKey.toBase58());
89
+ * ```
90
+ */
91
+ async getPublicKey(): Promise<PublicKey> {
92
+ // Return cached public key if available
93
+ if (this.publicKey) {
94
+ return this.publicKey;
95
+ }
96
+
97
+ // Get DER-encoded public key from KMS
98
+ const derPublicKey = await this.kmsClient.getPublicKey();
99
+
100
+ // Extract raw 32-byte ED25519 public key
101
+ const rawPublicKey = extractEd25519PublicKey(derPublicKey);
102
+
103
+ // Create Solana PublicKey and cache both forms
104
+ this.rawPublicKey = rawPublicKey;
105
+ this.publicKey = new PublicKey(rawPublicKey);
106
+
107
+ return this.publicKey;
108
+ }
109
+
110
+ /**
111
+ * Retrieves the raw 32-byte ED25519 public key.
112
+ *
113
+ * The public key is cached after first retrieval to minimize KMS API calls.
114
+ *
115
+ * @returns Raw 32-byte public key as Uint8Array
116
+ * @throws {KmsClientError} If KMS API call fails
117
+ * @throws {PublicKeyExtractionError} If DER decoding fails
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * const rawPublicKey = await signer.getRawPublicKey();
122
+ * console.log('Raw public key length:', rawPublicKey.length); // 32
123
+ * ```
124
+ */
125
+ async getRawPublicKey(): Promise<Uint8Array> {
126
+ // Return cached raw public key if available
127
+ if (this.rawPublicKey) {
128
+ return this.rawPublicKey;
129
+ }
130
+
131
+ // Get DER-encoded public key from KMS
132
+ const derPublicKey = await this.kmsClient.getPublicKey();
133
+
134
+ // Extract raw 32-byte ED25519 public key
135
+ this.rawPublicKey = extractEd25519PublicKey(derPublicKey);
136
+
137
+ return this.rawPublicKey;
138
+ }
139
+
140
+ /**
141
+ * Signs an arbitrary message using the KMS key.
142
+ *
143
+ * The signature is verified using tweetnacl before being returned
144
+ * to ensure cryptographic correctness.
145
+ *
146
+ * @param message - Message to sign as Uint8Array
147
+ * @returns ED25519 signature (64 bytes)
148
+ * @throws {KmsClientError} If KMS API call fails
149
+ * @throws {SignatureVerificationError} If signature verification fails
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * const message = new TextEncoder().encode('Hello, Solana!');
154
+ * const signature = await signer.signMessage(message);
155
+ * console.log('Signature length:', signature.length); // 64
156
+ * ```
157
+ */
158
+ async signMessage(message: Uint8Array): Promise<Uint8Array> {
159
+ // Get signature from KMS
160
+ const signature = await this.kmsClient.sign(message);
161
+
162
+ // Get raw public key for verification
163
+ const rawPublicKey = await this.getRawPublicKey();
164
+
165
+ // Verify signature using tweetnacl
166
+ const isValid = nacl.sign.detached.verify(
167
+ message,
168
+ signature,
169
+ rawPublicKey
170
+ );
171
+
172
+ if (!isValid) {
173
+ throw new SignatureVerificationError(
174
+ 'Signature verification failed: signature does not match public key and message'
175
+ );
176
+ }
177
+
178
+ return signature;
179
+ }
180
+
181
+ /**
182
+ * Signs a Solana legacy Transaction.
183
+ *
184
+ * The transaction must have recentBlockhash and feePayer set before signing.
185
+ *
186
+ * @param transaction - Transaction to sign
187
+ * @returns Signed transaction
188
+ * @throws {KmsClientError} If KMS API call fails
189
+ * @throws {SignatureVerificationError} If signature verification fails
190
+ *
191
+ * @example
192
+ * ```typescript
193
+ * const transaction = new Transaction().add(instruction);
194
+ * transaction.recentBlockhash = recentBlockhash;
195
+ * transaction.feePayer = await signer.getPublicKey();
196
+ * const signedTx = await signer.signTransaction(transaction);
197
+ * ```
198
+ */
199
+ async signTransaction(transaction: Transaction): Promise<Transaction> {
200
+ // Get public key for signing
201
+ const publicKey = await this.getPublicKey();
202
+
203
+ // Serialize transaction message
204
+ const message = transaction.serializeMessage();
205
+
206
+ // Sign the serialized message
207
+ const signature = await this.signMessage(message);
208
+
209
+ // Add signature to transaction
210
+ transaction.addSignature(publicKey, Buffer.from(signature));
211
+
212
+ return transaction;
213
+ }
214
+
215
+ /**
216
+ * Signs a Solana VersionedTransaction.
217
+ *
218
+ * The transaction must have a valid message with recentBlockhash set.
219
+ *
220
+ * @param transaction - VersionedTransaction to sign
221
+ * @returns Signed versioned transaction
222
+ * @throws {KmsClientError} If KMS API call fails
223
+ * @throws {SignatureVerificationError} If signature verification fails
224
+ *
225
+ * @example
226
+ * ```typescript
227
+ * const message = MessageV0.compile({
228
+ * payerKey: await signer.getPublicKey(),
229
+ * instructions: [instruction],
230
+ * recentBlockhash: recentBlockhash
231
+ * });
232
+ * const transaction = new VersionedTransaction(message);
233
+ * const signedTx = await signer.signVersionedTransaction(transaction);
234
+ * ```
235
+ */
236
+ async signVersionedTransaction(
237
+ transaction: VersionedTransaction
238
+ ): Promise<VersionedTransaction> {
239
+ // Serialize transaction message
240
+ const message = transaction.message.serialize();
241
+
242
+ // Sign the serialized message
243
+ const signature = await this.signMessage(message);
244
+
245
+ // Add signature to transaction's signatures array
246
+ transaction.addSignature(await this.getPublicKey(), Buffer.from(signature));
247
+
248
+ return transaction;
249
+ }
250
+
251
+ /**
252
+ * Signs multiple Solana transactions in parallel.
253
+ *
254
+ * All transactions must have recentBlockhash and feePayer set before signing.
255
+ *
256
+ * @param transactions - Array of transactions to sign
257
+ * @returns Array of signed transactions in the same order
258
+ * @throws {KmsClientError} If any KMS API call fails
259
+ * @throws {SignatureVerificationError} If any signature verification fails
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * const transactions = [tx1, tx2, tx3];
264
+ * const signedTxs = await signer.signAllTransactions(transactions);
265
+ * ```
266
+ */
267
+ async signAllTransactions(
268
+ transactions: Transaction[]
269
+ ): Promise<Transaction[]> {
270
+ return Promise.all(
271
+ transactions.map((transaction) => this.signTransaction(transaction))
272
+ );
273
+ }
274
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Configuration for AWS KMS client.
3
+ */
4
+ export interface KmsConfig {
5
+ /**
6
+ * AWS region where the KMS key is located (e.g., 'us-east-1').
7
+ */
8
+ region: string;
9
+
10
+ /**
11
+ * KMS key ID or ARN to use for signing operations.
12
+ */
13
+ keyId: string;
14
+
15
+ /**
16
+ * Optional AWS credentials. If not provided, the AWS SDK will use
17
+ * environment variables, IAM roles, or other credential providers.
18
+ */
19
+ credentials?: {
20
+ /**
21
+ * AWS access key ID.
22
+ */
23
+ accessKeyId: string;
24
+
25
+ /**
26
+ * AWS secret access key.
27
+ */
28
+ secretAccessKey: string;
29
+
30
+ /**
31
+ * Optional session token for temporary credentials.
32
+ */
33
+ sessionToken?: string;
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Configuration for Solana KMS Signer.
39
+ * Currently extends KmsConfig with no additional fields.
40
+ * Additional configuration options can be added in the future.
41
+ */
42
+ export interface SolanaKmsSignerConfig extends KmsConfig {
43
+ // Additional configuration options can be added here in the future
44
+ }
@@ -0,0 +1,135 @@
1
+ import { extractEd25519PublicKey } from './publicKey.js';
2
+ import { PublicKeyExtractionError } from '../errors/index.js';
3
+
4
+ /**
5
+ * Mock DER-encoded ED25519 public key matching AWS KMS GetPublicKey format.
6
+ * Structure:
7
+ * - 30 2a: SEQUENCE, 42 bytes
8
+ * - 30 05: AlgorithmIdentifier SEQUENCE
9
+ * - 06 03 2b 65 70: OID 1.3.101.112 (Ed25519)
10
+ * - 03 21 00: BIT STRING, 33 bytes (32 bytes + 1 padding)
11
+ * - [32 bytes]: Mock public key data
12
+ */
13
+ const MOCK_DER_PUBLIC_KEY = new Uint8Array([
14
+ 0x30, 0x2a, // SEQUENCE, 42 bytes
15
+ 0x30, 0x05, // AlgorithmIdentifier SEQUENCE, 5 bytes
16
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID: 1.3.101.112 (Ed25519)
17
+ 0x03, 0x21, 0x00, // BIT STRING, 33 bytes (32 + 1 padding)
18
+ // Mock 32-byte ED25519 public key
19
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
20
+ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
21
+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
22
+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
23
+ ]);
24
+
25
+ describe('extractEd25519PublicKey', () => {
26
+ describe('Happy Path', () => {
27
+ it('should extract 32-byte public key from valid DER encoding', () => {
28
+ // given: Valid DER-encoded public key from AWS KMS
29
+ const derEncoded = MOCK_DER_PUBLIC_KEY;
30
+
31
+ // when: Extracting public key
32
+ const publicKey = extractEd25519PublicKey(derEncoded);
33
+
34
+ // then: Should return exactly 32 bytes
35
+ expect(publicKey).toBeInstanceOf(Uint8Array);
36
+ expect(publicKey.length).toBe(32);
37
+
38
+ // then: Should match the mock public key bytes (starting at offset 12)
39
+ const expectedPublicKey = new Uint8Array([
40
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
41
+ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
42
+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
43
+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
44
+ ]);
45
+ expect(publicKey).toEqual(expectedPublicKey);
46
+ });
47
+ });
48
+
49
+ describe('Failure Paths', () => {
50
+ it('should throw PublicKeyExtractionError when SEQUENCE tag is missing', () => {
51
+ // given: Invalid DER with wrong first byte (not 0x30)
52
+ const invalidDer = new Uint8Array([
53
+ 0x31, 0x2a, // Wrong tag (0x31 instead of 0x30)
54
+ 0x30, 0x05,
55
+ 0x06, 0x03, 0x2b, 0x65, 0x70,
56
+ 0x03, 0x21, 0x00,
57
+ ...new Array(32).fill(0x01),
58
+ ]);
59
+
60
+ // when & then: Should throw PublicKeyExtractionError
61
+ expect(() => extractEd25519PublicKey(invalidDer)).toThrow(
62
+ PublicKeyExtractionError
63
+ );
64
+ expect(() => extractEd25519PublicKey(invalidDer)).toThrow(
65
+ 'Invalid DER encoding: missing SEQUENCE tag'
66
+ );
67
+ });
68
+
69
+ it('should throw PublicKeyExtractionError when BIT STRING tag is missing', () => {
70
+ // given: Invalid DER without BIT STRING tag (0x03)
71
+ const invalidDer = new Uint8Array([
72
+ 0x30, 0x2a, // SEQUENCE
73
+ 0x30, 0x05, // AlgorithmIdentifier
74
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID
75
+ 0x04, 0x21, 0x00, // Wrong tag (0x04 instead of 0x03)
76
+ ...new Array(32).fill(0x01),
77
+ ]);
78
+
79
+ // when & then: Should throw PublicKeyExtractionError
80
+ expect(() => extractEd25519PublicKey(invalidDer)).toThrow(
81
+ PublicKeyExtractionError
82
+ );
83
+ expect(() => extractEd25519PublicKey(invalidDer)).toThrow(
84
+ 'Invalid DER encoding: missing BIT STRING'
85
+ );
86
+ });
87
+
88
+ it('should throw PublicKeyExtractionError when BIT STRING length is incorrect', () => {
89
+ // given: Invalid DER with wrong BIT STRING length (0x20 instead of 0x21)
90
+ const invalidDer = new Uint8Array([
91
+ 0x30, 0x2a, // SEQUENCE
92
+ 0x30, 0x05, // AlgorithmIdentifier
93
+ 0x06, 0x03, 0x2b, 0x65, 0x70, // OID
94
+ 0x03, 0x20, 0x00, // Wrong length (0x20 = 32 instead of 0x21 = 33)
95
+ ...new Array(32).fill(0x01),
96
+ ]);
97
+
98
+ // when & then: Should throw PublicKeyExtractionError with specific message
99
+ expect(() => extractEd25519PublicKey(invalidDer)).toThrow(
100
+ PublicKeyExtractionError
101
+ );
102
+ expect(() => extractEd25519PublicKey(invalidDer)).toThrow(
103
+ 'Unexpected BIT STRING length: expected 0x21 (33 bytes), got 0x20'
104
+ );
105
+ });
106
+
107
+ it('should throw PublicKeyExtractionError when input is empty', () => {
108
+ // given: Empty Uint8Array
109
+ const emptyInput = new Uint8Array(0);
110
+
111
+ // when & then: Should throw PublicKeyExtractionError
112
+ expect(() => extractEd25519PublicKey(emptyInput)).toThrow(
113
+ PublicKeyExtractionError
114
+ );
115
+ expect(() => extractEd25519PublicKey(emptyInput)).toThrow(
116
+ 'Invalid DER encoding: missing SEQUENCE tag'
117
+ );
118
+ });
119
+
120
+ it('should throw error when input is null or undefined', () => {
121
+ // given: Null input
122
+ const nullInput = null as unknown as Uint8Array;
123
+
124
+ // when & then: Should throw TypeError (not PublicKeyExtractionError)
125
+ // Note: This tests TypeScript runtime behavior - null/undefined are not Uint8Array
126
+ expect(() => extractEd25519PublicKey(nullInput)).toThrow();
127
+
128
+ // given: Undefined input
129
+ const undefinedInput = undefined as unknown as Uint8Array;
130
+
131
+ // when & then: Should throw TypeError
132
+ expect(() => extractEd25519PublicKey(undefinedInput)).toThrow();
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,70 @@
1
+ import { PublicKeyExtractionError } from '../errors/index.js';
2
+
3
+ /**
4
+ * Extracts a 32-byte ED25519 public key from DER-encoded SubjectPublicKeyInfo.
5
+ *
6
+ * AWS KMS GetPublicKey returns DER-encoded public keys in X.509 SubjectPublicKeyInfo format:
7
+ * ```
8
+ * 30 2a # SEQUENCE, 42 bytes
9
+ * 30 05 # SEQUENCE, 5 bytes (AlgorithmIdentifier)
10
+ * 06 03 # OID, 3 bytes
11
+ * 2b 65 70 # 1.3.101.112 (Ed25519)
12
+ * 03 21 00 # BIT STRING, 33 bytes (32 bytes + 1 byte padding)
13
+ * [32 bytes] # ED25519 public key
14
+ * ```
15
+ *
16
+ * This function extracts the raw 32-byte public key from the DER structure.
17
+ *
18
+ * @param derEncoded - DER-encoded SubjectPublicKeyInfo from AWS KMS
19
+ * @returns Raw 32-byte ED25519 public key
20
+ * @throws {PublicKeyExtractionError} If the DER encoding is invalid or unexpected
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const derEncoded = await kmsClient.getPublicKey();
25
+ * const rawPublicKey = extractEd25519PublicKey(derEncoded);
26
+ * // rawPublicKey is Uint8Array of 32 bytes
27
+ * ```
28
+ */
29
+ export function extractEd25519PublicKey(derEncoded: Uint8Array): Uint8Array {
30
+ // Validate DER structure - first byte must be SEQUENCE tag (0x30)
31
+ if (derEncoded[0] !== 0x30) {
32
+ throw new PublicKeyExtractionError(
33
+ 'Invalid DER encoding: missing SEQUENCE tag'
34
+ );
35
+ }
36
+
37
+ // Parse AlgorithmIdentifier SEQUENCE to find where BIT STRING starts
38
+ // Position 2 should be another SEQUENCE tag for AlgorithmIdentifier
39
+ if (derEncoded[2] !== 0x30) {
40
+ throw new PublicKeyExtractionError(
41
+ 'Invalid DER encoding: missing AlgorithmIdentifier SEQUENCE'
42
+ );
43
+ }
44
+
45
+ // Get length of AlgorithmIdentifier content
46
+ const algorithmIdentifierLength = derEncoded[3];
47
+
48
+ // BIT STRING starts after AlgorithmIdentifier SEQUENCE
49
+ // Position = 4 (first content byte) + algorithmIdentifierLength
50
+ const bitStringIndex = 4 + algorithmIdentifierLength;
51
+
52
+ // Verify BIT STRING tag (0x03)
53
+ if (derEncoded[bitStringIndex] !== 0x03) {
54
+ throw new PublicKeyExtractionError(
55
+ 'Invalid DER encoding: missing BIT STRING'
56
+ );
57
+ }
58
+
59
+ // Verify BIT STRING length (0x21 = 33 bytes: 1 byte unused bits + 32 bytes public key)
60
+ const bitStringLength = derEncoded[bitStringIndex + 1];
61
+ if (bitStringLength !== 0x21) {
62
+ throw new PublicKeyExtractionError(
63
+ `Unexpected BIT STRING length: expected 0x21 (33 bytes), got 0x${bitStringLength.toString(16)}`
64
+ );
65
+ }
66
+
67
+ // First byte after length is unused bits (0x00), next 32 bytes is the public key
68
+ const publicKeyStart = bitStringIndex + 3;
69
+ return derEncoded.slice(publicKeyStart, publicKeyStart + 32);
70
+ }