@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
package/src/passkey.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PASSKEY_PRF_SALT_HEX,
|
|
3
|
+
PasskeyCancelledError,
|
|
4
|
+
} from '@lightninglabs/wavelength-core';
|
|
5
|
+
import type {
|
|
6
|
+
PasskeyAssertion,
|
|
7
|
+
PasskeyCeremony,
|
|
8
|
+
} from '@lightninglabs/wavelength-core';
|
|
9
|
+
|
|
10
|
+
// Whether a native ceremony rejection means the user dismissed the OS prompt.
|
|
11
|
+
// iOS surfaces ASAuthorizationError code 1001 ("canceled"); Android surfaces
|
|
12
|
+
// GetCredentialCancellationException / CreateCredentialCancellationException
|
|
13
|
+
// with "cancel" in the type or message. Message matching is the only signal
|
|
14
|
+
// that crosses the bridge uniformly, since the bridge flattens native
|
|
15
|
+
// exceptions to a plain Error with no structured code. Known exception type
|
|
16
|
+
// names are matched first, since they are unambiguous; the narrowed
|
|
17
|
+
// "user cancel" regex is a fallback for messages that carry the platform
|
|
18
|
+
// wording without the type name. A bare /cancel/i is deliberately avoided so
|
|
19
|
+
// an unrelated failure whose message merely contains "cancel" (e.g.
|
|
20
|
+
// "cancellation token invalid") is not misclassified as a user cancellation.
|
|
21
|
+
// Follow-up: a native-side sentinel (an error code field rather than message
|
|
22
|
+
// text) would make this exact instead of best-effort.
|
|
23
|
+
function isNativeCancel(err: unknown): boolean {
|
|
24
|
+
const message =
|
|
25
|
+
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
/GetCredentialCancellationException/.test(message) ||
|
|
29
|
+
/CreateCredentialCancellationException/.test(message)
|
|
30
|
+
) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (/ASAuthorizationError/.test(message) && /\b1001\b/.test(message)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (/\berror\s*1001\b/i.test(message)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return /\buser.{0,10}cancel/i.test(message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A passkey ceremony that neither resolves nor rejects within this bound has
|
|
44
|
+
// wedged (a silent native provider); reject so useWalletPasskey's
|
|
45
|
+
// createPending/openPending flags cannot stick until an app restart.
|
|
46
|
+
// Generous enough that a real user completing biometrics or a PIN never
|
|
47
|
+
// trips it.
|
|
48
|
+
const PASSKEY_TIMEOUT_MS = 120000;
|
|
49
|
+
|
|
50
|
+
// withPasskeyTimeout rejects if the native ceremony call has not settled
|
|
51
|
+
// within PASSKEY_TIMEOUT_MS. It does not cancel the native ceremony; it only
|
|
52
|
+
// unwedges the JavaScript promise.
|
|
53
|
+
function withPasskeyTimeout<T>(op: Promise<T>, label: string): Promise<T> {
|
|
54
|
+
return new Promise<T>((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(
|
|
56
|
+
() => reject(new Error(`passkey ${label} timed out`)),
|
|
57
|
+
PASSKEY_TIMEOUT_MS,
|
|
58
|
+
);
|
|
59
|
+
op.then(
|
|
60
|
+
(value) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
resolve(value);
|
|
63
|
+
},
|
|
64
|
+
(err) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
reject(err);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The subset of the native Turbo Module the passkey ceremony depends on.
|
|
74
|
+
* Narrowed to an interface (rather than the generated Spec) so unit tests can
|
|
75
|
+
* inject a fake without loading react-native.
|
|
76
|
+
*/
|
|
77
|
+
export type WavelengthPasskeyNativeModule = {
|
|
78
|
+
/** Reports whether the platform can run a passkey PRF ceremony. */
|
|
79
|
+
passkeySupported(): Promise<boolean>;
|
|
80
|
+
/** Runs a passkey registration ceremony; WebAuthn JSON in and out. */
|
|
81
|
+
passkeyCreate(requestJson: string): Promise<string>;
|
|
82
|
+
/** Runs a passkey assertion ceremony; WebAuthn JSON in and out. */
|
|
83
|
+
passkeyGet(requestJson: string): Promise<string>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Options for creating the native passkey ceremony. */
|
|
87
|
+
export type NativePasskeyCeremonyOptions = {
|
|
88
|
+
/**
|
|
89
|
+
* The WebAuthn relying-party id: the domain whose
|
|
90
|
+
* /.well-known/assetlinks.json (Android) and apple-app-site-association
|
|
91
|
+
* (iOS) vouch for this app. Native apps have no window.location, so the
|
|
92
|
+
* rpId is explicit configuration.
|
|
93
|
+
*/
|
|
94
|
+
rpId: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Builds a {@link PasskeyCeremony} over the given native ceremony methods.
|
|
99
|
+
* The factory in index.ts wires the real Turbo Module; unit tests inject a
|
|
100
|
+
* fake. Request and response payloads are standard WebAuthn JSON with
|
|
101
|
+
* base64url binary fields, the format both platform APIs speak natively.
|
|
102
|
+
*/
|
|
103
|
+
export function nativePasskeyCeremony(
|
|
104
|
+
native: WavelengthPasskeyNativeModule,
|
|
105
|
+
options: NativePasskeyCeremonyOptions,
|
|
106
|
+
): PasskeyCeremony {
|
|
107
|
+
const saltB64url = hexToBase64Url(PASSKEY_PRF_SALT_HEX);
|
|
108
|
+
|
|
109
|
+
// The memoized probe promise for this ceremony instance. The probe reads
|
|
110
|
+
// native.passkeySupported(), which does not depend on options (rpId etc.),
|
|
111
|
+
// but a fresh native module can be wired into a different ceremony
|
|
112
|
+
// instance, so the memo lives per instance rather than at module scope.
|
|
113
|
+
let supportsPasskeyPrfProbe: Promise<boolean> | null = null;
|
|
114
|
+
|
|
115
|
+
const assertPasskeyPrf = async (
|
|
116
|
+
allowCredentialId?: string,
|
|
117
|
+
): Promise<PasskeyAssertion> => {
|
|
118
|
+
const request = {
|
|
119
|
+
challenge: saltB64url,
|
|
120
|
+
rpId: options.rpId,
|
|
121
|
+
allowCredentials: allowCredentialId
|
|
122
|
+
? [{ type: 'public-key', id: allowCredentialId }]
|
|
123
|
+
: [],
|
|
124
|
+
userVerification: 'required',
|
|
125
|
+
extensions: { prf: { eval: { first: saltB64url } } },
|
|
126
|
+
};
|
|
127
|
+
let responseJson: string;
|
|
128
|
+
try {
|
|
129
|
+
responseJson = await withPasskeyTimeout(
|
|
130
|
+
native.passkeyGet(JSON.stringify(request)),
|
|
131
|
+
'authentication',
|
|
132
|
+
);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw isNativeCancel(err) ? new PasskeyCancelledError() : err;
|
|
135
|
+
}
|
|
136
|
+
const response = JSON.parse(responseJson) as WebAuthnResponse;
|
|
137
|
+
|
|
138
|
+
return { prfOutput: requirePrfHex(response), credentialId: response.id };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
// Memoized per ceremony instance: the first call stores the in-flight
|
|
143
|
+
// probe and every later call reuses it. A rejection is not cached: the
|
|
144
|
+
// memo is cleared first so a later call retries the probe, and this
|
|
145
|
+
// call still degrades to false rather than leaving an unhandled
|
|
146
|
+
// rejection.
|
|
147
|
+
async supportsPasskeyPrf() {
|
|
148
|
+
if (!supportsPasskeyPrfProbe) {
|
|
149
|
+
supportsPasskeyPrfProbe = native.passkeySupported();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
return await supportsPasskeyPrfProbe;
|
|
154
|
+
} catch {
|
|
155
|
+
supportsPasskeyPrfProbe = null;
|
|
156
|
+
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async registerPasskeyWallet(appName: string): Promise<PasskeyAssertion> {
|
|
162
|
+
const request = {
|
|
163
|
+
challenge: saltB64url,
|
|
164
|
+
rp: { id: options.rpId, name: appName },
|
|
165
|
+
user: {
|
|
166
|
+
id: randomUserIdBase64Url(),
|
|
167
|
+
name: appName,
|
|
168
|
+
displayName: appName,
|
|
169
|
+
},
|
|
170
|
+
pubKeyCredParams: [
|
|
171
|
+
{ alg: -7, type: 'public-key' },
|
|
172
|
+
{ alg: -257, type: 'public-key' },
|
|
173
|
+
],
|
|
174
|
+
authenticatorSelection: {
|
|
175
|
+
authenticatorAttachment: 'platform',
|
|
176
|
+
userVerification: 'required',
|
|
177
|
+
residentKey: 'required',
|
|
178
|
+
},
|
|
179
|
+
extensions: { prf: { eval: { first: saltB64url } } },
|
|
180
|
+
};
|
|
181
|
+
let responseJson: string;
|
|
182
|
+
try {
|
|
183
|
+
responseJson = await withPasskeyTimeout(
|
|
184
|
+
native.passkeyCreate(JSON.stringify(request)),
|
|
185
|
+
'registration',
|
|
186
|
+
);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw isNativeCancel(err) ? new PasskeyCancelledError() : err;
|
|
189
|
+
}
|
|
190
|
+
const response = JSON.parse(responseJson) as WebAuthnResponse;
|
|
191
|
+
|
|
192
|
+
const first = prfFirst(response);
|
|
193
|
+
if (first) {
|
|
194
|
+
return {
|
|
195
|
+
prfOutput: prfOutputHex(first),
|
|
196
|
+
credentialId: response.id,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Some providers do not surface PRF from create; read it with an
|
|
201
|
+
// assertion scoped to the just-created credential, mirroring the web
|
|
202
|
+
// ceremony's fallback.
|
|
203
|
+
return assertPasskeyPrf(response.id);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
assertPasskeyPrf,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The slice of a WebAuthn JSON response the ceremony reads.
|
|
211
|
+
type WebAuthnResponse = {
|
|
212
|
+
id: string;
|
|
213
|
+
clientExtensionResults?: { prf?: { results?: { first?: string } } };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// prfFirst plucks the base64url PRF output from a response, or null.
|
|
217
|
+
function prfFirst(response: WebAuthnResponse): string | null {
|
|
218
|
+
return response?.clientExtensionResults?.prf?.results?.first ?? null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// requirePrfHex reads the mandatory PRF output as hex, matching the web
|
|
222
|
+
// ceremony's error when the authenticator did not return one.
|
|
223
|
+
function requirePrfHex(response: WebAuthnResponse): string {
|
|
224
|
+
const first = prfFirst(response);
|
|
225
|
+
if (!first) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
'passkey PRF extension result was not returned by this authenticator',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return prfOutputHex(first);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// prfOutputHex decodes a PRF output and requires the WebAuthn-mandated 32
|
|
235
|
+
// bytes: short or padded key material must never reach wallet derivation.
|
|
236
|
+
function prfOutputHex(firstB64url: string): string {
|
|
237
|
+
const hex = base64UrlToHex(firstB64url);
|
|
238
|
+
if (hex.length !== 64) {
|
|
239
|
+
throw new Error('passkey PRF output is not 32 bytes');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return hex;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// randomUserIdBase64Url makes a fresh 16-byte WebAuthn user handle. user.id
|
|
246
|
+
// is an account identifier, not key material, so cryptographic randomness is
|
|
247
|
+
// not required; crypto.getRandomValues is still preferred when the runtime
|
|
248
|
+
// provides it.
|
|
249
|
+
function randomUserIdBase64Url(): string {
|
|
250
|
+
const bytes = new Uint8Array(16);
|
|
251
|
+
// Typed structurally (not as the DOM lib's Crypto) because this package's
|
|
252
|
+
// tsconfig targets ES2022 without DOM, matching its Hermes/RN runtime.
|
|
253
|
+
const cryptoApi = (
|
|
254
|
+
globalThis as {
|
|
255
|
+
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array };
|
|
256
|
+
}
|
|
257
|
+
).crypto;
|
|
258
|
+
if (cryptoApi?.getRandomValues) {
|
|
259
|
+
cryptoApi.getRandomValues(bytes);
|
|
260
|
+
} else {
|
|
261
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
262
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return bytesToBase64Url(bytes);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The base64url alphabet, indexed by 6-bit value.
|
|
270
|
+
const B64URL =
|
|
271
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
272
|
+
|
|
273
|
+
// hexToBase64Url re-encodes a lower-case hex string as unpadded base64url.
|
|
274
|
+
// Hand-rolled because Hermes offers neither Buffer nor a guaranteed atob.
|
|
275
|
+
function hexToBase64Url(hex: string): string {
|
|
276
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
277
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
278
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return bytesToBase64Url(bytes);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// base64UrlToHex decodes unpadded base64url and renders lower-case hex.
|
|
285
|
+
function base64UrlToHex(value: string): string {
|
|
286
|
+
return Array.from(base64UrlToBytes(value))
|
|
287
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
288
|
+
.join('');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function bytesToBase64Url(bytes: Uint8Array): string {
|
|
292
|
+
let out = '';
|
|
293
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
294
|
+
const a = bytes[i];
|
|
295
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
|
296
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
|
297
|
+
out += B64URL[a >> 2];
|
|
298
|
+
out += B64URL[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
|
|
299
|
+
if (b !== undefined) {
|
|
300
|
+
out += B64URL[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
|
|
301
|
+
}
|
|
302
|
+
if (c !== undefined) {
|
|
303
|
+
out += B64URL[c & 0x3f];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function base64UrlToBytes(value: string): Uint8Array {
|
|
311
|
+
// Tolerate standard base64 alphabet and padding so provider quirks cannot
|
|
312
|
+
// bite, but fail closed on anything else: this decodes wallet key
|
|
313
|
+
// material, and silently skipping a corrupted character would derive a
|
|
314
|
+
// different wallet instead of surfacing an error.
|
|
315
|
+
const normalized = value.replace(/\+/g, '-').replace(/\//g, '_');
|
|
316
|
+
const out: number[] = [];
|
|
317
|
+
let buffer = 0;
|
|
318
|
+
let bits = 0;
|
|
319
|
+
for (const ch of normalized) {
|
|
320
|
+
const idx = B64URL.indexOf(ch);
|
|
321
|
+
if (idx < 0) {
|
|
322
|
+
if (ch === '=' || /\s/.test(ch)) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
throw new Error('malformed base64url payload in passkey response');
|
|
326
|
+
}
|
|
327
|
+
buffer = (buffer << 6) | idx;
|
|
328
|
+
bits += 6;
|
|
329
|
+
if (bits >= 8) {
|
|
330
|
+
bits -= 8;
|
|
331
|
+
out.push((buffer >> bits) & 0xff);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return Uint8Array.from(out);
|
|
336
|
+
}
|