@lightninglabs/wavelength-react-native 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.
- package/LICENSE +19 -0
- package/README.md +112 -0
- package/WavelengthReactNative.podspec +24 -0
- package/android/build.gradle +41 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthModule.kt +291 -0
- package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthPackage.kt +28 -0
- package/dist/NativeWalletdk.d.ts +31 -0
- package/dist/NativeWalletdk.d.ts.map +1 -0
- package/dist/NativeWalletdk.js +2 -0
- package/dist/NativeWavelength.d.ts +31 -0
- package/dist/NativeWavelength.d.ts.map +1 -0
- package/dist/NativeWavelength.js +2 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +143 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/passkey.d.ts +32 -0
- package/dist/passkey.d.ts.map +1 -0
- package/dist/passkey.js +244 -0
- package/ios/WavelengthModule.h +8 -0
- package/ios/WavelengthModule.mm +298 -0
- package/ios/WavelengthPasskey.swift +262 -0
- package/package.json +70 -0
- package/src/NativeWavelength.ts +32 -0
- package/src/client.test.ts +307 -0
- package/src/client.ts +222 -0
- package/src/config.test.ts +28 -0
- package/src/config.ts +28 -0
- package/src/index.ts +102 -0
- package/src/native-dispatch.test.ts +174 -0
- package/src/passkey.test.ts +301 -0
- package/src/passkey.ts +336 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
import type { FacadeMethod } from '@lightninglabs/wavelength-core';
|
|
5
|
+
|
|
6
|
+
const android = new URL(
|
|
7
|
+
'../android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthModule.kt',
|
|
8
|
+
import.meta.url,
|
|
9
|
+
);
|
|
10
|
+
const ios = new URL('../ios/WavelengthModule.mm', import.meta.url);
|
|
11
|
+
|
|
12
|
+
const nativeMethods = {
|
|
13
|
+
start: ['Mobile.start', 'MobileStart'],
|
|
14
|
+
stop: ['Mobile.stop', 'MobileStop'],
|
|
15
|
+
getInfo: ['Mobile.getInfo', 'MobileGetInfo'],
|
|
16
|
+
status: ['Mobile.status', 'MobileStatus'],
|
|
17
|
+
balance: ['Mobile.balance', 'MobileBalance'],
|
|
18
|
+
createWallet: ['Mobile.createWallet', 'MobileCreateWallet'],
|
|
19
|
+
unlockWallet: ['Mobile.unlockWallet', 'MobileUnlockWallet'],
|
|
20
|
+
openWalletFromPasskey: [
|
|
21
|
+
'Mobile.openWalletFromPasskey',
|
|
22
|
+
'MobileOpenWalletFromPasskey',
|
|
23
|
+
],
|
|
24
|
+
deposit: ['Mobile.deposit', 'MobileDeposit'],
|
|
25
|
+
receive: ['Mobile.receive', 'MobileReceive'],
|
|
26
|
+
prepareSend: ['Mobile.prepareSend', 'MobilePrepareSend'],
|
|
27
|
+
sendPrepared: ['Mobile.sendPrepared', 'MobileSendPrepared'],
|
|
28
|
+
list: ['Mobile.list', 'MobileList'],
|
|
29
|
+
exit: ['Mobile.exit', 'MobileExit'],
|
|
30
|
+
exitStatus: ['Mobile.exitStatus', 'MobileExitStatus'],
|
|
31
|
+
exitSummary: ['Mobile.exitSummary', 'MobileExitSummary'],
|
|
32
|
+
getExitPlan: ['Mobile.getExitPlan', 'MobileGetExitPlan'],
|
|
33
|
+
sweepWallet: ['Mobile.sweepWallet', 'MobileSweepWallet'],
|
|
34
|
+
confirmedBalanceSat: [
|
|
35
|
+
'Mobile.confirmedBalanceSat',
|
|
36
|
+
'MobileConfirmedBalanceSat',
|
|
37
|
+
],
|
|
38
|
+
pendingInboundSat: [
|
|
39
|
+
'Mobile.pendingInboundSat',
|
|
40
|
+
'MobilePendingInboundSat',
|
|
41
|
+
],
|
|
42
|
+
walletReady: ['Mobile.walletReady', 'MobileWalletReady'],
|
|
43
|
+
isRunning: ['Mobile.isRunning', 'MobileIsRunning'],
|
|
44
|
+
} as const satisfies Record<FacadeMethod, readonly [string, string]>;
|
|
45
|
+
|
|
46
|
+
const scalarMethods = {
|
|
47
|
+
confirmedBalanceSat: 'integer',
|
|
48
|
+
pendingInboundSat: 'integer',
|
|
49
|
+
walletReady: 'boolean',
|
|
50
|
+
isRunning: 'boolean',
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
async function readNativeSources(): Promise<{ kt: string; mm: string }> {
|
|
54
|
+
const [kt, mm] = await Promise.all([
|
|
55
|
+
readFile(android, 'utf8'),
|
|
56
|
+
readFile(ios, 'utf8'),
|
|
57
|
+
]);
|
|
58
|
+
return { kt, mm };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function androidDispatch(source: string): Record<string, string> {
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
[...source.matchAll(/^\s*"([^"]+)"\s*->\s*(?:\{\s*)?(Mobile\.\w+)\(/gm)]
|
|
64
|
+
.map((match) => [match[1], match[2]]),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function iosDispatch(source: string): Record<string, string> {
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
[...source.matchAll(
|
|
71
|
+
/\[method isEqualToString:@"([^"]+)"\]\)\s*\{[\s\S]*?\b(Mobile[A-Z]\w*)\(/g,
|
|
72
|
+
)].map((match) => [match[1], match[2]]),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function section(source: string, start: string, end: string): string {
|
|
77
|
+
const startIndex = source.indexOf(start);
|
|
78
|
+
const endIndex = source.indexOf(end, startIndex);
|
|
79
|
+
assert.notEqual(startIndex, -1, `missing section start: ${start}`);
|
|
80
|
+
assert.notEqual(endIndex, -1, `missing section end: ${end}`);
|
|
81
|
+
|
|
82
|
+
return source.slice(startIndex, endIndex);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('native facade dispatch', () => {
|
|
86
|
+
it('maps every facade method to the matching Android and iOS entry point', async () => {
|
|
87
|
+
const { kt, mm } = await readNativeSources();
|
|
88
|
+
const expectedAndroid = Object.fromEntries(
|
|
89
|
+
Object.entries(nativeMethods).map(([method, names]) => [method, names[0]]),
|
|
90
|
+
);
|
|
91
|
+
const expectedIos = Object.fromEntries(
|
|
92
|
+
Object.entries(nativeMethods).map(([method, names]) => [method, names[1]]),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(androidDispatch(kt), expectedAndroid);
|
|
96
|
+
assert.deepEqual(iosDispatch(mm), expectedIos);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
for (const [method, kind] of Object.entries(scalarMethods)) {
|
|
100
|
+
it(`serializes the ${method} ${kind} as JSON text`, async () => {
|
|
101
|
+
const { kt, mm } = await readNativeSources();
|
|
102
|
+
const [androidMethod, iosMethod] =
|
|
103
|
+
nativeMethods[method as keyof typeof scalarMethods];
|
|
104
|
+
|
|
105
|
+
assert.match(
|
|
106
|
+
kt,
|
|
107
|
+
new RegExp(
|
|
108
|
+
`"${method}"\\s*->\\s*${androidMethod.replace('.', '\\.')}\\(\\)\\.toString\\(\\)`,
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
if (method === 'isRunning') {
|
|
112
|
+
assert.match(
|
|
113
|
+
mm,
|
|
114
|
+
new RegExp(`json = ${iosMethod}\\(\\) \\? @"true" : @"false"`),
|
|
115
|
+
);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'boolean') {
|
|
119
|
+
assert.match(mm, /BOOL value = NO;/);
|
|
120
|
+
assert.match(mm, new RegExp(`${iosMethod}\\(&value, &error\\);`));
|
|
121
|
+
assert.match(mm, /json = value \? @"true" : @"false";/);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
assert.match(mm, /int64_t value = 0;/);
|
|
125
|
+
assert.match(mm, new RegExp(`${iosMethod}\\(&value, &error\\);`));
|
|
126
|
+
assert.match(
|
|
127
|
+
mm,
|
|
128
|
+
/json = \[NSString stringWithFormat:@"%lld",\s*\(long long\)value\]/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('detaches a stopped subscription before close and identifies its old pump by identity', async () => {
|
|
134
|
+
const { kt, mm } = await readNativeSources();
|
|
135
|
+
const androidStop = section(
|
|
136
|
+
kt,
|
|
137
|
+
'override fun stopActivity',
|
|
138
|
+
'// pump drains the facade',
|
|
139
|
+
);
|
|
140
|
+
const iosStop = section(
|
|
141
|
+
mm,
|
|
142
|
+
'- (void)stopActivity:',
|
|
143
|
+
'// pump drains the subscription',
|
|
144
|
+
);
|
|
145
|
+
const androidPump = section(kt, 'private fun pump', 'private fun sendEvent');
|
|
146
|
+
const iosPump = section(mm, '- (void)pump:', '- (std::shared_ptr');
|
|
147
|
+
|
|
148
|
+
assert.match(androidStop, /subscription\s*=\s*null/);
|
|
149
|
+
assert.ok(
|
|
150
|
+
androidStop.indexOf('subscription = null') < androidStop.indexOf('sub?.close()'),
|
|
151
|
+
'Android must detach the stopped subscription before close()',
|
|
152
|
+
);
|
|
153
|
+
assert.match(androidPump, /subscription\s*!==\s*sub/);
|
|
154
|
+
assert.match(androidPump, /if\s*\(stopped\)\s*\{\s*break\s*\}/);
|
|
155
|
+
assert.equal(
|
|
156
|
+
androidPump.match(/subscription\s*!==\s*sub/g)?.length,
|
|
157
|
+
2,
|
|
158
|
+
'Android must re-check ownership before emitting an entry',
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
assert.match(iosStop, /self->_subscription\s*=\s*nil/);
|
|
162
|
+
assert.ok(
|
|
163
|
+
iosStop.indexOf('self->_subscription = nil') < iosStop.indexOf('[sub close:&error]'),
|
|
164
|
+
'iOS must detach the stopped subscription before close',
|
|
165
|
+
);
|
|
166
|
+
assert.match(iosPump, /self->_subscription\s*!=\s*sub/);
|
|
167
|
+
assert.match(iosPump, /if\s*\(stopped\)\s*\{\s*break;\s*\}/);
|
|
168
|
+
assert.equal(
|
|
169
|
+
iosPump.match(/self->_subscription\s*!=\s*sub/g)?.length,
|
|
170
|
+
2,
|
|
171
|
+
'iOS must re-check ownership before emitting an entry',
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { PasskeyCancelledError } from '@lightninglabs/wavelength-core';
|
|
4
|
+
import {
|
|
5
|
+
nativePasskeyCeremony,
|
|
6
|
+
type WavelengthPasskeyNativeModule,
|
|
7
|
+
} from './passkey.ts';
|
|
8
|
+
|
|
9
|
+
// The PRF salt (SHA-256 of the shared namespace) in the two encodings the
|
|
10
|
+
// ceremony round-trips between; pinned in core's passkey test.
|
|
11
|
+
const SALT_B64URL = 'mnouD_PF0fLxcs1e3WdSe_OSrjmQSOtuNrnLbDq4nQM';
|
|
12
|
+
|
|
13
|
+
// A 32-byte PRF output fixture (0xabcd repeated), since the ceremony rejects
|
|
14
|
+
// anything that is not exactly 32 bytes of key material.
|
|
15
|
+
const PRF32_B64URL = 'q82rzavNq82rzavNq82rzavNq82rzavNq82rzavNq80';
|
|
16
|
+
const PRF32_HEX = 'abcd'.repeat(16);
|
|
17
|
+
|
|
18
|
+
// A scriptable fake of the native ceremony methods: records request JSON and
|
|
19
|
+
// replays canned responses, mirroring client.test.ts's fake pattern.
|
|
20
|
+
function makeFake() {
|
|
21
|
+
const calls: Array<{ method: 'create' | 'get'; request: any }> = [];
|
|
22
|
+
const fake = {
|
|
23
|
+
supported: Promise.resolve(true),
|
|
24
|
+
createResponse: '' as string,
|
|
25
|
+
getResponse: '' as string,
|
|
26
|
+
hang: false,
|
|
27
|
+
native: {
|
|
28
|
+
passkeySupported: () => fake.supported,
|
|
29
|
+
passkeyCreate(requestJson: string) {
|
|
30
|
+
calls.push({ method: 'create', request: JSON.parse(requestJson) });
|
|
31
|
+
return fake.createResponse.startsWith('REJECT')
|
|
32
|
+
? Promise.reject(new Error(fake.createResponse.slice(7)))
|
|
33
|
+
: Promise.resolve(fake.createResponse);
|
|
34
|
+
},
|
|
35
|
+
passkeyGet(requestJson: string) {
|
|
36
|
+
calls.push({ method: 'get', request: JSON.parse(requestJson) });
|
|
37
|
+
if (fake.hang) {
|
|
38
|
+
return new Promise<string>(() => {});
|
|
39
|
+
}
|
|
40
|
+
return fake.getResponse.startsWith('REJECT')
|
|
41
|
+
? Promise.reject(new Error(fake.getResponse.slice(7)))
|
|
42
|
+
: Promise.resolve(fake.getResponse);
|
|
43
|
+
},
|
|
44
|
+
} satisfies WavelengthPasskeyNativeModule,
|
|
45
|
+
calls,
|
|
46
|
+
};
|
|
47
|
+
return fake;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const withPrf = (id: string, firstB64url: string) =>
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
id,
|
|
53
|
+
rawId: id,
|
|
54
|
+
type: 'public-key',
|
|
55
|
+
clientExtensionResults: { prf: { results: { first: firstB64url } } },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('nativePasskeyCeremony', () => {
|
|
59
|
+
it('builds the creation request with the shared salt and rpId', async () => {
|
|
60
|
+
const fake = makeFake();
|
|
61
|
+
fake.createResponse = withPrf('cred-1', PRF32_B64URL);
|
|
62
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
63
|
+
|
|
64
|
+
await ceremony.registerPasskeyWallet('Demo App');
|
|
65
|
+
|
|
66
|
+
const req = fake.calls[0].request;
|
|
67
|
+
assert.equal(fake.calls[0].method, 'create');
|
|
68
|
+
assert.equal(req.challenge, SALT_B64URL);
|
|
69
|
+
assert.equal(req.rp.id, 'rp.example');
|
|
70
|
+
assert.equal(req.rp.name, 'Demo App');
|
|
71
|
+
assert.equal(req.user.name, 'Demo App');
|
|
72
|
+
assert.equal(typeof req.user.id, 'string');
|
|
73
|
+
assert.ok(req.user.id.length > 0);
|
|
74
|
+
assert.equal(req.authenticatorSelection.residentKey, 'required');
|
|
75
|
+
assert.equal(req.authenticatorSelection.userVerification, 'required');
|
|
76
|
+
assert.equal(req.extensions.prf.eval.first, SALT_B64URL);
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
req.pubKeyCredParams.map((p: { alg: number }) => p.alg),
|
|
79
|
+
[-7, -257],
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns hex PRF output and credential id from registration', async () => {
|
|
84
|
+
const fake = makeFake();
|
|
85
|
+
fake.createResponse = withPrf('cred-1', PRF32_B64URL);
|
|
86
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
87
|
+
|
|
88
|
+
const result = await ceremony.registerPasskeyWallet('Demo App');
|
|
89
|
+
|
|
90
|
+
assert.equal(result.prfOutput, PRF32_HEX);
|
|
91
|
+
assert.equal(result.credentialId, 'cred-1');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('falls back to a scoped assertion when create omits PRF', async () => {
|
|
95
|
+
const fake = makeFake();
|
|
96
|
+
fake.createResponse = JSON.stringify({ id: 'cred-2', type: 'public-key' });
|
|
97
|
+
fake.getResponse = withPrf('cred-2', PRF32_B64URL);
|
|
98
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
99
|
+
|
|
100
|
+
const result = await ceremony.registerPasskeyWallet('Demo App');
|
|
101
|
+
|
|
102
|
+
assert.equal(result.prfOutput, PRF32_HEX);
|
|
103
|
+
assert.equal(result.credentialId, 'cred-2');
|
|
104
|
+
const getReq = fake.calls[1];
|
|
105
|
+
assert.equal(getReq.method, 'get');
|
|
106
|
+
assert.deepEqual(getReq.request.allowCredentials, [
|
|
107
|
+
{ type: 'public-key', id: 'cred-2' },
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('scopes assertion to the given credential id', async () => {
|
|
112
|
+
const fake = makeFake();
|
|
113
|
+
fake.getResponse = withPrf('cred-3', PRF32_B64URL);
|
|
114
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
115
|
+
|
|
116
|
+
await ceremony.assertPasskeyPrf('cred-3');
|
|
117
|
+
|
|
118
|
+
const req = fake.calls[0].request;
|
|
119
|
+
assert.equal(req.rpId, 'rp.example');
|
|
120
|
+
assert.equal(req.challenge, SALT_B64URL);
|
|
121
|
+
assert.equal(req.userVerification, 'required');
|
|
122
|
+
assert.deepEqual(req.allowCredentials, [
|
|
123
|
+
{ type: 'public-key', id: 'cred-3' },
|
|
124
|
+
]);
|
|
125
|
+
assert.equal(req.extensions.prf.eval.first, SALT_B64URL);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('sends empty allowCredentials for a discoverable assertion', async () => {
|
|
129
|
+
const fake = makeFake();
|
|
130
|
+
fake.getResponse = withPrf('cred-4', PRF32_B64URL);
|
|
131
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
132
|
+
|
|
133
|
+
await ceremony.assertPasskeyPrf();
|
|
134
|
+
|
|
135
|
+
assert.deepEqual(fake.calls[0].request.allowCredentials, []);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('throws the parity error when the assertion returns no PRF', async () => {
|
|
139
|
+
const fake = makeFake();
|
|
140
|
+
fake.getResponse = JSON.stringify({ id: 'cred-5', type: 'public-key' });
|
|
141
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
142
|
+
|
|
143
|
+
await assert.rejects(
|
|
144
|
+
() => ceremony.assertPasskeyPrf(),
|
|
145
|
+
/passkey PRF extension result was not returned by this authenticator/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('propagates native rejection messages', async () => {
|
|
150
|
+
const fake = makeFake();
|
|
151
|
+
fake.createResponse = 'REJECT:native module unavailable';
|
|
152
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
153
|
+
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
() => ceremony.registerPasskeyWallet('Demo App'),
|
|
156
|
+
/native module unavailable/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('maps a user-cancelled native ceremony to PasskeyCancelledError', async () => {
|
|
161
|
+
const fake = makeFake();
|
|
162
|
+
fake.createResponse =
|
|
163
|
+
'REJECT:androidx.credentials.exceptions.GetCredentialCancellationException: User cancelled';
|
|
164
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
165
|
+
|
|
166
|
+
await assert.rejects(
|
|
167
|
+
() => ceremony.registerPasskeyWallet('Demo App'),
|
|
168
|
+
(err: unknown) => err instanceof PasskeyCancelledError,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not map an unrelated failure that merely mentions cancel to cancellation', async () => {
|
|
173
|
+
const fake = makeFake();
|
|
174
|
+
fake.createResponse = 'REJECT:cancellation token invalid while registering';
|
|
175
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
176
|
+
|
|
177
|
+
await assert.rejects(
|
|
178
|
+
() => ceremony.registerPasskeyWallet('Demo App'),
|
|
179
|
+
(err: unknown) =>
|
|
180
|
+
err instanceof Error &&
|
|
181
|
+
!(err instanceof PasskeyCancelledError) &&
|
|
182
|
+
/cancellation token invalid while registering/.test(err.message),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does not map a timeout to cancellation', async (t) => {
|
|
187
|
+
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
188
|
+
const fake = makeFake();
|
|
189
|
+
fake.hang = true;
|
|
190
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
191
|
+
|
|
192
|
+
const pending = ceremony.assertPasskeyPrf();
|
|
193
|
+
const rejects = assert.rejects(
|
|
194
|
+
pending,
|
|
195
|
+
(err: unknown) =>
|
|
196
|
+
err instanceof Error && !(err instanceof PasskeyCancelledError),
|
|
197
|
+
);
|
|
198
|
+
t.mock.timers.tick(120000);
|
|
199
|
+
await rejects;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('supportsPasskeyPrf degrades to false when the probe rejects', async () => {
|
|
203
|
+
const fake = makeFake();
|
|
204
|
+
fake.supported = Promise.reject(new Error('no module'));
|
|
205
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
206
|
+
|
|
207
|
+
assert.equal(await ceremony.supportsPasskeyPrf(), false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('memoizes supportsPasskeyPrf per ceremony instance, and retries after a rejection', async () => {
|
|
211
|
+
const calls: boolean[] = [];
|
|
212
|
+
let behavior: 'reject' | 'resolve' = 'reject';
|
|
213
|
+
const native: WavelengthPasskeyNativeModule = {
|
|
214
|
+
passkeySupported: async () => {
|
|
215
|
+
calls.push(true);
|
|
216
|
+
if (behavior === 'reject') {
|
|
217
|
+
throw new Error('no module');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
222
|
+
passkeyCreate: async () => '',
|
|
223
|
+
passkeyGet: async () => '',
|
|
224
|
+
};
|
|
225
|
+
const ceremony = nativePasskeyCeremony(native, { rpId: 'rp.example' });
|
|
226
|
+
|
|
227
|
+
// The first call's probe rejects; the ceremony still degrades to false,
|
|
228
|
+
// but does not cache the rejection.
|
|
229
|
+
assert.equal(await ceremony.supportsPasskeyPrf(), false);
|
|
230
|
+
assert.equal(calls.length, 1);
|
|
231
|
+
|
|
232
|
+
// A rejected probe is retryable.
|
|
233
|
+
behavior = 'resolve';
|
|
234
|
+
assert.equal(await ceremony.supportsPasskeyPrf(), true);
|
|
235
|
+
assert.equal(calls.length, 2);
|
|
236
|
+
|
|
237
|
+
// A resolved probe is memoized for this ceremony instance.
|
|
238
|
+
const [a, b] = await Promise.all([
|
|
239
|
+
ceremony.supportsPasskeyPrf(),
|
|
240
|
+
ceremony.supportsPasskeyPrf(),
|
|
241
|
+
]);
|
|
242
|
+
assert.equal(a, true);
|
|
243
|
+
assert.equal(b, true);
|
|
244
|
+
assert.equal(calls.length, 2);
|
|
245
|
+
|
|
246
|
+
// A different ceremony instance gets its own memo, not the shared one.
|
|
247
|
+
const otherCalls: boolean[] = [];
|
|
248
|
+
const otherNative: WavelengthPasskeyNativeModule = {
|
|
249
|
+
passkeySupported: async () => {
|
|
250
|
+
otherCalls.push(true);
|
|
251
|
+
|
|
252
|
+
return true;
|
|
253
|
+
},
|
|
254
|
+
passkeyCreate: async () => '',
|
|
255
|
+
passkeyGet: async () => '',
|
|
256
|
+
};
|
|
257
|
+
const otherCeremony = nativePasskeyCeremony(otherNative, { rpId: 'rp.example' });
|
|
258
|
+
assert.equal(await otherCeremony.supportsPasskeyPrf(), true);
|
|
259
|
+
assert.equal(otherCalls.length, 1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('rejects a PRF output that is not 32 bytes', async () => {
|
|
263
|
+
const fake = makeFake();
|
|
264
|
+
// Two bytes of key material; deriving a wallet from it must never happen.
|
|
265
|
+
fake.getResponse = withPrf('cred-7', 'q80');
|
|
266
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
267
|
+
|
|
268
|
+
await assert.rejects(
|
|
269
|
+
() => ceremony.assertPasskeyPrf(),
|
|
270
|
+
/passkey PRF output is not 32 bytes/,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('rejects a corrupted PRF payload instead of decoding around it', async () => {
|
|
275
|
+
const fake = makeFake();
|
|
276
|
+
// The '!' is outside the base64url alphabet; skipping it would silently
|
|
277
|
+
// derive a different wallet, so the codec must fail closed.
|
|
278
|
+
fake.getResponse = withPrf('cred-6', 'q8!0');
|
|
279
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
280
|
+
|
|
281
|
+
await assert.rejects(
|
|
282
|
+
() => ceremony.assertPasskeyPrf(),
|
|
283
|
+
/malformed base64url payload in passkey response/,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('rejects when the native ceremony never settles', async (t) => {
|
|
288
|
+
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
289
|
+
const fake = makeFake();
|
|
290
|
+
fake.hang = true;
|
|
291
|
+
const ceremony = nativePasskeyCeremony(fake.native, { rpId: 'rp.example' });
|
|
292
|
+
|
|
293
|
+
const pending = ceremony.assertPasskeyPrf();
|
|
294
|
+
const rejects = assert.rejects(
|
|
295
|
+
pending,
|
|
296
|
+
/passkey authentication timed out/,
|
|
297
|
+
);
|
|
298
|
+
t.mock.timers.tick(120000);
|
|
299
|
+
await rejects;
|
|
300
|
+
});
|
|
301
|
+
});
|