@lightninglabs/wavelength-react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +112 -0
- package/WavelengthReactNative.podspec +24 -0
- package/android/build.gradle +41 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthModule.kt +291 -0
- package/android/src/main/java/engineering/lightning/wavelength/reactnative/WavelengthPackage.kt +28 -0
- package/dist/NativeWalletdk.d.ts +31 -0
- package/dist/NativeWalletdk.d.ts.map +1 -0
- package/dist/NativeWalletdk.js +2 -0
- package/dist/NativeWavelength.d.ts +31 -0
- package/dist/NativeWavelength.d.ts.map +1 -0
- package/dist/NativeWavelength.js +2 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +143 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/passkey.d.ts +32 -0
- package/dist/passkey.d.ts.map +1 -0
- package/dist/passkey.js +244 -0
- package/ios/WavelengthModule.h +8 -0
- package/ios/WavelengthModule.mm +298 -0
- package/ios/WavelengthPasskey.swift +262 -0
- package/package.json +70 -0
- package/src/NativeWavelength.ts +32 -0
- package/src/client.test.ts +307 -0
- package/src/client.ts +222 -0
- package/src/config.test.ts +28 -0
- package/src/config.ts +28 -0
- package/src/index.ts +102 -0
- package/src/native-dispatch.test.ts +174 -0
- package/src/passkey.test.ts +301 -0
- package/src/passkey.ts +336 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#import "WavelengthModule.h"
|
|
2
|
+
|
|
3
|
+
#import <Wavewalletdk/Wavewalletdk.h>
|
|
4
|
+
#import <WavelengthSpec/WavelengthSpec.h>
|
|
5
|
+
#import "WavelengthReactNative-Swift.h"
|
|
6
|
+
|
|
7
|
+
// The single device event name; the body is { kind, payload }.
|
|
8
|
+
static NSString *const kWavelengthEvent = @"wavelengthActivity";
|
|
9
|
+
static NSString *const kWavelengthErrorCode = @"wavelength_error";
|
|
10
|
+
|
|
11
|
+
// The codegen spec conformance lives here (see the note in the header): the
|
|
12
|
+
// generated protocol drags in C++ headers that must stay out of the public
|
|
13
|
+
// Objective-C surface.
|
|
14
|
+
@interface WavelengthModule () <NativeWavelengthSpec>
|
|
15
|
+
@end
|
|
16
|
+
|
|
17
|
+
@implementation WavelengthModule {
|
|
18
|
+
MobileSubscription *_subscription;
|
|
19
|
+
WavelengthPasskey *_passkey;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
RCT_EXPORT_MODULE(Wavelength)
|
|
23
|
+
|
|
24
|
+
+ (BOOL)requiresMainQueueSetup
|
|
25
|
+
{
|
|
26
|
+
return NO;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
- (NSArray<NSString *> *)supportedEvents
|
|
30
|
+
{
|
|
31
|
+
return @[ kWavelengthEvent ];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
- (void)getDefaultDataDir:(RCTPromiseResolveBlock)resolve
|
|
35
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
36
|
+
{
|
|
37
|
+
NSURL *appSupport = [[NSFileManager.defaultManager
|
|
38
|
+
URLsForDirectory:NSApplicationSupportDirectory
|
|
39
|
+
inDomains:NSUserDomainMask] firstObject];
|
|
40
|
+
resolve([appSupport URLByAppendingPathComponent:@"wavelength"].path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
- (void)passkeySupported:(RCTPromiseResolveBlock)resolve
|
|
44
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
45
|
+
{
|
|
46
|
+
resolve(@([WavelengthPasskey prfSupported]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
- (void)passkeyCreate:(NSString *)requestJson
|
|
50
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
51
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
52
|
+
{
|
|
53
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
54
|
+
@synchronized (self) {
|
|
55
|
+
if (self->_passkey == nil) {
|
|
56
|
+
self->_passkey = [WavelengthPasskey new];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
[self->_passkey create:requestJson
|
|
60
|
+
completion:^(NSString *json, NSString *errorMessage) {
|
|
61
|
+
if (json != nil) {
|
|
62
|
+
resolve(json);
|
|
63
|
+
} else {
|
|
64
|
+
reject(kWavelengthErrorCode,
|
|
65
|
+
errorMessage ?: @"passkey ceremony failed", nil);
|
|
66
|
+
}
|
|
67
|
+
}];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
- (void)passkeyGet:(NSString *)requestJson
|
|
72
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
73
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
74
|
+
{
|
|
75
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
76
|
+
@synchronized (self) {
|
|
77
|
+
if (self->_passkey == nil) {
|
|
78
|
+
self->_passkey = [WavelengthPasskey new];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
[self->_passkey get:requestJson
|
|
82
|
+
completion:^(NSString *json, NSString *errorMessage) {
|
|
83
|
+
if (json != nil) {
|
|
84
|
+
resolve(json);
|
|
85
|
+
} else {
|
|
86
|
+
reject(kWavelengthErrorCode,
|
|
87
|
+
errorMessage ?: @"passkey ceremony failed", nil);
|
|
88
|
+
}
|
|
89
|
+
}];
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// call maps a verb name onto the gomobile Mobile* entry points. The facade
|
|
94
|
+
// takes and returns JSON bytes; this switch stays dumb on purpose because all
|
|
95
|
+
// typing lives in the TypeScript layer.
|
|
96
|
+
- (void)call:(NSString *)method
|
|
97
|
+
paramsJson:(NSString *)paramsJson
|
|
98
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
99
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
100
|
+
{
|
|
101
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
102
|
+
NSError *error = nil;
|
|
103
|
+
NSData *result = nil;
|
|
104
|
+
NSString *json = @"";
|
|
105
|
+
NSData *params = [paramsJson dataUsingEncoding:NSUTF8StringEncoding];
|
|
106
|
+
|
|
107
|
+
if ([method isEqualToString:@"start"]) {
|
|
108
|
+
MobileStart(paramsJson, &error);
|
|
109
|
+
} else if ([method isEqualToString:@"stop"]) {
|
|
110
|
+
MobileStop(&error);
|
|
111
|
+
} else if ([method isEqualToString:@"getInfo"]) {
|
|
112
|
+
result = MobileGetInfo(&error);
|
|
113
|
+
} else if ([method isEqualToString:@"status"]) {
|
|
114
|
+
result = MobileStatus(&error);
|
|
115
|
+
} else if ([method isEqualToString:@"balance"]) {
|
|
116
|
+
result = MobileBalance(&error);
|
|
117
|
+
} else if ([method isEqualToString:@"createWallet"]) {
|
|
118
|
+
result = MobileCreateWallet(params, &error);
|
|
119
|
+
} else if ([method isEqualToString:@"unlockWallet"]) {
|
|
120
|
+
result = MobileUnlockWallet(params, &error);
|
|
121
|
+
} else if ([method isEqualToString:@"openWalletFromPasskey"]) {
|
|
122
|
+
result = MobileOpenWalletFromPasskey(params, &error);
|
|
123
|
+
} else if ([method isEqualToString:@"deposit"]) {
|
|
124
|
+
result = MobileDeposit(params, &error);
|
|
125
|
+
} else if ([method isEqualToString:@"receive"]) {
|
|
126
|
+
result = MobileReceive(params, &error);
|
|
127
|
+
} else if ([method isEqualToString:@"prepareSend"]) {
|
|
128
|
+
result = MobilePrepareSend(params, &error);
|
|
129
|
+
} else if ([method isEqualToString:@"sendPrepared"]) {
|
|
130
|
+
result = MobileSendPrepared(params, &error);
|
|
131
|
+
} else if ([method isEqualToString:@"list"]) {
|
|
132
|
+
result = MobileList(params, &error);
|
|
133
|
+
} else if ([method isEqualToString:@"exit"]) {
|
|
134
|
+
result = MobileExit(params, &error);
|
|
135
|
+
} else if ([method isEqualToString:@"exitStatus"]) {
|
|
136
|
+
result = MobileExitStatus(params, &error);
|
|
137
|
+
} else if ([method isEqualToString:@"exitSummary"]) {
|
|
138
|
+
result = MobileExitSummary(params, &error);
|
|
139
|
+
} else if ([method isEqualToString:@"getExitPlan"]) {
|
|
140
|
+
result = MobileGetExitPlan(params, &error);
|
|
141
|
+
} else if ([method isEqualToString:@"sweepWallet"]) {
|
|
142
|
+
result = MobileSweepWallet(params, &error);
|
|
143
|
+
} else if ([method isEqualToString:@"confirmedBalanceSat"]) {
|
|
144
|
+
int64_t value = 0;
|
|
145
|
+
MobileConfirmedBalanceSat(&value, &error);
|
|
146
|
+
json = [NSString stringWithFormat:@"%lld",
|
|
147
|
+
(long long)value];
|
|
148
|
+
} else if ([method isEqualToString:@"pendingInboundSat"]) {
|
|
149
|
+
int64_t value = 0;
|
|
150
|
+
MobilePendingInboundSat(&value, &error);
|
|
151
|
+
json = [NSString stringWithFormat:@"%lld",
|
|
152
|
+
(long long)value];
|
|
153
|
+
} else if ([method isEqualToString:@"walletReady"]) {
|
|
154
|
+
BOOL value = NO;
|
|
155
|
+
MobileWalletReady(&value, &error);
|
|
156
|
+
json = value ? @"true" : @"false";
|
|
157
|
+
} else if ([method isEqualToString:@"isRunning"]) {
|
|
158
|
+
json = MobileIsRunning() ? @"true" : @"false";
|
|
159
|
+
} else {
|
|
160
|
+
reject(kWavelengthErrorCode,
|
|
161
|
+
[NSString stringWithFormat:@"unknown wavelength verb: %@", method],
|
|
162
|
+
nil);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (error != nil) {
|
|
167
|
+
reject(kWavelengthErrorCode, error.localizedDescription, error);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (result != nil) {
|
|
171
|
+
json = [[NSString alloc] initWithData:result encoding:NSUTF8StringEncoding];
|
|
172
|
+
}
|
|
173
|
+
resolve(json ?: @"");
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// startActivity opens the facade's pull subscription and pumps entries to
|
|
178
|
+
// device events on a background queue, because next() blocks.
|
|
179
|
+
- (void)startActivity:(NSString *)reqJson
|
|
180
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
181
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
182
|
+
{
|
|
183
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
184
|
+
// The check, the subscribe, and the store happen in one critical section
|
|
185
|
+
// (mirroring the Kotlin module): separate sections would let two
|
|
186
|
+
// overlapping calls both subscribe, leaking a pump that double-emits
|
|
187
|
+
// every entry.
|
|
188
|
+
MobileSubscription *sub = nil;
|
|
189
|
+
NSError *error = nil;
|
|
190
|
+
@synchronized (self) {
|
|
191
|
+
if (self->_subscription != nil) {
|
|
192
|
+
resolve(nil);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
NSData *req = [reqJson dataUsingEncoding:NSUTF8StringEncoding];
|
|
196
|
+
sub = MobileSubscribe(req, &error);
|
|
197
|
+
if (error == nil && sub != nil) {
|
|
198
|
+
self->_subscription = sub;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (error != nil || sub == nil) {
|
|
202
|
+
reject(kWavelengthErrorCode,
|
|
203
|
+
error.localizedDescription ?: @"wavelength subscribe failed",
|
|
204
|
+
error);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
[self pump:sub];
|
|
209
|
+
resolve(nil);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
- (void)stopActivity:(RCTPromiseResolveBlock)resolve
|
|
214
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
215
|
+
{
|
|
216
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
217
|
+
MobileSubscription *sub = nil;
|
|
218
|
+
@synchronized (self) {
|
|
219
|
+
sub = self->_subscription;
|
|
220
|
+
// Detach before close returns so a queued start can't reuse a pump
|
|
221
|
+
// that's already stopping.
|
|
222
|
+
self->_subscription = nil;
|
|
223
|
+
}
|
|
224
|
+
NSError *error = nil;
|
|
225
|
+
if (sub != nil) {
|
|
226
|
+
[sub close:&error];
|
|
227
|
+
}
|
|
228
|
+
if (error != nil) {
|
|
229
|
+
reject(kWavelengthErrorCode, error.localizedDescription, error);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
resolve(nil);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// pump drains the subscription on a background queue until it ends, emitting
|
|
237
|
+
// one device event per entry and a terminal end or error event.
|
|
238
|
+
- (void)pump:(MobileSubscription *)sub
|
|
239
|
+
{
|
|
240
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
|
|
241
|
+
while (YES) {
|
|
242
|
+
NSError *error = nil;
|
|
243
|
+
NSData *entry = [sub next:&error];
|
|
244
|
+
if (error != nil || entry == nil) {
|
|
245
|
+
// A nil entry with no error, or the exact terminal "EOF", is a clean
|
|
246
|
+
// close; a substring match would misclassify failures like
|
|
247
|
+
// "unexpected EOF" as clean ends.
|
|
248
|
+
BOOL eof = error == nil ||
|
|
249
|
+
[error.localizedDescription isEqualToString:@"EOF"];
|
|
250
|
+
BOOL stopped;
|
|
251
|
+
@synchronized (self) {
|
|
252
|
+
stopped = self->_subscription != sub;
|
|
253
|
+
}
|
|
254
|
+
// An intentional stop has no terminal event. A replacement may
|
|
255
|
+
// already be open by the time this old pump unblocks.
|
|
256
|
+
if (stopped) {
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
if (eof) {
|
|
260
|
+
[self sendEventWithName:kWavelengthEvent
|
|
261
|
+
body:@{ @"kind" : @"end", @"payload" : @"" }];
|
|
262
|
+
} else {
|
|
263
|
+
[self sendEventWithName:kWavelengthEvent
|
|
264
|
+
body:@{
|
|
265
|
+
@"kind" : @"error",
|
|
266
|
+
@"payload" : error.localizedDescription
|
|
267
|
+
?: @"wavelength activity stream failed"
|
|
268
|
+
}];
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
BOOL stopped;
|
|
273
|
+
@synchronized (self) {
|
|
274
|
+
stopped = self->_subscription != sub;
|
|
275
|
+
}
|
|
276
|
+
if (stopped) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
NSString *json =
|
|
280
|
+
[[NSString alloc] initWithData:entry encoding:NSUTF8StringEncoding];
|
|
281
|
+
[self sendEventWithName:kWavelengthEvent
|
|
282
|
+
body:@{ @"kind" : @"entry", @"payload" : json ?: @"" }];
|
|
283
|
+
}
|
|
284
|
+
@synchronized (self) {
|
|
285
|
+
if (self->_subscription == sub) {
|
|
286
|
+
self->_subscription = nil;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
293
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
294
|
+
{
|
|
295
|
+
return std::make_shared<facebook::react::NativeWavelengthSpecJSI>(params);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import AuthenticationServices
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
// The native half of the passkey ceremony: WebAuthn-shaped JSON in, a minimal
|
|
7
|
+
// WebAuthn-shaped JSON response out. The TypeScript parser reads only id and
|
|
8
|
+
// the PRF results; rawId and type ride along for WebAuthn shape fidelity. The JSON-to-
|
|
9
|
+
// ASAuthorization mapping follows react-native-passkey (MIT,
|
|
10
|
+
// https://github.com/f-23/react-native-passkey), which shipped the iOS 18 PRF
|
|
11
|
+
// path this implementation mirrors.
|
|
12
|
+
//
|
|
13
|
+
// Experimental: compiles and is driven by unit-tested TS request shapes, but
|
|
14
|
+
// has not been verified end to end; that needs an Associated Domains
|
|
15
|
+
// entitlement backed by a paid Apple Developer Program team.
|
|
16
|
+
@objc(WavelengthPasskey)
|
|
17
|
+
public final class WavelengthPasskey: NSObject {
|
|
18
|
+
|
|
19
|
+
// Retains in-flight runners; ASAuthorizationController does not retain its
|
|
20
|
+
// delegate, so each ceremony holds itself here until it completes. Typed
|
|
21
|
+
// as AnyObject because stored properties cannot be availability-gated and
|
|
22
|
+
// PasskeyRunner itself requires iOS 18.
|
|
23
|
+
private var inFlight: [AnyObject] = []
|
|
24
|
+
|
|
25
|
+
// PRF requires the iOS 18 AuthenticationServices API.
|
|
26
|
+
@objc public static func prfSupported() -> Bool {
|
|
27
|
+
if #available(iOS 18.0, *) {
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@objc public func create(
|
|
34
|
+
_ requestJson: String,
|
|
35
|
+
completion: @escaping (String?, String?) -> Void
|
|
36
|
+
) {
|
|
37
|
+
guard #available(iOS 18.0, *) else {
|
|
38
|
+
completion(nil, "passkeys require iOS 18 or newer")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
guard
|
|
42
|
+
let body = parseJson(requestJson),
|
|
43
|
+
let rp = body["rp"] as? [String: Any],
|
|
44
|
+
let rpId = rp["id"] as? String,
|
|
45
|
+
let user = body["user"] as? [String: Any],
|
|
46
|
+
let userName = user["name"] as? String,
|
|
47
|
+
let userIdB64 = user["id"] as? String,
|
|
48
|
+
let userId = Self.dataFromBase64Url(userIdB64),
|
|
49
|
+
let challengeB64 = body["challenge"] as? String,
|
|
50
|
+
let challenge = Self.dataFromBase64Url(challengeB64),
|
|
51
|
+
let salt = prfSalt(body)
|
|
52
|
+
else {
|
|
53
|
+
completion(nil, "malformed passkey registration request")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
|
|
58
|
+
relyingPartyIdentifier: rpId)
|
|
59
|
+
let request = provider.createCredentialRegistrationRequest(
|
|
60
|
+
challenge: challenge, name: userName, userID: userId)
|
|
61
|
+
request.userVerificationPreference = .required
|
|
62
|
+
request.prf = .inputValues(
|
|
63
|
+
ASAuthorizationPublicKeyCredentialPRFRegistrationInput.InputValues(
|
|
64
|
+
saltInput1: salt, saltInput2: nil))
|
|
65
|
+
|
|
66
|
+
run(
|
|
67
|
+
request: request,
|
|
68
|
+
cancelMessage: "passkey registration was cancelled",
|
|
69
|
+
completion: completion)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@objc public func get(
|
|
73
|
+
_ requestJson: String,
|
|
74
|
+
completion: @escaping (String?, String?) -> Void
|
|
75
|
+
) {
|
|
76
|
+
guard #available(iOS 18.0, *) else {
|
|
77
|
+
completion(nil, "passkeys require iOS 18 or newer")
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
guard
|
|
81
|
+
let body = parseJson(requestJson),
|
|
82
|
+
let rpId = body["rpId"] as? String,
|
|
83
|
+
let challengeB64 = body["challenge"] as? String,
|
|
84
|
+
let challenge = Self.dataFromBase64Url(challengeB64),
|
|
85
|
+
let salt = prfSalt(body)
|
|
86
|
+
else {
|
|
87
|
+
completion(nil, "malformed passkey assertion request")
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
|
|
92
|
+
relyingPartyIdentifier: rpId)
|
|
93
|
+
let request = provider.createCredentialAssertionRequest(challenge: challenge)
|
|
94
|
+
request.userVerificationPreference = .required
|
|
95
|
+
if let allowed = body["allowCredentials"] as? [[String: Any]] {
|
|
96
|
+
request.allowedCredentials = allowed.compactMap { entry in
|
|
97
|
+
guard
|
|
98
|
+
let idB64 = entry["id"] as? String,
|
|
99
|
+
let id = Self.dataFromBase64Url(idB64)
|
|
100
|
+
else {
|
|
101
|
+
return nil
|
|
102
|
+
}
|
|
103
|
+
return ASAuthorizationPlatformPublicKeyCredentialDescriptor(
|
|
104
|
+
credentialID: id)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
request.prf = .inputValues(
|
|
108
|
+
ASAuthorizationPublicKeyCredentialPRFRegistrationInput.InputValues(
|
|
109
|
+
saltInput1: salt, saltInput2: nil))
|
|
110
|
+
|
|
111
|
+
run(
|
|
112
|
+
request: request,
|
|
113
|
+
cancelMessage: "passkey authentication was cancelled",
|
|
114
|
+
completion: completion)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@available(iOS 18.0, *)
|
|
118
|
+
private func run(
|
|
119
|
+
request: ASAuthorizationRequest,
|
|
120
|
+
cancelMessage: String,
|
|
121
|
+
completion: @escaping (String?, String?) -> Void
|
|
122
|
+
) {
|
|
123
|
+
let runner = PasskeyRunner(
|
|
124
|
+
cancelMessage: cancelMessage, completion: completion)
|
|
125
|
+
runner.onDone = { [weak self, weak runner] in
|
|
126
|
+
self?.inFlight.removeAll { $0 === runner }
|
|
127
|
+
}
|
|
128
|
+
inFlight.append(runner)
|
|
129
|
+
runner.run(request: request)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// prfSalt reads extensions.prf.eval.first (base64url) from a request body.
|
|
133
|
+
private func prfSalt(_ body: [String: Any]) -> Data? {
|
|
134
|
+
guard
|
|
135
|
+
let extensions = body["extensions"] as? [String: Any],
|
|
136
|
+
let prf = extensions["prf"] as? [String: Any],
|
|
137
|
+
let eval = prf["eval"] as? [String: Any],
|
|
138
|
+
let firstB64 = eval["first"] as? String
|
|
139
|
+
else {
|
|
140
|
+
return nil
|
|
141
|
+
}
|
|
142
|
+
return Self.dataFromBase64Url(firstB64)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func parseJson(_ json: String) -> [String: Any]? {
|
|
146
|
+
guard let data = json.data(using: .utf8) else {
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
static func base64Url(_ data: Data) -> String {
|
|
153
|
+
data.base64EncodedString()
|
|
154
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
155
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
156
|
+
.replacingOccurrences(of: "=", with: "")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
static func dataFromBase64Url(_ value: String) -> Data? {
|
|
160
|
+
var s = value
|
|
161
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
162
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
163
|
+
while s.count % 4 != 0 {
|
|
164
|
+
s += "="
|
|
165
|
+
}
|
|
166
|
+
return Data(base64Encoded: s)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// PasskeyRunner owns one ceremony: it runs the controller, anchors its UI to
|
|
171
|
+
// the key window, and serializes the credential (or error) back to JSON.
|
|
172
|
+
@available(iOS 18.0, *)
|
|
173
|
+
private final class PasskeyRunner: NSObject,
|
|
174
|
+
ASAuthorizationControllerDelegate,
|
|
175
|
+
ASAuthorizationControllerPresentationContextProviding {
|
|
176
|
+
|
|
177
|
+
private let cancelMessage: String
|
|
178
|
+
private let completion: (String?, String?) -> Void
|
|
179
|
+
var onDone: () -> Void = {}
|
|
180
|
+
|
|
181
|
+
init(cancelMessage: String, completion: @escaping (String?, String?) -> Void) {
|
|
182
|
+
self.cancelMessage = cancelMessage
|
|
183
|
+
self.completion = completion
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func run(request: ASAuthorizationRequest) {
|
|
187
|
+
let controller = ASAuthorizationController(authorizationRequests: [request])
|
|
188
|
+
controller.delegate = self
|
|
189
|
+
controller.presentationContextProvider = self
|
|
190
|
+
controller.performRequests()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func presentationAnchor(
|
|
194
|
+
for controller: ASAuthorizationController
|
|
195
|
+
) -> ASPresentationAnchor {
|
|
196
|
+
UIApplication.shared.connectedScenes
|
|
197
|
+
.compactMap { $0 as? UIWindowScene }
|
|
198
|
+
.flatMap { $0.windows }
|
|
199
|
+
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func authorizationController(
|
|
203
|
+
controller: ASAuthorizationController,
|
|
204
|
+
didCompleteWithAuthorization authorization: ASAuthorization
|
|
205
|
+
) {
|
|
206
|
+
switch authorization.credential {
|
|
207
|
+
case let registration
|
|
208
|
+
as ASAuthorizationPlatformPublicKeyCredentialRegistration:
|
|
209
|
+
completion(
|
|
210
|
+
Self.responseJson(
|
|
211
|
+
credentialId: registration.credentialID,
|
|
212
|
+
prfFirst: Self.prfData(registration.prf?.first)),
|
|
213
|
+
nil)
|
|
214
|
+
case let assertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
|
|
215
|
+
completion(
|
|
216
|
+
Self.responseJson(
|
|
217
|
+
credentialId: assertion.credentialID,
|
|
218
|
+
prfFirst: Self.prfData(assertion.prf?.first)),
|
|
219
|
+
nil)
|
|
220
|
+
default:
|
|
221
|
+
completion(nil, "unexpected credential type from the authenticator")
|
|
222
|
+
}
|
|
223
|
+
onDone()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func authorizationController(
|
|
227
|
+
controller: ASAuthorizationController,
|
|
228
|
+
didCompleteWithError error: Error
|
|
229
|
+
) {
|
|
230
|
+
let asError = error as? ASAuthorizationError
|
|
231
|
+
let message = asError?.code == .canceled
|
|
232
|
+
? cancelMessage
|
|
233
|
+
: error.localizedDescription
|
|
234
|
+
completion(nil, message)
|
|
235
|
+
onDone()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// prfData converts a PRF output (a CryptoKit SymmetricKey) into raw bytes.
|
|
239
|
+
private static func prfData(_ key: SymmetricKey?) -> Data? {
|
|
240
|
+
key?.withUnsafeBytes { Data($0) }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// responseJson builds the minimal WebAuthn response the TS parser reads.
|
|
244
|
+
private static func responseJson(credentialId: Data, prfFirst: Data?) -> String? {
|
|
245
|
+
var extensionResults: [String: Any] = [:]
|
|
246
|
+
if let first = prfFirst {
|
|
247
|
+
extensionResults = [
|
|
248
|
+
"prf": ["results": ["first": WavelengthPasskey.base64Url(first)]],
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
let body: [String: Any] = [
|
|
252
|
+
"id": WavelengthPasskey.base64Url(credentialId),
|
|
253
|
+
"rawId": WavelengthPasskey.base64Url(credentialId),
|
|
254
|
+
"type": "public-key",
|
|
255
|
+
"clientExtensionResults": extensionResults,
|
|
256
|
+
]
|
|
257
|
+
guard let data = try? JSONSerialization.data(withJSONObject: body) else {
|
|
258
|
+
return nil
|
|
259
|
+
}
|
|
260
|
+
return String(data: data, encoding: .utf8)
|
|
261
|
+
}
|
|
262
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lightninglabs/wavelength-react-native",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "React Native transport for the Wavelength self-custodial Lightning wallet SDK.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://wavelength.lightning.engineering",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/lightninglabs/wavelength-sdk.git",
|
|
11
|
+
"directory": "packages/react-native"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/lightninglabs/wavelength-sdk/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"lightning",
|
|
16
|
+
"bitcoin",
|
|
17
|
+
"wallet",
|
|
18
|
+
"self-custodial",
|
|
19
|
+
"payments",
|
|
20
|
+
"react-native"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"codegenConfig": {
|
|
36
|
+
"name": "WavelengthSpec",
|
|
37
|
+
"type": "modules",
|
|
38
|
+
"jsSrcsDir": "src",
|
|
39
|
+
"android": {
|
|
40
|
+
"javaPackageName": "engineering.lightning.wavelength.reactnative"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@lightninglabs/wavelength-core": "0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18.0.0",
|
|
48
|
+
"react-native": ">=0.76.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"react-native": "0.86.0",
|
|
52
|
+
"@types/react": "^19.2.17",
|
|
53
|
+
"react": "19.2.3"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"dist",
|
|
57
|
+
"src",
|
|
58
|
+
"android",
|
|
59
|
+
"ios",
|
|
60
|
+
"*.podspec",
|
|
61
|
+
"!android/libs",
|
|
62
|
+
"!ios/Wavewalletdk.xcframework"
|
|
63
|
+
],
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsc -p tsconfig.json",
|
|
66
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
67
|
+
"test": "node --test",
|
|
68
|
+
"fetch-bindings": "bash scripts/fetch-bindings.sh"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { TurboModule } from 'react-native';
|
|
2
|
+
import { TurboModuleRegistry } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Turbo Module contract for the wavelength native bridge. The surface is
|
|
6
|
+
* a deliberately thin JSON pipe: `call` dispatches every facade verb by name
|
|
7
|
+
* with a JSON string in and a JSON string out, and the activity stream
|
|
8
|
+
* arrives as 'wavelengthActivity' device events carrying
|
|
9
|
+
* `{ kind: 'entry' | 'end' | 'error', payload: string }`.
|
|
10
|
+
*/
|
|
11
|
+
export interface Spec extends TurboModule {
|
|
12
|
+
/** Invokes a facade verb by name with a JSON payload, returning JSON. */
|
|
13
|
+
call(method: string, paramsJson: string): Promise<string>;
|
|
14
|
+
/** Opens the activity subscription; entries arrive as device events. */
|
|
15
|
+
startActivity(reqJson: string): Promise<void>;
|
|
16
|
+
/** Closes the activity subscription; a no-op when none is open. */
|
|
17
|
+
stopActivity(): Promise<void>;
|
|
18
|
+
/** Resolves the platform default wallet data directory. */
|
|
19
|
+
getDefaultDataDir(): Promise<string>;
|
|
20
|
+
/** Reports whether the platform can run a passkey PRF ceremony. */
|
|
21
|
+
passkeySupported(): Promise<boolean>;
|
|
22
|
+
/** Runs a passkey registration ceremony; WebAuthn JSON in and out. */
|
|
23
|
+
passkeyCreate(requestJson: string): Promise<string>;
|
|
24
|
+
/** Runs a passkey assertion ceremony; WebAuthn JSON in and out. */
|
|
25
|
+
passkeyGet(requestJson: string): Promise<string>;
|
|
26
|
+
/** Required by NativeEventEmitter. */
|
|
27
|
+
addListener(eventName: string): void;
|
|
28
|
+
/** Required by NativeEventEmitter. */
|
|
29
|
+
removeListeners(count: number): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default TurboModuleRegistry.getEnforcing<Spec>('Wavelength');
|