@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/dist/client.js ADDED
@@ -0,0 +1,143 @@
1
+ import { BaseWavelengthClient, WavelengthError, errorMessage, } from '@lightninglabs/wavelength-core';
2
+ /**
3
+ * The React Native transport: implements {@link BaseWavelengthClient}'s pipe
4
+ * over the gomobile Turbo Module. JSON strings cross the RN bridge, then the
5
+ * shared base client normalizes responses and streamed entries in TS.
6
+ */
7
+ export class NativeWavelengthClient extends BaseWavelengthClient {
8
+ // The embedded daemon runs natively, so it dials the servers over gRPC.
9
+ serverTransport = 'grpc';
10
+ removeNativeListener = null;
11
+ // Serializes start/stop native ops so a start always waits for a pending
12
+ // stop's native close to finish before it subscribes.
13
+ opChain = Promise.resolve();
14
+ // Whether a native subscription is currently open. Only read and written
15
+ // inside serialized ops, so it never races.
16
+ streamOpen = false;
17
+ native;
18
+ subscribeToNativeEvents;
19
+ constructor(native, subscribeToNativeEvents) {
20
+ super();
21
+ this.native = native;
22
+ this.subscribeToNativeEvents = subscribeToNativeEvents;
23
+ }
24
+ // enqueue runs op after the previous op settles, whether it fulfilled or
25
+ // rejected, so a rejected native call cannot stall the chain.
26
+ enqueue(op) {
27
+ const next = this.opChain.then(op, op);
28
+ this.opChain = next;
29
+ return next;
30
+ }
31
+ // The runtime is compiled into the app binary, so there is nothing to load.
32
+ ready() {
33
+ return Promise.resolve();
34
+ }
35
+ // start fills in the platform default data directory when the caller did
36
+ // not choose one; only the native side knows the app's sandbox paths.
37
+ async start(config) {
38
+ return super.start({
39
+ ...config,
40
+ dataDir: config.dataDir ?? (await this.native.getDefaultDataDir()),
41
+ });
42
+ }
43
+ async invokeFacade(method, params = {}) {
44
+ try {
45
+ const resultJson = await this.native.call(method, JSON.stringify(params ?? {}));
46
+ return (resultJson ? JSON.parse(resultJson) : null);
47
+ }
48
+ catch (err) {
49
+ throw new WavelengthError(errorMessage(err), 'wavelength_error', {
50
+ cause: err,
51
+ });
52
+ }
53
+ }
54
+ // startActivity opens the native pull subscription; the native side pumps
55
+ // entries to 'wavelengthActivity' device events, which are re-emitted here
56
+ // as typed 'activity' events. Idempotent while a stream is open.
57
+ async openActivityStream(opts) {
58
+ return this.enqueue(async () => {
59
+ if (this.streamOpen) {
60
+ return;
61
+ }
62
+ this.removeNativeListener ??= this.subscribeToNativeEvents((event) => this.onNativeEvent(event));
63
+ await this.native.startActivity(JSON.stringify({
64
+ includeExisting: opts.includeExisting ?? false,
65
+ kinds: opts.kinds ?? [],
66
+ cursor: opts.cursor ?? 0,
67
+ }));
68
+ this.streamOpen = true;
69
+ });
70
+ }
71
+ stopActivity() {
72
+ void this.enqueue(async () => {
73
+ if (!this.streamOpen) {
74
+ return;
75
+ }
76
+ // Native stop detaches the old subscription before close and emits no
77
+ // terminal event for it, so a queued start can safely open a new pump.
78
+ this.streamOpen = false;
79
+ try {
80
+ await this.native.stopActivity();
81
+ }
82
+ catch (err) {
83
+ // A failed native close means the pump may still be running; surface
84
+ // it instead of swallowing so a zombie stream is at least diagnosable.
85
+ this.emit({
86
+ type: 'log',
87
+ payload: {
88
+ level: 'warn',
89
+ message: `failed to close the activity stream: ${errorMessage(err)}`,
90
+ },
91
+ });
92
+ }
93
+ });
94
+ }
95
+ onNativeEvent(event) {
96
+ switch (event.kind) {
97
+ case 'entry': {
98
+ let entry;
99
+ try {
100
+ entry = this.normalizeActivityEntry(JSON.parse(event.payload));
101
+ }
102
+ catch (err) {
103
+ this.emit({
104
+ type: 'log',
105
+ payload: {
106
+ level: 'error',
107
+ message: `dropped an unparseable activity entry: ${errorMessage(err)}`,
108
+ },
109
+ });
110
+ return;
111
+ }
112
+ this.emit({ type: 'activity', payload: entry });
113
+ return;
114
+ }
115
+ case 'end':
116
+ this.streamOpen = false;
117
+ this.emit({ type: 'activityStream', payload: { state: 'ended' } });
118
+ return;
119
+ case 'error':
120
+ this.streamOpen = false;
121
+ this.emit({
122
+ type: 'activityStream',
123
+ payload: { state: 'failed', message: event.payload },
124
+ });
125
+ return;
126
+ default:
127
+ this.emit({
128
+ type: 'log',
129
+ payload: {
130
+ level: 'warn',
131
+ message: `unknown wavelength native event: ${event.kind}`,
132
+ },
133
+ });
134
+ }
135
+ }
136
+ dispose() {
137
+ // super.dispose() calls stopActivity(). Keep streamOpen intact until that
138
+ // queued stop reads it, or disposal would leak the native pump.
139
+ super.dispose();
140
+ this.removeNativeListener?.();
141
+ this.removeNativeListener = null;
142
+ }
143
+ }
@@ -0,0 +1,19 @@
1
+ import { type PresetNetwork, type RuntimeConfig } from '@lightninglabs/wavelength-core';
2
+ /**
3
+ * Returns a ready-to-use {@link RuntimeConfig} for a network on the React
4
+ * Native transport, preloaded with the canonical public gRPC host:port
5
+ * addresses and merged with any overrides. Pass overrides to set dataDir or
6
+ * point at your own infrastructure, e.g.
7
+ * `defaultConfig('signet', { dataDir: '/wallet' })`.
8
+ *
9
+ * Only the preset networks are accepted (see {@link PresetNetwork}). mainnet
10
+ * and regtest have no preset: build their config by hand, mainnet with your
11
+ * own gRPC addresses and allowMainnet, regtest with local addresses and the
12
+ * insecure-transport flags.
13
+ *
14
+ * @param network - The Bitcoin network to build a config for.
15
+ * @param overrides - Fields that override the network preset's defaults.
16
+ * @returns The merged runtime configuration.
17
+ */
18
+ export declare function defaultConfig(network: PresetNetwork, overrides?: Partial<RuntimeConfig>): RuntimeConfig;
19
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,aAAa,EACnB,MAAM,gCAAgC,CAAC;AAExC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,aAAa,EACtB,SAAS,GAAE,OAAO,CAAC,aAAa,CAAM,GACrC,aAAa,CAEf"}
package/dist/config.js ADDED
@@ -0,0 +1,20 @@
1
+ import { networkDefaults, } from '@lightninglabs/wavelength-core';
2
+ /**
3
+ * Returns a ready-to-use {@link RuntimeConfig} for a network on the React
4
+ * Native transport, preloaded with the canonical public gRPC host:port
5
+ * addresses and merged with any overrides. Pass overrides to set dataDir or
6
+ * point at your own infrastructure, e.g.
7
+ * `defaultConfig('signet', { dataDir: '/wallet' })`.
8
+ *
9
+ * Only the preset networks are accepted (see {@link PresetNetwork}). mainnet
10
+ * and regtest have no preset: build their config by hand, mainnet with your
11
+ * own gRPC addresses and allowMainnet, regtest with local addresses and the
12
+ * insecure-transport flags.
13
+ *
14
+ * @param network - The Bitcoin network to build a config for.
15
+ * @param overrides - Fields that override the network preset's defaults.
16
+ * @returns The merged runtime configuration.
17
+ */
18
+ export function defaultConfig(network, overrides = {}) {
19
+ return { network, ...networkDefaults(network, 'grpc'), ...overrides };
20
+ }
@@ -0,0 +1,46 @@
1
+ import { type WavelengthClient, type PasskeyCeremony, type DistributiveOmit, type WalletEngine, type WalletEngineOptions } from '@lightninglabs/wavelength-core';
2
+ import { type NativePasskeyCeremonyOptions } from './passkey.ts';
3
+ /**
4
+ * Creates a {@link WavelengthClient} backed by the React Native transport: the
5
+ * daemon compiled into the app via the gomobile bindings. Takes no options
6
+ * today; an options parameter can be added later without a breaking change.
7
+ */
8
+ export declare function createNativeClient(): WavelengthClient;
9
+ /**
10
+ * Options for {@link createNativeWalletEngine}. See {@link WalletEngineOptions}
11
+ * for the config/autoStart field docs; the type requires config when
12
+ * autoStart is true.
13
+ */
14
+ export type NativeWalletEngineOptions = DistributiveOmit<WalletEngineOptions, 'client'>;
15
+ /**
16
+ * Creates a {@link WalletEngine} over the React Native transport: the
17
+ * one-call setup for an RN app. Pass the engine to WavelengthProvider from
18
+ * \@lightninglabs/wavelength-react.
19
+ */
20
+ export declare function createNativeWalletEngine(options?: NativeWalletEngineOptions): WalletEngine;
21
+ /**
22
+ * Creates the native (Android Credential Manager / iOS AuthenticationServices)
23
+ * implementation of the {@link PasskeyCeremony} contract; pass it to
24
+ * useWalletPasskey, or drive it directly. Requires the relying-party domain
25
+ * to be associated with your app (assetlinks.json on Android, an Associated
26
+ * Domains entitlement plus apple-app-site-association on iOS). iOS support is
27
+ * experimental and needs iOS 18 or newer at runtime.
28
+ */
29
+ export declare function createNativePasskeyCeremony(options: NativePasskeyCeremonyOptions): PasskeyCeremony;
30
+ /**
31
+ * Resolves the platform default wallet data directory (the same directory
32
+ * {@link createNativeClient}'s client uses when RuntimeConfig.dataDir is not
33
+ * set). Exposed for app-level data management: showing the storage location,
34
+ * backing it up, or deleting it to wipe the wallet.
35
+ *
36
+ * Returns a plain absolute filesystem path with no URI scheme; a consumer that
37
+ * needs a `file://` URL (for example to delete the directory) must add it. The
38
+ * directory is not guaranteed to exist until the runtime has started.
39
+ */
40
+ export declare function getDefaultDataDir(): Promise<string>;
41
+ export type { NativePasskeyCeremonyOptions, WavelengthPasskeyNativeModule, } from './passkey.ts';
42
+ export { defaultConfig } from './config.ts';
43
+ export { NativeWavelengthClient } from './client.ts';
44
+ export type { NativeActivityEvent, SubscribeToNativeEvents, WavelengthNativeModule, } from './client.ts';
45
+ export * from '@lightninglabs/wavelength-core';
46
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACzB,MAAM,gCAAgC,CAAC;AAGxC,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,cAAc,CAAC;AAEtB;;;;GAIG;AACH,wBAAgB,kBAAkB,IAAI,gBAAgB,CAWrD;AAED;;;;GAIG;AACH,MAAM,MAAM,yBAAyB,GAAG,gBAAgB,CACtD,mBAAmB,EACnB,QAAQ,CACT,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,YAAY,CAKd;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,4BAA4B,GACpC,eAAe,CAEjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC,CAEnD;AAED,YAAY,EACV,4BAA4B,EAC5B,6BAA6B,GAC9B,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EACV,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,aAAa,CAAC;AAIrB,cAAc,gCAAgC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import { NativeEventEmitter, NativeModules } from 'react-native';
2
+ import { createWalletEngine, } from '@lightninglabs/wavelength-core';
3
+ import NativeWavelength from "./NativeWavelength.js";
4
+ import { NativeWavelengthClient } from "./client.js";
5
+ import { nativePasskeyCeremony, } from "./passkey.js";
6
+ /**
7
+ * Creates a {@link WavelengthClient} backed by the React Native transport: the
8
+ * daemon compiled into the app via the gomobile bindings. Takes no options
9
+ * today; an options parameter can be added later without a breaking change.
10
+ */
11
+ export function createNativeClient() {
12
+ // NativeModules.Wavelength is the interop view of the Turbo Module; the
13
+ // emitter needs it (or any module carrying addListener/removeListeners) to
14
+ // route 'wavelengthActivity' device events on both platforms.
15
+ const emitter = new NativeEventEmitter(NativeModules.Wavelength);
16
+ return new NativeWavelengthClient(NativeWavelength, (listener) => {
17
+ const subscription = emitter.addListener('wavelengthActivity', listener);
18
+ return () => subscription.remove();
19
+ });
20
+ }
21
+ /**
22
+ * Creates a {@link WalletEngine} over the React Native transport: the
23
+ * one-call setup for an RN app. Pass the engine to WavelengthProvider from
24
+ * \@lightninglabs/wavelength-react.
25
+ */
26
+ export function createNativeWalletEngine(options = {}) {
27
+ return createWalletEngine({
28
+ client: createNativeClient(),
29
+ ...options,
30
+ });
31
+ }
32
+ /**
33
+ * Creates the native (Android Credential Manager / iOS AuthenticationServices)
34
+ * implementation of the {@link PasskeyCeremony} contract; pass it to
35
+ * useWalletPasskey, or drive it directly. Requires the relying-party domain
36
+ * to be associated with your app (assetlinks.json on Android, an Associated
37
+ * Domains entitlement plus apple-app-site-association on iOS). iOS support is
38
+ * experimental and needs iOS 18 or newer at runtime.
39
+ */
40
+ export function createNativePasskeyCeremony(options) {
41
+ return nativePasskeyCeremony(NativeWavelength, options);
42
+ }
43
+ /**
44
+ * Resolves the platform default wallet data directory (the same directory
45
+ * {@link createNativeClient}'s client uses when RuntimeConfig.dataDir is not
46
+ * set). Exposed for app-level data management: showing the storage location,
47
+ * backing it up, or deleting it to wipe the wallet.
48
+ *
49
+ * Returns a plain absolute filesystem path with no URI scheme; a consumer that
50
+ * needs a `file://` URL (for example to delete the directory) must add it. The
51
+ * directory is not guaranteed to exist until the runtime has started.
52
+ */
53
+ export function getDefaultDataDir() {
54
+ return NativeWavelength.getDefaultDataDir();
55
+ }
56
+ export { defaultConfig } from "./config.js";
57
+ export { NativeWavelengthClient } from "./client.js";
58
+ // Re-export the core contract so an RN consumer can import the client and
59
+ // every type/enum from this one package, the way wavelength-web already does.
60
+ export * from '@lightninglabs/wavelength-core';
@@ -0,0 +1,32 @@
1
+ import type { PasskeyCeremony } from '@lightninglabs/wavelength-core';
2
+ /**
3
+ * The subset of the native Turbo Module the passkey ceremony depends on.
4
+ * Narrowed to an interface (rather than the generated Spec) so unit tests can
5
+ * inject a fake without loading react-native.
6
+ */
7
+ export type WavelengthPasskeyNativeModule = {
8
+ /** Reports whether the platform can run a passkey PRF ceremony. */
9
+ passkeySupported(): Promise<boolean>;
10
+ /** Runs a passkey registration ceremony; WebAuthn JSON in and out. */
11
+ passkeyCreate(requestJson: string): Promise<string>;
12
+ /** Runs a passkey assertion ceremony; WebAuthn JSON in and out. */
13
+ passkeyGet(requestJson: string): Promise<string>;
14
+ };
15
+ /** Options for creating the native passkey ceremony. */
16
+ export type NativePasskeyCeremonyOptions = {
17
+ /**
18
+ * The WebAuthn relying-party id: the domain whose
19
+ * /.well-known/assetlinks.json (Android) and apple-app-site-association
20
+ * (iOS) vouch for this app. Native apps have no window.location, so the
21
+ * rpId is explicit configuration.
22
+ */
23
+ rpId: string;
24
+ };
25
+ /**
26
+ * Builds a {@link PasskeyCeremony} over the given native ceremony methods.
27
+ * The factory in index.ts wires the real Turbo Module; unit tests inject a
28
+ * fake. Request and response payloads are standard WebAuthn JSON with
29
+ * base64url binary fields, the format both platform APIs speak natively.
30
+ */
31
+ export declare function nativePasskeyCeremony(native: WavelengthPasskeyNativeModule, options: NativePasskeyCeremonyOptions): PasskeyCeremony;
32
+ //# sourceMappingURL=passkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkey.d.ts","sourceRoot":"","sources":["../src/passkey.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,eAAe,EAChB,MAAM,gCAAgC,CAAC;AAgExC;;;;GAIG;AACH,MAAM,MAAM,6BAA6B,GAAG;IAC1C,mEAAmE;IACnE,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACrC,sEAAsE;IACtE,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,mEAAmE;IACnE,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAClD,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,4BAA4B,GAAG;IACzC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,6BAA6B,EACrC,OAAO,EAAE,4BAA4B,GACpC,eAAe,CAsGjB"}
@@ -0,0 +1,244 @@
1
+ import { PASSKEY_PRF_SALT_HEX, PasskeyCancelledError, } from '@lightninglabs/wavelength-core';
2
+ // Whether a native ceremony rejection means the user dismissed the OS prompt.
3
+ // iOS surfaces ASAuthorizationError code 1001 ("canceled"); Android surfaces
4
+ // GetCredentialCancellationException / CreateCredentialCancellationException
5
+ // with "cancel" in the type or message. Message matching is the only signal
6
+ // that crosses the bridge uniformly, since the bridge flattens native
7
+ // exceptions to a plain Error with no structured code. Known exception type
8
+ // names are matched first, since they are unambiguous; the narrowed
9
+ // "user cancel" regex is a fallback for messages that carry the platform
10
+ // wording without the type name. A bare /cancel/i is deliberately avoided so
11
+ // an unrelated failure whose message merely contains "cancel" (e.g.
12
+ // "cancellation token invalid") is not misclassified as a user cancellation.
13
+ // Follow-up: a native-side sentinel (an error code field rather than message
14
+ // text) would make this exact instead of best-effort.
15
+ function isNativeCancel(err) {
16
+ const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
17
+ if (/GetCredentialCancellationException/.test(message) ||
18
+ /CreateCredentialCancellationException/.test(message)) {
19
+ return true;
20
+ }
21
+ if (/ASAuthorizationError/.test(message) && /\b1001\b/.test(message)) {
22
+ return true;
23
+ }
24
+ if (/\berror\s*1001\b/i.test(message)) {
25
+ return true;
26
+ }
27
+ return /\buser.{0,10}cancel/i.test(message);
28
+ }
29
+ // A passkey ceremony that neither resolves nor rejects within this bound has
30
+ // wedged (a silent native provider); reject so useWalletPasskey's
31
+ // createPending/openPending flags cannot stick until an app restart.
32
+ // Generous enough that a real user completing biometrics or a PIN never
33
+ // trips it.
34
+ const PASSKEY_TIMEOUT_MS = 120000;
35
+ // withPasskeyTimeout rejects if the native ceremony call has not settled
36
+ // within PASSKEY_TIMEOUT_MS. It does not cancel the native ceremony; it only
37
+ // unwedges the JavaScript promise.
38
+ function withPasskeyTimeout(op, label) {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error(`passkey ${label} timed out`)), PASSKEY_TIMEOUT_MS);
41
+ op.then((value) => {
42
+ clearTimeout(timer);
43
+ resolve(value);
44
+ }, (err) => {
45
+ clearTimeout(timer);
46
+ reject(err);
47
+ });
48
+ });
49
+ }
50
+ /**
51
+ * Builds a {@link PasskeyCeremony} over the given native ceremony methods.
52
+ * The factory in index.ts wires the real Turbo Module; unit tests inject a
53
+ * fake. Request and response payloads are standard WebAuthn JSON with
54
+ * base64url binary fields, the format both platform APIs speak natively.
55
+ */
56
+ export function nativePasskeyCeremony(native, options) {
57
+ const saltB64url = hexToBase64Url(PASSKEY_PRF_SALT_HEX);
58
+ // The memoized probe promise for this ceremony instance. The probe reads
59
+ // native.passkeySupported(), which does not depend on options (rpId etc.),
60
+ // but a fresh native module can be wired into a different ceremony
61
+ // instance, so the memo lives per instance rather than at module scope.
62
+ let supportsPasskeyPrfProbe = null;
63
+ const assertPasskeyPrf = async (allowCredentialId) => {
64
+ const request = {
65
+ challenge: saltB64url,
66
+ rpId: options.rpId,
67
+ allowCredentials: allowCredentialId
68
+ ? [{ type: 'public-key', id: allowCredentialId }]
69
+ : [],
70
+ userVerification: 'required',
71
+ extensions: { prf: { eval: { first: saltB64url } } },
72
+ };
73
+ let responseJson;
74
+ try {
75
+ responseJson = await withPasskeyTimeout(native.passkeyGet(JSON.stringify(request)), 'authentication');
76
+ }
77
+ catch (err) {
78
+ throw isNativeCancel(err) ? new PasskeyCancelledError() : err;
79
+ }
80
+ const response = JSON.parse(responseJson);
81
+ return { prfOutput: requirePrfHex(response), credentialId: response.id };
82
+ };
83
+ return {
84
+ // Memoized per ceremony instance: the first call stores the in-flight
85
+ // probe and every later call reuses it. A rejection is not cached: the
86
+ // memo is cleared first so a later call retries the probe, and this
87
+ // call still degrades to false rather than leaving an unhandled
88
+ // rejection.
89
+ async supportsPasskeyPrf() {
90
+ if (!supportsPasskeyPrfProbe) {
91
+ supportsPasskeyPrfProbe = native.passkeySupported();
92
+ }
93
+ try {
94
+ return await supportsPasskeyPrfProbe;
95
+ }
96
+ catch {
97
+ supportsPasskeyPrfProbe = null;
98
+ return false;
99
+ }
100
+ },
101
+ async registerPasskeyWallet(appName) {
102
+ const request = {
103
+ challenge: saltB64url,
104
+ rp: { id: options.rpId, name: appName },
105
+ user: {
106
+ id: randomUserIdBase64Url(),
107
+ name: appName,
108
+ displayName: appName,
109
+ },
110
+ pubKeyCredParams: [
111
+ { alg: -7, type: 'public-key' },
112
+ { alg: -257, type: 'public-key' },
113
+ ],
114
+ authenticatorSelection: {
115
+ authenticatorAttachment: 'platform',
116
+ userVerification: 'required',
117
+ residentKey: 'required',
118
+ },
119
+ extensions: { prf: { eval: { first: saltB64url } } },
120
+ };
121
+ let responseJson;
122
+ try {
123
+ responseJson = await withPasskeyTimeout(native.passkeyCreate(JSON.stringify(request)), 'registration');
124
+ }
125
+ catch (err) {
126
+ throw isNativeCancel(err) ? new PasskeyCancelledError() : err;
127
+ }
128
+ const response = JSON.parse(responseJson);
129
+ const first = prfFirst(response);
130
+ if (first) {
131
+ return {
132
+ prfOutput: prfOutputHex(first),
133
+ credentialId: response.id,
134
+ };
135
+ }
136
+ // Some providers do not surface PRF from create; read it with an
137
+ // assertion scoped to the just-created credential, mirroring the web
138
+ // ceremony's fallback.
139
+ return assertPasskeyPrf(response.id);
140
+ },
141
+ assertPasskeyPrf,
142
+ };
143
+ }
144
+ // prfFirst plucks the base64url PRF output from a response, or null.
145
+ function prfFirst(response) {
146
+ return response?.clientExtensionResults?.prf?.results?.first ?? null;
147
+ }
148
+ // requirePrfHex reads the mandatory PRF output as hex, matching the web
149
+ // ceremony's error when the authenticator did not return one.
150
+ function requirePrfHex(response) {
151
+ const first = prfFirst(response);
152
+ if (!first) {
153
+ throw new Error('passkey PRF extension result was not returned by this authenticator');
154
+ }
155
+ return prfOutputHex(first);
156
+ }
157
+ // prfOutputHex decodes a PRF output and requires the WebAuthn-mandated 32
158
+ // bytes: short or padded key material must never reach wallet derivation.
159
+ function prfOutputHex(firstB64url) {
160
+ const hex = base64UrlToHex(firstB64url);
161
+ if (hex.length !== 64) {
162
+ throw new Error('passkey PRF output is not 32 bytes');
163
+ }
164
+ return hex;
165
+ }
166
+ // randomUserIdBase64Url makes a fresh 16-byte WebAuthn user handle. user.id
167
+ // is an account identifier, not key material, so cryptographic randomness is
168
+ // not required; crypto.getRandomValues is still preferred when the runtime
169
+ // provides it.
170
+ function randomUserIdBase64Url() {
171
+ const bytes = new Uint8Array(16);
172
+ // Typed structurally (not as the DOM lib's Crypto) because this package's
173
+ // tsconfig targets ES2022 without DOM, matching its Hermes/RN runtime.
174
+ const cryptoApi = globalThis.crypto;
175
+ if (cryptoApi?.getRandomValues) {
176
+ cryptoApi.getRandomValues(bytes);
177
+ }
178
+ else {
179
+ for (let i = 0; i < bytes.length; i++) {
180
+ bytes[i] = Math.floor(Math.random() * 256);
181
+ }
182
+ }
183
+ return bytesToBase64Url(bytes);
184
+ }
185
+ // The base64url alphabet, indexed by 6-bit value.
186
+ const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
187
+ // hexToBase64Url re-encodes a lower-case hex string as unpadded base64url.
188
+ // Hand-rolled because Hermes offers neither Buffer nor a guaranteed atob.
189
+ function hexToBase64Url(hex) {
190
+ const bytes = new Uint8Array(hex.length / 2);
191
+ for (let i = 0; i < bytes.length; i++) {
192
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
193
+ }
194
+ return bytesToBase64Url(bytes);
195
+ }
196
+ // base64UrlToHex decodes unpadded base64url and renders lower-case hex.
197
+ function base64UrlToHex(value) {
198
+ return Array.from(base64UrlToBytes(value))
199
+ .map((b) => b.toString(16).padStart(2, '0'))
200
+ .join('');
201
+ }
202
+ function bytesToBase64Url(bytes) {
203
+ let out = '';
204
+ for (let i = 0; i < bytes.length; i += 3) {
205
+ const a = bytes[i];
206
+ const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
207
+ const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
208
+ out += B64URL[a >> 2];
209
+ out += B64URL[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
210
+ if (b !== undefined) {
211
+ out += B64URL[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
212
+ }
213
+ if (c !== undefined) {
214
+ out += B64URL[c & 0x3f];
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+ function base64UrlToBytes(value) {
220
+ // Tolerate standard base64 alphabet and padding so provider quirks cannot
221
+ // bite, but fail closed on anything else: this decodes wallet key
222
+ // material, and silently skipping a corrupted character would derive a
223
+ // different wallet instead of surfacing an error.
224
+ const normalized = value.replace(/\+/g, '-').replace(/\//g, '_');
225
+ const out = [];
226
+ let buffer = 0;
227
+ let bits = 0;
228
+ for (const ch of normalized) {
229
+ const idx = B64URL.indexOf(ch);
230
+ if (idx < 0) {
231
+ if (ch === '=' || /\s/.test(ch)) {
232
+ continue;
233
+ }
234
+ throw new Error('malformed base64url payload in passkey response');
235
+ }
236
+ buffer = (buffer << 6) | idx;
237
+ bits += 6;
238
+ if (bits >= 8) {
239
+ bits -= 8;
240
+ out.push((buffer >> bits) & 0xff);
241
+ }
242
+ }
243
+ return Uint8Array.from(out);
244
+ }
@@ -0,0 +1,8 @@
1
+ #import <React/RCTEventEmitter.h>
2
+
3
+ // The codegen spec conformance is declared in the implementation file, not
4
+ // here: this public header must stay pure Objective-C so the pod's Clang
5
+ // module (required by the Swift ceremony source) can build without pulling
6
+ // in C++ codegen headers.
7
+ @interface WavelengthModule : RCTEventEmitter
8
+ @end