@solana/keychain-dfns 0.0.0 → 0.6.1

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.
Files changed (37) hide show
  1. package/README.md +136 -0
  2. package/dist/__tests__/dfns-signer.integration.test.d.ts +2 -0
  3. package/dist/__tests__/dfns-signer.integration.test.d.ts.map +1 -0
  4. package/dist/__tests__/dfns-signer.integration.test.js +17 -0
  5. package/dist/__tests__/dfns-signer.integration.test.js.map +1 -0
  6. package/dist/__tests__/dfns-signer.test.d.ts +2 -0
  7. package/dist/__tests__/dfns-signer.test.d.ts.map +1 -0
  8. package/dist/__tests__/dfns-signer.test.js +157 -0
  9. package/dist/__tests__/dfns-signer.test.js.map +1 -0
  10. package/dist/__tests__/setup.d.ts +45 -0
  11. package/dist/__tests__/setup.d.ts.map +1 -0
  12. package/dist/__tests__/setup.js +64 -0
  13. package/dist/__tests__/setup.js.map +1 -0
  14. package/dist/auth.d.ts +7 -0
  15. package/dist/auth.d.ts.map +1 -0
  16. package/dist/auth.js +117 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/dfns-signer.d.ts +65 -0
  19. package/dist/dfns-signer.d.ts.map +1 -0
  20. package/dist/dfns-signer.js +331 -0
  21. package/dist/dfns-signer.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/types.d.ts +101 -0
  27. package/dist/types.d.ts.map +1 -0
  28. package/dist/types.js +2 -0
  29. package/dist/types.js.map +1 -0
  30. package/package.json +61 -8
  31. package/src/__tests__/dfns-signer.integration.test.ts +17 -0
  32. package/src/__tests__/dfns-signer.test.ts +217 -0
  33. package/src/__tests__/setup.ts +76 -0
  34. package/src/auth.ts +136 -0
  35. package/src/dfns-signer.ts +421 -0
  36. package/src/index.ts +2 -0
  37. package/src/types.ts +113 -0
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('@solana/keychain-core', async importOriginal => {
4
+ const mod = await importOriginal<typeof import('@solana/keychain-core')>();
5
+ return { ...mod, assertSignatureValid: vi.fn() };
6
+ });
7
+
8
+ import { DfnsSigner } from '../dfns-signer.js';
9
+ import {
10
+ TEST_AUTH_TOKEN,
11
+ TEST_CRED_ID,
12
+ TEST_ED25519_PEM,
13
+ TEST_WALLET_ID,
14
+ createSignatureResponse,
15
+ createUserActionInitResponse,
16
+ createUserActionResponse,
17
+ createWalletResponse,
18
+ } from './setup.js';
19
+
20
+ global.fetch = vi.fn();
21
+ const mockFetch = global.fetch as ReturnType<typeof vi.fn>;
22
+
23
+ const defaultConfig = {
24
+ authToken: TEST_AUTH_TOKEN,
25
+ credId: TEST_CRED_ID,
26
+ privateKeyPem: TEST_ED25519_PEM,
27
+ walletId: TEST_WALLET_ID,
28
+ };
29
+
30
+ function mockWalletFetch(overrides?: Parameters<typeof createWalletResponse>[0]) {
31
+ mockFetch.mockResolvedValueOnce({
32
+ json: async () => createWalletResponse(overrides),
33
+ ok: true,
34
+ });
35
+ }
36
+
37
+ describe('DfnsSigner', () => {
38
+ beforeEach(() => {
39
+ vi.resetAllMocks();
40
+ });
41
+
42
+ describe('create', () => {
43
+ it('creates a DfnsSigner with valid config', async () => {
44
+ mockWalletFetch();
45
+ const signer = await DfnsSigner.create(defaultConfig);
46
+ expect(signer).toBeDefined();
47
+ expect(signer.address).toBeDefined();
48
+ });
49
+
50
+ it('throws error for missing authToken', async () => {
51
+ await expect(DfnsSigner.create({ ...defaultConfig, authToken: '' })).rejects.toThrow(
52
+ 'Missing required authToken field',
53
+ );
54
+ });
55
+
56
+ it('throws error for missing credId', async () => {
57
+ await expect(DfnsSigner.create({ ...defaultConfig, credId: '' })).rejects.toThrow(
58
+ 'Missing required credId field',
59
+ );
60
+ });
61
+
62
+ it('throws error for missing privateKeyPem', async () => {
63
+ await expect(DfnsSigner.create({ ...defaultConfig, privateKeyPem: '' })).rejects.toThrow(
64
+ 'Missing required privateKeyPem field',
65
+ );
66
+ });
67
+
68
+ it('throws error for missing walletId', async () => {
69
+ await expect(DfnsSigner.create({ ...defaultConfig, walletId: '' })).rejects.toThrow(
70
+ 'Missing required walletId field',
71
+ );
72
+ });
73
+
74
+ it('throws error for inactive wallet', async () => {
75
+ mockWalletFetch({ status: 'Inactive' });
76
+ await expect(DfnsSigner.create(defaultConfig)).rejects.toThrow('not active');
77
+ });
78
+
79
+ it('throws error for non-EdDSA scheme', async () => {
80
+ mockWalletFetch({ scheme: 'ECDSA' });
81
+ await expect(DfnsSigner.create(defaultConfig)).rejects.toThrow('Unsupported key scheme');
82
+ });
83
+
84
+ it('throws error for API failure', async () => {
85
+ mockFetch.mockResolvedValueOnce({
86
+ ok: false,
87
+ status: 401,
88
+ });
89
+ await expect(DfnsSigner.create(defaultConfig)).rejects.toThrow();
90
+ });
91
+
92
+ it('throws error for negative requestDelayMs', async () => {
93
+ await expect(DfnsSigner.create({ ...defaultConfig, requestDelayMs: -1 })).rejects.toThrow(
94
+ 'requestDelayMs must not be negative',
95
+ );
96
+ });
97
+
98
+ it('warns for high requestDelayMs', async () => {
99
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
100
+ mockWalletFetch();
101
+ await DfnsSigner.create({ ...defaultConfig, requestDelayMs: 5000 });
102
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('requestDelayMs is greater than 3000ms'));
103
+ warnSpy.mockRestore();
104
+ });
105
+
106
+ it('throws HTTP_ERROR when fetch fails during create', async () => {
107
+ mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
108
+ await expect(DfnsSigner.create(defaultConfig)).rejects.toMatchObject({
109
+ code: 'SIGNER_HTTP_ERROR',
110
+ message: expect.stringContaining('Dfns network request failed'),
111
+ });
112
+ });
113
+ });
114
+
115
+ describe('signMessages', () => {
116
+ it('throws HTTP_ERROR when fetch fails during signing', async () => {
117
+ mockWalletFetch();
118
+ const signer = await DfnsSigner.create(defaultConfig);
119
+
120
+ // The auth flow init request fails with network error
121
+ mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
122
+
123
+ await expect(
124
+ signer.signMessages([{ content: new Uint8Array([1, 2, 3]), signatures: {} }]),
125
+ ).rejects.toMatchObject({
126
+ code: 'SIGNER_HTTP_ERROR',
127
+ message: expect.stringContaining('Dfns network request failed'),
128
+ });
129
+ });
130
+
131
+ it('signs a message successfully', async () => {
132
+ const rHex = '11'.repeat(32);
133
+ const sHex = '22'.repeat(32);
134
+
135
+ mockWalletFetch();
136
+
137
+ mockFetch.mockResolvedValueOnce({
138
+ json: async () => createUserActionInitResponse(),
139
+ ok: true,
140
+ });
141
+
142
+ mockFetch.mockResolvedValueOnce({
143
+ json: async () => createUserActionResponse(),
144
+ ok: true,
145
+ });
146
+
147
+ mockFetch.mockResolvedValueOnce({
148
+ json: async () => createSignatureResponse(rHex, sHex),
149
+ ok: true,
150
+ });
151
+
152
+ const signer = await DfnsSigner.create(defaultConfig);
153
+
154
+ const result = await signer.signMessages([{ content: new Uint8Array([1, 2, 3]), signatures: {} }]);
155
+
156
+ expect(result).toHaveLength(1);
157
+ expect(result[0]?.[signer.address]).toBeDefined();
158
+
159
+ const sig = result[0]![signer.address]!;
160
+ expect(sig.length).toBe(64);
161
+ });
162
+
163
+ it('left-pads short signature components', async () => {
164
+ // r is 31 bytes (short by 1), s is 32 bytes
165
+ const rHex = 'ff'.repeat(31);
166
+ const sHex = 'aa'.repeat(32);
167
+
168
+ mockWalletFetch();
169
+
170
+ mockFetch.mockResolvedValueOnce({
171
+ json: async () => createUserActionInitResponse(),
172
+ ok: true,
173
+ });
174
+
175
+ mockFetch.mockResolvedValueOnce({
176
+ json: async () => createUserActionResponse(),
177
+ ok: true,
178
+ });
179
+
180
+ mockFetch.mockResolvedValueOnce({
181
+ json: async () => createSignatureResponse(rHex, sHex),
182
+ ok: true,
183
+ });
184
+
185
+ const signer = await DfnsSigner.create(defaultConfig);
186
+
187
+ const result = await signer.signMessages([{ content: new Uint8Array([1, 2, 3]), signatures: {} }]);
188
+
189
+ const sig = result[0]![signer.address]!;
190
+ expect(sig.length).toBe(64);
191
+ // First byte should be 0x00 (left-pad), then 31 bytes of 0xff
192
+ expect(sig[0]).toBe(0x00);
193
+ expect(sig[1]).toBe(0xff);
194
+ });
195
+ });
196
+
197
+ describe('isAvailable', () => {
198
+ it('returns true when API responds', async () => {
199
+ mockWalletFetch();
200
+ // isAvailable doesn't need create(), but we need a signer instance
201
+ mockWalletFetch(); // for the isAvailable call
202
+ const signer = await DfnsSigner.create(defaultConfig);
203
+ expect(await signer.isAvailable()).toBe(true);
204
+ });
205
+
206
+ it('returns false when API fails', async () => {
207
+ mockWalletFetch(); // for create()
208
+ const signer = await DfnsSigner.create(defaultConfig);
209
+
210
+ mockFetch.mockResolvedValueOnce({
211
+ ok: false,
212
+ status: 500,
213
+ });
214
+ expect(await signer.isAvailable()).toBe(false);
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,76 @@
1
+ import type { SolanaSigner } from '@solana/keychain-core';
2
+ import { SignerTestConfig, TestScenario } from '@solana/keychain-test-utils';
3
+ import { createDfnsSigner } from '../dfns-signer.js';
4
+
5
+ export const TEST_AUTH_TOKEN = 'test-auth-token';
6
+ export const TEST_CRED_ID = 'test-cred-id';
7
+ export const TEST_WALLET_ID = 'test-wallet-id';
8
+ export const TEST_KEY_ID = 'test-key-id';
9
+
10
+ // Ed25519 test key in PKCS#8 PEM format
11
+ export const TEST_ED25519_PEM = `-----BEGIN PRIVATE KEY-----
12
+ MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikUmifl1yiWd+IiYyoHBD
13
+ -----END PRIVATE KEY-----`;
14
+
15
+ export const TEST_PUBKEY_HEX = '5da30b28c87836b0ee76ae7b07e3a2e3be1a4c12e48fce3aee18de0a13040b9a';
16
+
17
+ export function createWalletResponse(overrides?: Partial<{ status: string; scheme: string; curve: string }>) {
18
+ return {
19
+ id: TEST_WALLET_ID,
20
+ network: 'Solana',
21
+ signingKey: {
22
+ id: TEST_KEY_ID,
23
+ curve: overrides?.curve ?? 'ed25519',
24
+ publicKey: TEST_PUBKEY_HEX,
25
+ scheme: overrides?.scheme ?? 'EdDSA',
26
+ },
27
+ status: overrides?.status ?? 'Active',
28
+ };
29
+ }
30
+
31
+ export function createUserActionInitResponse() {
32
+ return {
33
+ allowCredentials: {
34
+ key: [{ id: TEST_CRED_ID }],
35
+ },
36
+ challenge: 'test-challenge',
37
+ challengeIdentifier: 'test-challenge-id',
38
+ };
39
+ }
40
+
41
+ export function createUserActionResponse() {
42
+ return {
43
+ userAction: 'test-user-action-token',
44
+ };
45
+ }
46
+
47
+ export function createSignatureResponse(r: string, s: string) {
48
+ return {
49
+ id: 'sig-123',
50
+ signature: { r, s },
51
+ status: 'Signed',
52
+ };
53
+ }
54
+
55
+ const SIGNER_TYPE = 'dfns';
56
+ const REQUIRED_ENV_VARS = ['DFNS_AUTH_TOKEN', 'DFNS_CRED_ID', 'DFNS_PRIVATE_KEY_PEM', 'DFNS_WALLET_ID'];
57
+
58
+ const CONFIG: SignerTestConfig<SolanaSigner> = {
59
+ signerType: SIGNER_TYPE,
60
+ requiredEnvVars: REQUIRED_ENV_VARS,
61
+ createSigner: () =>
62
+ createDfnsSigner({
63
+ authToken: process.env.DFNS_AUTH_TOKEN!,
64
+ credId: process.env.DFNS_CRED_ID!,
65
+ privateKeyPem: process.env.DFNS_PRIVATE_KEY_PEM!,
66
+ walletId: process.env.DFNS_WALLET_ID!,
67
+ apiBaseUrl: process.env.DFNS_API_BASE_URL,
68
+ }),
69
+ };
70
+
71
+ export async function getConfig(scenarios: TestScenario[]): Promise<SignerTestConfig<SolanaSigner>> {
72
+ return {
73
+ ...CONFIG,
74
+ testScenarios: scenarios,
75
+ };
76
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,136 @@
1
+ import * as crypto from 'node:crypto';
2
+
3
+ import { base64UrlDecoder, SignerErrorCode, throwSignerError } from '@solana/keychain-core';
4
+
5
+ import type { UserActionInitResponse, UserActionResponse } from './types.js';
6
+
7
+ /**
8
+ * Perform the Dfns User Action Signing flow. For more details, see https://docs.dfns.co/api-reference/auth/signing-flows#asymetric-keys-signing-flow
9
+ *
10
+ * @returns The `userAction` token to include as `x-dfns-useraction` header.
11
+ */
12
+ export async function signUserAction(
13
+ apiBaseUrl: string,
14
+ authToken: string,
15
+ credId: string,
16
+ privateKeyPem: string,
17
+ httpMethod: string,
18
+ httpPath: string,
19
+ body: string,
20
+ ): Promise<string> {
21
+ // Request a challenge
22
+ const initUrl = `${apiBaseUrl}/auth/action/init`;
23
+ let initResponse: Response;
24
+ try {
25
+ initResponse = await fetch(initUrl, {
26
+ body: JSON.stringify({
27
+ userActionHttpMethod: httpMethod,
28
+ userActionHttpPath: httpPath,
29
+ userActionPayload: body,
30
+ userActionServerKind: 'Api',
31
+ }),
32
+ headers: {
33
+ Authorization: `Bearer ${authToken}`,
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ method: 'POST',
37
+ });
38
+ } catch (error) {
39
+ throwSignerError(SignerErrorCode.HTTP_ERROR, {
40
+ cause: error,
41
+ message: 'Dfns network request failed',
42
+ url: initUrl,
43
+ });
44
+ }
45
+
46
+ if (!initResponse.ok) {
47
+ const errorText = await initResponse.text().catch(() => 'Failed to read error response');
48
+ throwSignerError(SignerErrorCode.REMOTE_API_ERROR, {
49
+ message: `Dfns auth/action/init failed: ${initResponse.status}`,
50
+ response: errorText,
51
+ status: initResponse.status,
52
+ });
53
+ }
54
+
55
+ let challenge: UserActionInitResponse;
56
+ try {
57
+ challenge = (await initResponse.json()) as UserActionInitResponse;
58
+ } catch (error) {
59
+ throwSignerError(SignerErrorCode.PARSING_ERROR, {
60
+ cause: error,
61
+ message: 'Failed to parse Dfns auth challenge response',
62
+ });
63
+ }
64
+
65
+ // Verify credential is allowed
66
+ const allowed = challenge.allowCredentials.key.some(c => c.id === credId);
67
+ if (!allowed) {
68
+ throwSignerError(SignerErrorCode.CONFIG_ERROR, {
69
+ message: `Credential ${credId} not in allowed credentials`,
70
+ });
71
+ }
72
+
73
+ // Sign the challenge
74
+ const clientData = new TextEncoder().encode(
75
+ JSON.stringify({
76
+ challenge: challenge.challenge,
77
+ type: 'key.get',
78
+ }),
79
+ );
80
+
81
+ const signature = crypto.sign(undefined, clientData, privateKeyPem);
82
+
83
+ const clientDataB64 = base64UrlDecoder(clientData);
84
+ const signatureB64 = base64UrlDecoder(new Uint8Array(signature));
85
+
86
+ // Submit the signed challenge
87
+ const actionUrl = `${apiBaseUrl}/auth/action`;
88
+ let signResponse: Response;
89
+ try {
90
+ signResponse = await fetch(actionUrl, {
91
+ body: JSON.stringify({
92
+ challengeIdentifier: challenge.challengeIdentifier,
93
+ firstFactor: {
94
+ credentialAssertion: {
95
+ clientData: clientDataB64,
96
+ credId,
97
+ signature: signatureB64,
98
+ },
99
+ kind: 'Key',
100
+ },
101
+ }),
102
+ headers: {
103
+ Authorization: `Bearer ${authToken}`,
104
+ 'Content-Type': 'application/json',
105
+ },
106
+ method: 'POST',
107
+ });
108
+ } catch (error) {
109
+ throwSignerError(SignerErrorCode.HTTP_ERROR, {
110
+ cause: error,
111
+ message: 'Dfns network request failed',
112
+ url: actionUrl,
113
+ });
114
+ }
115
+
116
+ if (!signResponse.ok) {
117
+ const errorText = await signResponse.text().catch(() => 'Failed to read error response');
118
+ throwSignerError(SignerErrorCode.REMOTE_API_ERROR, {
119
+ message: `Dfns auth/action failed: ${signResponse.status}`,
120
+ response: errorText,
121
+ status: signResponse.status,
122
+ });
123
+ }
124
+
125
+ let actionResponse: UserActionResponse;
126
+ try {
127
+ actionResponse = (await signResponse.json()) as UserActionResponse;
128
+ } catch (error) {
129
+ throwSignerError(SignerErrorCode.PARSING_ERROR, {
130
+ cause: error,
131
+ message: 'Failed to parse Dfns auth action response',
132
+ });
133
+ }
134
+
135
+ return actionResponse.userAction;
136
+ }