@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/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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/passkey.js
ADDED
|
@@ -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
|