@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.
Files changed (38) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +112 -0
  3. package/WavelengthReactNative.podspec +24 -0
  4. package/android/build.gradle +41 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthModule.kt +291 -0
  7. package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthPackage.kt +28 -0
  8. package/dist/NativeWalletdk.d.ts +31 -0
  9. package/dist/NativeWalletdk.d.ts.map +1 -0
  10. package/dist/NativeWalletdk.js +2 -0
  11. package/dist/NativeWavelength.d.ts +31 -0
  12. package/dist/NativeWavelength.d.ts.map +1 -0
  13. package/dist/NativeWavelength.js +2 -0
  14. package/dist/client.d.ts +56 -0
  15. package/dist/client.d.ts.map +1 -0
  16. package/dist/client.js +143 -0
  17. package/dist/config.d.ts +19 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/config.js +20 -0
  20. package/dist/index.d.ts +46 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +60 -0
  23. package/dist/passkey.d.ts +32 -0
  24. package/dist/passkey.d.ts.map +1 -0
  25. package/dist/passkey.js +244 -0
  26. package/ios/WavelengthModule.h +8 -0
  27. package/ios/WavelengthModule.mm +298 -0
  28. package/ios/WavelengthPasskey.swift +262 -0
  29. package/package.json +70 -0
  30. package/src/NativeWavelength.ts +32 -0
  31. package/src/client.test.ts +307 -0
  32. package/src/client.ts +222 -0
  33. package/src/config.test.ts +28 -0
  34. package/src/config.ts +28 -0
  35. package/src/index.ts +102 -0
  36. package/src/native-dispatch.test.ts +174 -0
  37. package/src/passkey.test.ts +301 -0
  38. 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
+ }