@qntx/wallet-native 0.6.0 → 0.7.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/WalletNative.podspec +1 -1
- package/cpp/host/Passkey.hpp +5 -4
- package/cpp/platform/PasskeyIos.mm +477 -10
- package/dist/index.d.mts +0 -6
- package/dist/index.mjs +3 -6
- package/dist/index.mjs.map +1 -1
- package/kernel/generated/kernel-entry.js +1862 -2351
- package/kernel/generated/kernel.manifest.json +3 -3
- package/package.json +3 -3
package/WalletNative.podspec
CHANGED
|
@@ -59,7 +59,7 @@ Pod::Spec.new do |s|
|
|
|
59
59
|
|
|
60
60
|
# The kernel's backends.spark binds the qs_* C ABI; the prebuilt
|
|
61
61
|
# QntxSpark.xcframework ships inside the SparkNative pod.
|
|
62
|
-
s.frameworks = "LocalAuthentication", "Security"
|
|
62
|
+
s.frameworks = "LocalAuthentication", "Security", "AuthenticationServices", "UIKit"
|
|
63
63
|
|
|
64
64
|
s.dependency 'NostrNative'
|
|
65
65
|
s.dependency 'SparkNative'
|
package/cpp/host/Passkey.hpp
CHANGED
|
@@ -7,10 +7,11 @@ namespace qntx {
|
|
|
7
7
|
|
|
8
8
|
// The platform passkey authenticator behind `__qntx.passkey` (W6). The kernel
|
|
9
9
|
// builds the WebAuthn request JSON and parses the response JSON itself; the
|
|
10
|
-
// platform only runs the system ceremony. Android: Credential Manager. iOS
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// platform only runs the system ceremony. Android: Credential Manager. iOS
|
|
11
|
+
// (W6c): ASAuthorization platform passkeys with the PRF extension on iOS 18+,
|
|
12
|
+
// unavailable below or without the associated domain. Each function has
|
|
13
|
+
// exactly one strong definition per platform (PasskeyAndroid.cpp,
|
|
14
|
+
// PasskeyIos.mm); host tests link a fake.
|
|
14
15
|
|
|
15
16
|
// The binding maps each status to the kernel's error code: cancelled →
|
|
16
17
|
// "cancelled", unavailable → "factor-unavailable", failed → "unavailable".
|
|
@@ -1,20 +1,487 @@
|
|
|
1
|
-
// iOS passkey authenticator:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// iOS passkey authenticator (W6c): ASAuthorization platform passkeys with the
|
|
2
|
+
// PRF extension (iOS 18+). The kernel builds the WebAuthn request JSON and
|
|
3
|
+
// parses the response JSON itself; this file only reads the fields the system
|
|
4
|
+
// ceremony needs, runs one ASAuthorizationController at a time and repacks the
|
|
5
|
+
// credential into the browser `toJSON()` shape the kernel parses. A ceremony
|
|
6
|
+
// requested while the scene is still activating — e.g. right after a system
|
|
7
|
+
// Face ID prompt resigns it — waits for UISceneDidActivateNotification instead
|
|
8
|
+
// of failing before any sheet appears. PRF outputs are encoded straight into
|
|
9
|
+
// the response JSON — no copies are kept and they are never logged.
|
|
6
10
|
#include "../host/Passkey.hpp"
|
|
7
11
|
|
|
12
|
+
#import <AuthenticationServices/AuthenticationServices.h>
|
|
13
|
+
#import <Foundation/Foundation.h>
|
|
14
|
+
#import <UIKit/UIKit.h>
|
|
15
|
+
#import <os/log.h>
|
|
16
|
+
|
|
17
|
+
#include <mutex>
|
|
18
|
+
#include <string>
|
|
19
|
+
#include <utility>
|
|
20
|
+
|
|
21
|
+
@class QntxPasskeySession;
|
|
22
|
+
|
|
23
|
+
namespace {
|
|
24
|
+
|
|
25
|
+
os_log_t passkeyLog() {
|
|
26
|
+
static os_log_t log = os_log_create("org.qntx.wallet", "passkey");
|
|
27
|
+
return log;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The session — and the controller it owns once the ceremony actually runs —
|
|
31
|
+
// stays alive only while a call is in flight (controller.delegate is weak).
|
|
32
|
+
// It is touched on the main thread only; the mutex pairs the one-in-flight
|
|
33
|
+
// check with the clear on completion. A parked session (scene not yet active)
|
|
34
|
+
// has no controller yet, so the busy check looks at the session.
|
|
35
|
+
std::mutex g_mutex;
|
|
36
|
+
QntxPasskeySession* g_session = nil;
|
|
37
|
+
|
|
38
|
+
// Strict base64url decode (padding restored); nil on any invalid character.
|
|
39
|
+
NSData* _Nullable base64urlDecode(NSString* _Nullable value) {
|
|
40
|
+
if (value == nil) {
|
|
41
|
+
return nil;
|
|
42
|
+
}
|
|
43
|
+
NSMutableString* standard = [NSMutableString stringWithCapacity:value.length + 3];
|
|
44
|
+
for (NSUInteger i = 0; i < value.length; i += 1) {
|
|
45
|
+
const unichar c = [value characterAtIndex:i];
|
|
46
|
+
if (c == '-') {
|
|
47
|
+
[standard appendString:@"+"];
|
|
48
|
+
} else if (c == '_') {
|
|
49
|
+
[standard appendString:@"/"];
|
|
50
|
+
} else if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
|
|
51
|
+
[standard appendFormat:@"%C", c];
|
|
52
|
+
} else {
|
|
53
|
+
return nil;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (standard.length % 4 == 1) {
|
|
57
|
+
return nil;
|
|
58
|
+
}
|
|
59
|
+
while (standard.length % 4 != 0) {
|
|
60
|
+
[standard appendString:@"="];
|
|
61
|
+
}
|
|
62
|
+
return [[NSData alloc] initWithBase64EncodedString:standard options:0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
NSString* base64urlEncode(NSData* data) {
|
|
66
|
+
NSString* standard = [data base64EncodedStringWithOptions:0];
|
|
67
|
+
standard = [standard stringByReplacingOccurrencesOfString:@"+" withString:@"-"];
|
|
68
|
+
standard = [standard stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
|
|
69
|
+
while ([standard hasSuffix:@"="]) {
|
|
70
|
+
standard = [standard substringToIndex:standard.length - 1];
|
|
71
|
+
}
|
|
72
|
+
return standard;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
NSDictionary<NSString*, id>* _Nullable dictOf(_Nullable id value) {
|
|
76
|
+
return [value isKindOfClass:NSDictionary.class] ? (NSDictionary<NSString*, id>*)value : nil;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
NSString* _Nullable stringOf(_Nullable id value) {
|
|
80
|
+
return [value isKindOfClass:NSString.class] ? (NSString*)value : nil;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
NSArray* _Nullable arrayOf(_Nullable id value) {
|
|
84
|
+
return [value isKindOfClass:NSArray.class] ? (NSArray*)value : nil;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
NSDictionary<NSString*, id>* _Nullable jsonOf(const std::string& json) {
|
|
88
|
+
NSData* data = [NSData dataWithBytes:json.data() length:json.size()];
|
|
89
|
+
return dictOf([NSJSONSerialization JSONObjectWithData:data options:0 error:nil]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The extensions.prf.eval block the kernel always sends; nil when missing or
|
|
93
|
+
// malformed (eval.second is optional).
|
|
94
|
+
ASAuthorizationPublicKeyCredentialPRFAssertionInputValues* _Nullable prfEval(
|
|
95
|
+
NSDictionary<NSString*, id>* request) API_AVAILABLE(ios(18.0)) {
|
|
96
|
+
NSDictionary* eval = dictOf(dictOf(dictOf(request[@"extensions"])[@"prf"])[@"eval"]);
|
|
97
|
+
NSData* first = base64urlDecode(stringOf(eval[@"first"]));
|
|
98
|
+
if (first == nil) {
|
|
99
|
+
return nil;
|
|
100
|
+
}
|
|
101
|
+
NSData* second = nil;
|
|
102
|
+
if (eval[@"second"] != nil) {
|
|
103
|
+
second = base64urlDecode(stringOf(eval[@"second"]));
|
|
104
|
+
if (second == nil) {
|
|
105
|
+
return nil;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [[ASAuthorizationPublicKeyCredentialPRFAssertionInputValues alloc]
|
|
109
|
+
initWithSaltInput1:first
|
|
110
|
+
saltInput2:second];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ASPresentationAnchor _Nullable activeKeyWindow() {
|
|
114
|
+
for (UIScene* scene in UIApplication.sharedApplication.connectedScenes) {
|
|
115
|
+
if ([scene isKindOfClass:UIWindowScene.class] &&
|
|
116
|
+
scene.activationState == UISceneActivationStateForegroundActive) {
|
|
117
|
+
UIWindow* window = ((UIWindowScene*)scene).keyWindow;
|
|
118
|
+
if (window != nil) {
|
|
119
|
+
return window;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return nil;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A window scene on its way to the foreground — e.g. right after a system
|
|
127
|
+
// Face ID prompt resigns the app's scene. Once it activates, its key window
|
|
128
|
+
// can anchor the passkey sheet.
|
|
129
|
+
BOOL hasInactiveWindowScene() {
|
|
130
|
+
for (UIScene* scene in UIApplication.sharedApplication.connectedScenes) {
|
|
131
|
+
if ([scene isKindOfClass:UIWindowScene.class] &&
|
|
132
|
+
scene.activationState == UISceneActivationStateForegroundInactive &&
|
|
133
|
+
((UIWindowScene*)scene).keyWindow != nil) {
|
|
134
|
+
return YES;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return NO;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
std::string jsonString(NSDictionary<NSString*, id>* json) {
|
|
141
|
+
NSData* data = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil];
|
|
142
|
+
if (data == nil) {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
return std::string(static_cast<const char*>(data.bytes), data.length);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
} // namespace
|
|
149
|
+
|
|
150
|
+
API_AVAILABLE(ios(18.0))
|
|
151
|
+
@interface QntxPasskeySession : NSObject <ASAuthorizationControllerDelegate,
|
|
152
|
+
ASAuthorizationControllerPresentationContextProviding>
|
|
153
|
+
- (instancetype)initWithDone:(qntx::PasskeyDone)done
|
|
154
|
+
request:(ASAuthorizationRequest*)request;
|
|
155
|
+
// Runs the ceremony now, or parks it until the window scene activates.
|
|
156
|
+
- (void)start;
|
|
157
|
+
// Cancels a parked wait or the running ceremony; completes through finish:.
|
|
158
|
+
- (void)cancelCeremony;
|
|
159
|
+
- (void)finish:(qntx::PasskeyResult)result;
|
|
160
|
+
@end
|
|
161
|
+
|
|
162
|
+
@implementation QntxPasskeySession {
|
|
163
|
+
qntx::PasskeyDone _done;
|
|
164
|
+
ASAuthorizationRequest* _request;
|
|
165
|
+
ASPresentationAnchor _anchor;
|
|
166
|
+
ASAuthorizationController* _controller;
|
|
167
|
+
NSObject* _observer;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
- (instancetype)initWithDone:(qntx::PasskeyDone)done
|
|
171
|
+
request:(ASAuthorizationRequest*)request {
|
|
172
|
+
self = [super init];
|
|
173
|
+
if (self != nil) {
|
|
174
|
+
_done = std::move(done);
|
|
175
|
+
_request = request;
|
|
176
|
+
}
|
|
177
|
+
return self;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A ceremony started while the scene is still activating fails before any
|
|
181
|
+
// sheet appears — like Keystore.kt's whenResumed, park on an inactive window
|
|
182
|
+
// scene and run it at UISceneDidActivateNotification. A scene that is fully
|
|
183
|
+
// absent still reports `unavailable`.
|
|
184
|
+
- (void)start {
|
|
185
|
+
ASPresentationAnchor anchor = activeKeyWindow();
|
|
186
|
+
if (anchor != nil) {
|
|
187
|
+
[self performWithAnchor:anchor];
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (hasInactiveWindowScene()) {
|
|
191
|
+
_observer = [NSNotificationCenter.defaultCenter
|
|
192
|
+
addObserverForName:UISceneDidActivateNotification
|
|
193
|
+
object:nil
|
|
194
|
+
queue:NSOperationQueue.mainQueue
|
|
195
|
+
usingBlock:^(NSNotification*) {
|
|
196
|
+
[self sceneActivated];
|
|
197
|
+
}];
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
[self finish:qntx::PasskeyResult{qntx::PasskeyStatus::unavailable, {}}];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
- (void)sceneActivated {
|
|
204
|
+
[NSNotificationCenter.defaultCenter removeObserver:_observer];
|
|
205
|
+
_observer = nil;
|
|
206
|
+
ASPresentationAnchor anchor = activeKeyWindow();
|
|
207
|
+
if (anchor == nil) {
|
|
208
|
+
[self finish:qntx::PasskeyResult{qntx::PasskeyStatus::unavailable, {}}];
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
[self performWithAnchor:anchor];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
- (void)performWithAnchor:(ASPresentationAnchor)anchor {
|
|
215
|
+
_anchor = anchor;
|
|
216
|
+
_controller =
|
|
217
|
+
[[ASAuthorizationController alloc] initWithAuthorizationRequests:@[ _request ]];
|
|
218
|
+
_controller.delegate = self;
|
|
219
|
+
_controller.presentationContextProvider = self;
|
|
220
|
+
[_controller performRequests];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
- (void)cancelCeremony {
|
|
224
|
+
if (_observer != nil) {
|
|
225
|
+
[NSNotificationCenter.defaultCenter removeObserver:_observer];
|
|
226
|
+
_observer = nil;
|
|
227
|
+
[self finish:qntx::PasskeyResult{qntx::PasskeyStatus::cancelled, {}}];
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
[_controller cancel];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Runs exactly once per session; clears the in-flight session before
|
|
234
|
+
// invoking the kernel completion.
|
|
235
|
+
- (void)finish:(qntx::PasskeyResult)result {
|
|
236
|
+
qntx::PasskeyDone done;
|
|
237
|
+
{
|
|
238
|
+
std::lock_guard<std::mutex> lock(g_mutex);
|
|
239
|
+
if (g_session != self) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
g_session = nil;
|
|
243
|
+
done = std::move(_done);
|
|
244
|
+
_done = nullptr;
|
|
245
|
+
}
|
|
246
|
+
if (_observer != nil) {
|
|
247
|
+
[NSNotificationCenter.defaultCenter removeObserver:_observer];
|
|
248
|
+
_observer = nil;
|
|
249
|
+
}
|
|
250
|
+
done(std::move(result));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
- (ASPresentationAnchor)presentationAnchorForAuthorizationController:
|
|
254
|
+
(ASAuthorizationController*)controller {
|
|
255
|
+
return _anchor;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
- (void)authorizationController:(ASAuthorizationController*)controller
|
|
259
|
+
didCompleteWithAuthorization:(ASAuthorization*)authorization {
|
|
260
|
+
NSMutableDictionary<NSString*, id>* extensions = [NSMutableDictionary new];
|
|
261
|
+
NSMutableDictionary<NSString*, id>* response = [NSMutableDictionary new];
|
|
262
|
+
std::string credentialId;
|
|
263
|
+
if ([authorization.credential
|
|
264
|
+
isKindOfClass:ASAuthorizationPlatformPublicKeyCredentialRegistration.class]) {
|
|
265
|
+
auto* credential = (ASAuthorizationPlatformPublicKeyCredentialRegistration*)
|
|
266
|
+
authorization.credential;
|
|
267
|
+
NSString* id64 = base64urlEncode(credential.credentialID);
|
|
268
|
+
credentialId = std::string(id64.UTF8String);
|
|
269
|
+
response[@"clientDataJSON"] = base64urlEncode(credential.rawClientDataJSON);
|
|
270
|
+
if (credential.rawAttestationObject != nil) {
|
|
271
|
+
response[@"attestationObject"] = base64urlEncode(credential.rawAttestationObject);
|
|
272
|
+
}
|
|
273
|
+
if (credential.prf != nil) {
|
|
274
|
+
NSMutableDictionary<NSString*, id>* prf =
|
|
275
|
+
[@{ @"enabled" : @(credential.prf.isSupported) } mutableCopy];
|
|
276
|
+
if (credential.prf.first != nil) {
|
|
277
|
+
NSMutableDictionary<NSString*, id>* results =
|
|
278
|
+
[@{ @"first" : base64urlEncode(credential.prf.first) } mutableCopy];
|
|
279
|
+
if (credential.prf.second != nil) {
|
|
280
|
+
results[@"second"] = base64urlEncode(credential.prf.second);
|
|
281
|
+
}
|
|
282
|
+
prf[@"results"] = results;
|
|
283
|
+
}
|
|
284
|
+
extensions[@"prf"] = prf;
|
|
285
|
+
}
|
|
286
|
+
} else if ([authorization.credential
|
|
287
|
+
isKindOfClass:ASAuthorizationPlatformPublicKeyCredentialAssertion.class]) {
|
|
288
|
+
auto* credential = (ASAuthorizationPlatformPublicKeyCredentialAssertion*)
|
|
289
|
+
authorization.credential;
|
|
290
|
+
NSString* id64 = base64urlEncode(credential.credentialID);
|
|
291
|
+
credentialId = std::string(id64.UTF8String);
|
|
292
|
+
response[@"clientDataJSON"] = base64urlEncode(credential.rawClientDataJSON);
|
|
293
|
+
response[@"authenticatorData"] = base64urlEncode(credential.rawAuthenticatorData);
|
|
294
|
+
response[@"signature"] = base64urlEncode(credential.signature);
|
|
295
|
+
response[@"userHandle"] = base64urlEncode(credential.userID);
|
|
296
|
+
if (credential.prf != nil) {
|
|
297
|
+
NSMutableDictionary<NSString*, id>* results =
|
|
298
|
+
[@{ @"first" : base64urlEncode(credential.prf.first) } mutableCopy];
|
|
299
|
+
if (credential.prf.second != nil) {
|
|
300
|
+
results[@"second"] = base64urlEncode(credential.prf.second);
|
|
301
|
+
}
|
|
302
|
+
extensions[@"prf"] = @{ @"results" : results };
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
[self finish:qntx::PasskeyResult{qntx::PasskeyStatus::failed, {}}];
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
std::string json = jsonString(@{
|
|
309
|
+
@"id" : @(credentialId.c_str()),
|
|
310
|
+
@"rawId" : @(credentialId.c_str()),
|
|
311
|
+
@"type" : @"public-key",
|
|
312
|
+
@"authenticatorAttachment" : @"platform",
|
|
313
|
+
@"response" : response,
|
|
314
|
+
@"clientExtensionResults" : extensions,
|
|
315
|
+
});
|
|
316
|
+
if (json.empty()) {
|
|
317
|
+
[self finish:qntx::PasskeyResult{qntx::PasskeyStatus::failed, {}}];
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
[self finish:qntx::PasskeyResult{qntx::PasskeyStatus::ok, std::move(json)}];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
- (void)authorizationController:(ASAuthorizationController*)controller
|
|
324
|
+
didCompleteWithError:(NSError*)error {
|
|
325
|
+
qntx::PasskeyStatus status = qntx::PasskeyStatus::failed;
|
|
326
|
+
if ([error.domain isEqualToString:ASAuthorizationErrorDomain]) {
|
|
327
|
+
switch (error.code) {
|
|
328
|
+
case ASAuthorizationErrorCanceled:
|
|
329
|
+
status = qntx::PasskeyStatus::cancelled;
|
|
330
|
+
break;
|
|
331
|
+
case ASAuthorizationErrorNotHandled:
|
|
332
|
+
case ASAuthorizationErrorFailed:
|
|
333
|
+
case ASAuthorizationErrorNotInteractive:
|
|
334
|
+
status = qntx::PasskeyStatus::unavailable;
|
|
335
|
+
break;
|
|
336
|
+
default:
|
|
337
|
+
// 1010 = ASAuthorizationErrorDeviceNotConfiguredForPasskeyCreation,
|
|
338
|
+
// compared raw so the iOS 19 symbol needs no availability guard.
|
|
339
|
+
status = error.code == 1010 ? qntx::PasskeyStatus::unavailable
|
|
340
|
+
: qntx::PasskeyStatus::failed;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Domain and code only — userInfo/localizedDescription can name the account.
|
|
344
|
+
os_log_error(
|
|
345
|
+
passkeyLog(),
|
|
346
|
+
"passkey ceremony failed: %{public}@/%ld",
|
|
347
|
+
error.domain,
|
|
348
|
+
static_cast<long>(error.code));
|
|
349
|
+
[self finish:qntx::PasskeyResult{status, {}}];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
@end
|
|
353
|
+
|
|
354
|
+
namespace {
|
|
355
|
+
|
|
356
|
+
ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest* _Nullable registrationRequest(
|
|
357
|
+
NSDictionary<NSString*, id>* request) API_AVAILABLE(ios(18.0)) {
|
|
358
|
+
NSDictionary* rp = dictOf(request[@"rp"]);
|
|
359
|
+
NSDictionary* user = dictOf(request[@"user"]);
|
|
360
|
+
NSString* rpId = stringOf(rp[@"id"]);
|
|
361
|
+
NSString* name = stringOf(user[@"name"]);
|
|
362
|
+
NSData* challenge = base64urlDecode(stringOf(request[@"challenge"]));
|
|
363
|
+
NSData* userId = base64urlDecode(stringOf(user[@"id"]));
|
|
364
|
+
ASAuthorizationPublicKeyCredentialPRFAssertionInputValues* eval = prfEval(request);
|
|
365
|
+
if (rpId == nil || name == nil || challenge == nil || userId == nil || eval == nil) {
|
|
366
|
+
return nil;
|
|
367
|
+
}
|
|
368
|
+
auto* provider = [[ASAuthorizationPlatformPublicKeyCredentialProvider alloc]
|
|
369
|
+
initWithRelyingPartyIdentifier:rpId];
|
|
370
|
+
auto* platform = [provider createCredentialRegistrationRequestWithChallenge:challenge
|
|
371
|
+
name:name
|
|
372
|
+
userID:userId];
|
|
373
|
+
platform.userVerificationPreference =
|
|
374
|
+
ASAuthorizationPublicKeyCredentialUserVerificationPreferenceRequired;
|
|
375
|
+
platform.attestationPreference = ASAuthorizationPublicKeyCredentialAttestationKindNone;
|
|
376
|
+
platform.prf = [[ASAuthorizationPublicKeyCredentialPRFRegistrationInput alloc]
|
|
377
|
+
initWithInputValues:eval];
|
|
378
|
+
return platform;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
ASAuthorizationPlatformPublicKeyCredentialAssertionRequest* _Nullable assertionRequest(
|
|
382
|
+
NSDictionary<NSString*, id>* request) API_AVAILABLE(ios(18.0)) {
|
|
383
|
+
NSString* rpId = stringOf(request[@"rpId"]);
|
|
384
|
+
NSData* challenge = base64urlDecode(stringOf(request[@"challenge"]));
|
|
385
|
+
ASAuthorizationPublicKeyCredentialPRFAssertionInputValues* eval = prfEval(request);
|
|
386
|
+
if (rpId == nil || challenge == nil || eval == nil) {
|
|
387
|
+
return nil;
|
|
388
|
+
}
|
|
389
|
+
auto* provider = [[ASAuthorizationPlatformPublicKeyCredentialProvider alloc]
|
|
390
|
+
initWithRelyingPartyIdentifier:rpId];
|
|
391
|
+
auto* platform = [provider createCredentialAssertionRequestWithChallenge:challenge];
|
|
392
|
+
if (request[@"allowCredentials"] != nil) {
|
|
393
|
+
NSArray* allow = arrayOf(request[@"allowCredentials"]);
|
|
394
|
+
if (allow == nil) {
|
|
395
|
+
return nil;
|
|
396
|
+
}
|
|
397
|
+
NSMutableArray<ASAuthorizationPlatformPublicKeyCredentialDescriptor*>* descriptors =
|
|
398
|
+
[NSMutableArray arrayWithCapacity:allow.count];
|
|
399
|
+
for (id item in allow) {
|
|
400
|
+
NSData* credentialId = base64urlDecode(stringOf(dictOf(item)[@"id"]));
|
|
401
|
+
if (credentialId == nil) {
|
|
402
|
+
return nil;
|
|
403
|
+
}
|
|
404
|
+
[descriptors addObject:[[ASAuthorizationPlatformPublicKeyCredentialDescriptor alloc]
|
|
405
|
+
initWithCredentialID:credentialId]];
|
|
406
|
+
}
|
|
407
|
+
platform.allowedCredentials = descriptors;
|
|
408
|
+
}
|
|
409
|
+
platform.userVerificationPreference =
|
|
410
|
+
ASAuthorizationPublicKeyCredentialUserVerificationPreferenceRequired;
|
|
411
|
+
platform.prf = [[ASAuthorizationPublicKeyCredentialPRFAssertionInput alloc]
|
|
412
|
+
initWithInputValues:eval
|
|
413
|
+
perCredentialInputValues:nil];
|
|
414
|
+
return platform;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Claims the single in-flight slot and runs the ceremony (main thread). A nil
|
|
418
|
+
// request means the JSON was malformed, a busy slot a second concurrent call.
|
|
419
|
+
// AuthenticationServices may deliver the delegate callback synchronously, so
|
|
420
|
+
// the mutex never spans a call into it — or into `done`.
|
|
421
|
+
void run(ASAuthorizationRequest* _Nullable request, qntx::PasskeyDone done)
|
|
422
|
+
API_AVAILABLE(ios(18.0)) {
|
|
423
|
+
if (request == nil) {
|
|
424
|
+
done(qntx::PasskeyResult{qntx::PasskeyStatus::failed, {}});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
QntxPasskeySession* session;
|
|
428
|
+
{
|
|
429
|
+
std::lock_guard<std::mutex> lock(g_mutex);
|
|
430
|
+
if (g_session == nil) {
|
|
431
|
+
session = g_session = [[QntxPasskeySession alloc] initWithDone:std::move(done)
|
|
432
|
+
request:request];
|
|
433
|
+
} else {
|
|
434
|
+
session = nil;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (session == nil) {
|
|
438
|
+
done(qntx::PasskeyResult{qntx::PasskeyStatus::failed, {}});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
[session start];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
} // namespace
|
|
445
|
+
|
|
8
446
|
namespace qntx {
|
|
9
447
|
|
|
10
|
-
void passkeyCreate(std::string, PasskeyDone done) {
|
|
11
|
-
|
|
448
|
+
void passkeyCreate(std::string requestJson, PasskeyDone done) {
|
|
449
|
+
auto json = std::make_shared<std::string>(std::move(requestJson));
|
|
450
|
+
auto finish = std::make_shared<PasskeyDone>(std::move(done));
|
|
451
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
452
|
+
if (@available(iOS 18.0, *)) {
|
|
453
|
+
run(registrationRequest(jsonOf(*json)), std::move(*finish));
|
|
454
|
+
} else {
|
|
455
|
+
(*finish)(PasskeyResult{PasskeyStatus::unavailable, {}});
|
|
456
|
+
}
|
|
457
|
+
});
|
|
12
458
|
}
|
|
13
459
|
|
|
14
|
-
void passkeyGet(std::string, PasskeyDone done) {
|
|
15
|
-
|
|
460
|
+
void passkeyGet(std::string requestJson, PasskeyDone done) {
|
|
461
|
+
auto json = std::make_shared<std::string>(std::move(requestJson));
|
|
462
|
+
auto finish = std::make_shared<PasskeyDone>(std::move(done));
|
|
463
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
464
|
+
if (@available(iOS 18.0, *)) {
|
|
465
|
+
run(assertionRequest(jsonOf(*json)), std::move(*finish));
|
|
466
|
+
} else {
|
|
467
|
+
(*finish)(PasskeyResult{PasskeyStatus::unavailable, {}});
|
|
468
|
+
}
|
|
469
|
+
});
|
|
16
470
|
}
|
|
17
471
|
|
|
18
|
-
void passkeyCancel() {
|
|
472
|
+
void passkeyCancel() {
|
|
473
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
474
|
+
if (@available(iOS 18.0, *)) {
|
|
475
|
+
QntxPasskeySession* session;
|
|
476
|
+
{
|
|
477
|
+
std::lock_guard<std::mutex> lock(g_mutex);
|
|
478
|
+
session = g_session;
|
|
479
|
+
}
|
|
480
|
+
// A parked wait completes `cancelled`; a running ceremony is cancelled —
|
|
481
|
+
// both answer through finish:, so this runs off the lock.
|
|
482
|
+
[session cancelCeremony];
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
19
486
|
|
|
20
487
|
} // namespace qntx
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { AutoLockPolicy, PasskeyConfig, Wallet } from "@qntx/wallet";
|
|
2
2
|
import { MainPort } from "@qntx/wallet/host";
|
|
3
3
|
import { ProtocolValue } from "@qntx/wallet/protocol";
|
|
4
|
-
//#region src/host.d.ts
|
|
5
4
|
type KernelExitReason = "locked" | "crashed" | "integrity" | "background";
|
|
6
5
|
/**
|
|
7
6
|
* Structural mirror of the Nitro `KernelHost` spec (W2b registers the real hybrid object; tests
|
|
@@ -15,8 +14,6 @@ type KernelHostLike = {
|
|
|
15
14
|
readonly lock: () => void;
|
|
16
15
|
};
|
|
17
16
|
export declare function spawnFromHost(host: KernelHostLike, backgroundLockSeconds: number): MainPort;
|
|
18
|
-
//#endregion
|
|
19
|
-
//#region src/cbor.d.ts
|
|
20
17
|
export declare const FRAME_MAX_BYTES: number;
|
|
21
18
|
type CborErrorCode = "oversize" | "truncated" | "malformed" | "float" | "unsafe-int" | "bigint" | "undefined" | "tag" | "non-string-key" | "duplicate-key" | "indefinite" | "bad-type";
|
|
22
19
|
export declare class CborError extends Error {
|
|
@@ -27,8 +24,6 @@ export declare class CborError extends Error {
|
|
|
27
24
|
export declare function encodeFrame(value: ProtocolValue): ArrayBuffer;
|
|
28
25
|
/** Decode a CBOR frame back to a `ProtocolValue`, enforcing the 1 MiB cap and strictness. */
|
|
29
26
|
export declare function decodeFrame(frame: ArrayBuffer | Uint8Array): ProtocolValue;
|
|
30
|
-
//#endregion
|
|
31
|
-
//#region src/index.d.ts
|
|
32
27
|
export type NativeWalletOptions = {
|
|
33
28
|
readonly passkey: PasskeyConfig;
|
|
34
29
|
readonly autoLock: AutoLockPolicy;
|
|
@@ -39,6 +34,5 @@ export type NativeWalletOptions = {
|
|
|
39
34
|
readonly host?: KernelHostLike;
|
|
40
35
|
};
|
|
41
36
|
export declare function createNativeWallet(options: NativeWalletOptions): Promise<Wallet>;
|
|
42
|
-
//#endregion
|
|
43
37
|
export type { CborErrorCode, KernelExitReason, KernelHostLike };
|
|
44
38
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WalletError } from "@qntx/wallet";
|
|
2
2
|
import { connectKernel } from "@qntx/wallet/host";
|
|
3
3
|
import { decode, encode } from "cborg";
|
|
4
|
-
|
|
4
|
+
// src/cbor.ts
|
|
5
5
|
/**
|
|
6
6
|
* CBOR framing for the kernel channel. `ProtocolValue` travels main-side ↔ kernel-side as CBOR; the
|
|
7
7
|
* C++ layer moves bytes only. Decode is deliberately strict: floats, unsafe integers, undefined,
|
|
@@ -83,8 +83,7 @@ function decodeFrame(frame) {
|
|
|
83
83
|
throw classify(error);
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
|
|
87
|
-
//#region src/host.ts
|
|
86
|
+
// src/host.ts
|
|
88
87
|
function spawnFromHost(host, backgroundLockSeconds) {
|
|
89
88
|
const exitHandlers = /* @__PURE__ */ new Set();
|
|
90
89
|
let exited = false;
|
|
@@ -135,8 +134,7 @@ function spawnFromHost(host, backgroundLockSeconds) {
|
|
|
135
134
|
}
|
|
136
135
|
};
|
|
137
136
|
}
|
|
138
|
-
|
|
139
|
-
//#region src/index.ts
|
|
137
|
+
// src/index.ts
|
|
140
138
|
/**
|
|
141
139
|
* `@qntx/wallet-native`: the RN host entry. `createNativeWallet` adapts a `KernelHost` (Nitro
|
|
142
140
|
* hybrid from W2b, or any object with the same shape) to the wallet `connectKernel` entry — the
|
|
@@ -164,7 +162,6 @@ async function createNativeWallet(options) {
|
|
|
164
162
|
setTimer
|
|
165
163
|
});
|
|
166
164
|
}
|
|
167
|
-
//#endregion
|
|
168
165
|
export { CborError, FRAME_MAX_BYTES, createNativeWallet, decodeFrame, encodeFrame, spawnFromHost };
|
|
169
166
|
|
|
170
167
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/cbor.ts","../src/host.ts","../src/index.ts"],"sourcesContent":["/**\n * CBOR framing for the kernel channel. `ProtocolValue` travels main-side ↔ kernel-side as CBOR; the\n * C++ layer moves bytes only. Decode is deliberately strict: floats, unsafe integers, undefined,\n * tags, non-string map keys, duplicate keys and indefinite-length items are all rejected — the wire\n * format carries exactly one value domain.\n */\nimport { decode, encode } from \"cborg\";\nimport type { DecodeOptions } from \"cborg\";\n\nimport type { ProtocolValue } from \"@qntx/wallet/protocol\";\n\nexport const FRAME_MAX_BYTES: number = 1024 * 1024;\n\nexport type CborErrorCode =\n | \"oversize\"\n | \"truncated\"\n | \"malformed\"\n | \"float\"\n | \"unsafe-int\"\n | \"bigint\"\n | \"undefined\"\n | \"tag\"\n | \"non-string-key\"\n | \"duplicate-key\"\n | \"indefinite\"\n | \"bad-type\";\n\nexport class CborError extends Error {\n readonly code: CborErrorCode;\n constructor(code: CborErrorCode, message: string) {\n super(message);\n this.name = \"CborError\";\n this.code = code;\n }\n}\n\nconst DECODE_OPTIONS: DecodeOptions = {\n strict: true,\n useMaps: false,\n rejectDuplicateMapKeys: true,\n allowUndefined: false,\n allowIndefinite: false,\n allowNaN: false,\n allowInfinity: false,\n allowBigInt: false,\n};\n\n/** Reject non-safe-integer numbers and non-string map keys; cborg flags cover the rest. */\nfunction validate(value: unknown): asserts value is ProtocolValue {\n if (value === null || typeof value === \"boolean\" || typeof value === \"string\") {\n return;\n }\n if (typeof value === \"number\") {\n if (!Number.isInteger(value)) {\n throw new CborError(\"float\", \"cbor frame: non-integer number\");\n }\n if (!Number.isSafeInteger(value)) {\n throw new CborError(\"unsafe-int\", \"cbor frame: integer outside the safe range\");\n }\n return;\n }\n if (value instanceof Uint8Array) {\n return;\n }\n if (Array.isArray(value)) {\n for (const item of value) {\n validate(item);\n }\n return;\n }\n if (typeof value === \"object\") {\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the object came from a CBOR map\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n if (typeof key !== \"string\") {\n throw new CborError(\"non-string-key\", \"cbor frame: non-string map key\");\n }\n validate(item);\n }\n return;\n }\n throw new CborError(\"bad-type\", `cbor frame: unsupported value type ${typeof value}`);\n}\n\n/** Classify a cborg rejection into the frame error taxonomy. */\nfunction classify(error: unknown): CborError {\n if (error instanceof CborError) {\n return error;\n }\n const text = error instanceof Error ? error.message : String(error);\n const table: ReadonlyArray<[RegExp, CborErrorCode]> = [\n [/duplicate key|repeat map key/i, \"duplicate-key\"],\n [/indefinite/i, \"indefinite\"],\n [/undefined/i, \"undefined\"],\n [/bigint|safe integer range/i, \"unsafe-int\"],\n [/tag/i, \"tag\"],\n [/string/i, \"non-string-key\"],\n ];\n for (const [pattern, code] of table) {\n if (pattern.test(text)) {\n return new CborError(code, `cbor frame: ${text}`);\n }\n }\n return new CborError(\"malformed\", `cbor frame: ${text}`);\n}\n\n/** Encode a `ProtocolValue` to a CBOR frame, enforcing the 1 MiB cap. */\nexport function encodeFrame(value: ProtocolValue): ArrayBuffer {\n validate(value);\n const bytes = encode(value);\n if (bytes.byteLength > FRAME_MAX_BYTES) {\n throw new CborError(\"oversize\", `cbor frame: ${bytes.byteLength} bytes exceeds the 1 MiB cap`);\n }\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);\n}\n\n/** Decode a CBOR frame back to a `ProtocolValue`, enforcing the 1 MiB cap and strictness. */\nexport function decodeFrame(frame: ArrayBuffer | Uint8Array): ProtocolValue {\n const bytes = frame instanceof Uint8Array ? frame : new Uint8Array(frame);\n if (bytes.byteLength > FRAME_MAX_BYTES) {\n throw new CborError(\"oversize\", `cbor frame: ${bytes.byteLength} bytes exceeds the 1 MiB cap`);\n }\n if (bytes.byteLength === 0) {\n throw new CborError(\"truncated\", \"cbor frame: empty frame\");\n }\n try {\n const value: unknown = decode(bytes, DECODE_OPTIONS);\n validate(value);\n return value;\n } catch (error) {\n throw classify(error);\n }\n}\n","/**\n * `KernelHost` JS wrapper: CBOR frames over a Nitro-host-shaped channel. The host moves bytes only\n * — encode/decode happens here and in the kernel entry — so a malformed inbound frame can never\n * reach the message domain: the port exits `integrity` and the host runtime is destroyed.\n */\nimport type { MainPort, PortExit } from \"@qntx/wallet/host\";\nimport type { ProtocolValue } from \"@qntx/wallet/protocol\";\n\nimport { decodeFrame, encodeFrame } from \"./cbor.ts\";\n\nexport type KernelExitReason = \"locked\" | \"crashed\" | \"integrity\" | \"background\";\n\n/**\n * Structural mirror of the Nitro `KernelHost` spec (W2b registers the real hybrid object; tests\n * drive the same shape over an in-memory frame pipe).\n */\nexport type KernelHostLike = {\n readonly start: (backgroundLockSeconds: number) => void;\n readonly post: (frame: ArrayBuffer) => void;\n readonly onMessage: (cb: (frame: ArrayBuffer) => void) => void;\n readonly onExit: (cb: (reason: KernelExitReason) => void) => void;\n readonly lock: () => void;\n};\n\nexport function spawnFromHost(host: KernelHostLike, backgroundLockSeconds: number): MainPort {\n const exitHandlers = new Set<(reason: PortExit) => void>();\n let exited = false;\n\n const exit = (reason: PortExit): void => {\n if (exited) {\n return;\n }\n exited = true;\n for (const handler of exitHandlers) {\n handler(reason);\n }\n host.lock();\n };\n\n host.onMessage((frame) => {\n let message: ProtocolValue;\n try {\n message = decodeFrame(frame);\n } catch {\n exit(\"crashed\");\n return;\n }\n dispatch(message);\n });\n\n // The platform locked the enclave in the background — report it honestly so\n // the channel takes the lock path, not the crash path.\n host.onExit((reason) => {\n exit(reason === \"background\" ? \"background\" : \"crashed\");\n });\n\n const messageHandlers = new Set<(message: unknown) => void>();\n const dispatch = (message: unknown): void => {\n for (const handler of messageHandlers) {\n handler(message);\n }\n };\n\n host.start(backgroundLockSeconds);\n\n return {\n post: (message, transfer) => {\n const frame = encodeFrame(message);\n host.post(frame);\n // Structured clone would neuter transferred buffers on the web; the main↔kernel\n // contract is the same here — the caller's buffers hold secrets (passwords, PRF\n // output) and are zeroed once their bytes are inside the frame.\n for (const buffer of transfer) {\n new Uint8Array(buffer).fill(0);\n }\n },\n listen: (handler) => {\n messageHandlers.add(handler);\n return () => {\n messageHandlers.delete(handler);\n };\n },\n onExit: (handler) => {\n exitHandlers.add(handler);\n return () => {\n exitHandlers.delete(handler);\n };\n },\n terminate: () => {\n exit(\"crashed\");\n },\n };\n}\n","import { WalletError } from \"@qntx/wallet\";\nimport type { AutoLockPolicy, PasskeyConfig, Wallet } from \"@qntx/wallet\";\nimport type { SetTimer } from \"@qntx/wallet/host\";\n/**\n * `@qntx/wallet-native`: the RN host entry. `createNativeWallet` adapts a `KernelHost` (Nitro\n * hybrid from W2b, or any object with the same shape) to the wallet `connectKernel` entry — the\n * returned `Wallet` is the same type as the web/node host.\n */\nimport { connectKernel } from \"@qntx/wallet/host\";\n\nimport { spawnFromHost } from \"./host.ts\";\nimport type { KernelHostLike } from \"./host.ts\";\nimport type { KernelHost } from \"./specs/kernel-host.nitro.ts\";\n\nexport type NativeWalletOptions = {\n readonly passkey: PasskeyConfig;\n readonly autoLock: AutoLockPolicy;\n /**\n * The Nitro `KernelHost` hybrid object (or a like-shaped fake for tests). Defaults to\n * `NitroModules.createHybridObject(\"KernelHost\")` — the only Nitro-registered object.\n */\n readonly host?: KernelHostLike;\n};\n\nconst setTimer: SetTimer = (delayMs, fn) => {\n const id = setTimeout(fn, delayMs);\n return () => {\n clearTimeout(id);\n };\n};\n\nasync function defaultHost(): Promise<KernelHostLike> {\n const { NitroModules } = await import(\"react-native-nitro-modules\");\n return NitroModules.createHybridObject<KernelHost>(\"KernelHost\");\n}\n\nexport async function createNativeWallet(options: NativeWalletOptions): Promise<Wallet> {\n const { autoLock } = options;\n const host = options.host ?? (await defaultHost());\n if (autoLock.platform !== \"native\") {\n throw new WalletError(\n \"invalid-input\",\n \"createNativeWallet accepts only the native autoLock policy\",\n );\n }\n // The native host owns the auto-lock signal: the platform layer calls host.lock() on\n // background expiry — the kernel never sees a presence source here.\n return connectKernel({\n spawn: () => spawnFromHost(host, autoLock.backgroundSeconds),\n factors: \"hardware\",\n passkey: options.passkey,\n ceremony: undefined,\n setTimer,\n });\n}\n\nexport { spawnFromHost, type KernelHostLike, type KernelExitReason } from \"./host.ts\";\nexport {\n encodeFrame,\n decodeFrame,\n CborError,\n FRAME_MAX_BYTES,\n type CborErrorCode,\n} from \"./cbor.ts\";\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/cbor.ts","../src/host.ts","../src/index.ts"],"sourcesContent":["/**\n * CBOR framing for the kernel channel. `ProtocolValue` travels main-side ↔ kernel-side as CBOR; the\n * C++ layer moves bytes only. Decode is deliberately strict: floats, unsafe integers, undefined,\n * tags, non-string map keys, duplicate keys and indefinite-length items are all rejected — the wire\n * format carries exactly one value domain.\n */\nimport { decode, encode } from \"cborg\";\nimport type { DecodeOptions } from \"cborg\";\n\nimport type { ProtocolValue } from \"@qntx/wallet/protocol\";\n\nexport const FRAME_MAX_BYTES: number = 1024 * 1024;\n\nexport type CborErrorCode =\n | \"oversize\"\n | \"truncated\"\n | \"malformed\"\n | \"float\"\n | \"unsafe-int\"\n | \"bigint\"\n | \"undefined\"\n | \"tag\"\n | \"non-string-key\"\n | \"duplicate-key\"\n | \"indefinite\"\n | \"bad-type\";\n\nexport class CborError extends Error {\n readonly code: CborErrorCode;\n constructor(code: CborErrorCode, message: string) {\n super(message);\n this.name = \"CborError\";\n this.code = code;\n }\n}\n\nconst DECODE_OPTIONS: DecodeOptions = {\n strict: true,\n useMaps: false,\n rejectDuplicateMapKeys: true,\n allowUndefined: false,\n allowIndefinite: false,\n allowNaN: false,\n allowInfinity: false,\n allowBigInt: false,\n};\n\n/** Reject non-safe-integer numbers and non-string map keys; cborg flags cover the rest. */\nfunction validate(value: unknown): asserts value is ProtocolValue {\n if (value === null || typeof value === \"boolean\" || typeof value === \"string\") {\n return;\n }\n if (typeof value === \"number\") {\n if (!Number.isInteger(value)) {\n throw new CborError(\"float\", \"cbor frame: non-integer number\");\n }\n if (!Number.isSafeInteger(value)) {\n throw new CborError(\"unsafe-int\", \"cbor frame: integer outside the safe range\");\n }\n return;\n }\n if (value instanceof Uint8Array) {\n return;\n }\n if (Array.isArray(value)) {\n for (const item of value) {\n validate(item);\n }\n return;\n }\n if (typeof value === \"object\") {\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the object came from a CBOR map\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n if (typeof key !== \"string\") {\n throw new CborError(\"non-string-key\", \"cbor frame: non-string map key\");\n }\n validate(item);\n }\n return;\n }\n throw new CborError(\"bad-type\", `cbor frame: unsupported value type ${typeof value}`);\n}\n\n/** Classify a cborg rejection into the frame error taxonomy. */\nfunction classify(error: unknown): CborError {\n if (error instanceof CborError) {\n return error;\n }\n const text = error instanceof Error ? error.message : String(error);\n const table: ReadonlyArray<[RegExp, CborErrorCode]> = [\n [/duplicate key|repeat map key/i, \"duplicate-key\"],\n [/indefinite/i, \"indefinite\"],\n [/undefined/i, \"undefined\"],\n [/bigint|safe integer range/i, \"unsafe-int\"],\n [/tag/i, \"tag\"],\n [/string/i, \"non-string-key\"],\n ];\n for (const [pattern, code] of table) {\n if (pattern.test(text)) {\n return new CborError(code, `cbor frame: ${text}`);\n }\n }\n return new CborError(\"malformed\", `cbor frame: ${text}`);\n}\n\n/** Encode a `ProtocolValue` to a CBOR frame, enforcing the 1 MiB cap. */\nexport function encodeFrame(value: ProtocolValue): ArrayBuffer {\n validate(value);\n const bytes = encode(value);\n if (bytes.byteLength > FRAME_MAX_BYTES) {\n throw new CborError(\"oversize\", `cbor frame: ${bytes.byteLength} bytes exceeds the 1 MiB cap`);\n }\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);\n}\n\n/** Decode a CBOR frame back to a `ProtocolValue`, enforcing the 1 MiB cap and strictness. */\nexport function decodeFrame(frame: ArrayBuffer | Uint8Array): ProtocolValue {\n const bytes = frame instanceof Uint8Array ? frame : new Uint8Array(frame);\n if (bytes.byteLength > FRAME_MAX_BYTES) {\n throw new CborError(\"oversize\", `cbor frame: ${bytes.byteLength} bytes exceeds the 1 MiB cap`);\n }\n if (bytes.byteLength === 0) {\n throw new CborError(\"truncated\", \"cbor frame: empty frame\");\n }\n try {\n const value: unknown = decode(bytes, DECODE_OPTIONS);\n validate(value);\n return value;\n } catch (error) {\n throw classify(error);\n }\n}\n","/**\n * `KernelHost` JS wrapper: CBOR frames over a Nitro-host-shaped channel. The host moves bytes only\n * — encode/decode happens here and in the kernel entry — so a malformed inbound frame can never\n * reach the message domain: the port exits `integrity` and the host runtime is destroyed.\n */\nimport type { MainPort, PortExit } from \"@qntx/wallet/host\";\nimport type { ProtocolValue } from \"@qntx/wallet/protocol\";\n\nimport { decodeFrame, encodeFrame } from \"./cbor.ts\";\n\nexport type KernelExitReason = \"locked\" | \"crashed\" | \"integrity\" | \"background\";\n\n/**\n * Structural mirror of the Nitro `KernelHost` spec (W2b registers the real hybrid object; tests\n * drive the same shape over an in-memory frame pipe).\n */\nexport type KernelHostLike = {\n readonly start: (backgroundLockSeconds: number) => void;\n readonly post: (frame: ArrayBuffer) => void;\n readonly onMessage: (cb: (frame: ArrayBuffer) => void) => void;\n readonly onExit: (cb: (reason: KernelExitReason) => void) => void;\n readonly lock: () => void;\n};\n\nexport function spawnFromHost(host: KernelHostLike, backgroundLockSeconds: number): MainPort {\n const exitHandlers = new Set<(reason: PortExit) => void>();\n let exited = false;\n\n const exit = (reason: PortExit): void => {\n if (exited) {\n return;\n }\n exited = true;\n for (const handler of exitHandlers) {\n handler(reason);\n }\n host.lock();\n };\n\n host.onMessage((frame) => {\n let message: ProtocolValue;\n try {\n message = decodeFrame(frame);\n } catch {\n exit(\"crashed\");\n return;\n }\n dispatch(message);\n });\n\n // The platform locked the enclave in the background — report it honestly so\n // the channel takes the lock path, not the crash path.\n host.onExit((reason) => {\n exit(reason === \"background\" ? \"background\" : \"crashed\");\n });\n\n const messageHandlers = new Set<(message: unknown) => void>();\n const dispatch = (message: unknown): void => {\n for (const handler of messageHandlers) {\n handler(message);\n }\n };\n\n host.start(backgroundLockSeconds);\n\n return {\n post: (message, transfer) => {\n const frame = encodeFrame(message);\n host.post(frame);\n // Structured clone would neuter transferred buffers on the web; the main↔kernel\n // contract is the same here — the caller's buffers hold secrets (passwords, PRF\n // output) and are zeroed once their bytes are inside the frame.\n for (const buffer of transfer) {\n new Uint8Array(buffer).fill(0);\n }\n },\n listen: (handler) => {\n messageHandlers.add(handler);\n return () => {\n messageHandlers.delete(handler);\n };\n },\n onExit: (handler) => {\n exitHandlers.add(handler);\n return () => {\n exitHandlers.delete(handler);\n };\n },\n terminate: () => {\n exit(\"crashed\");\n },\n };\n}\n","import { WalletError } from \"@qntx/wallet\";\nimport type { AutoLockPolicy, PasskeyConfig, Wallet } from \"@qntx/wallet\";\nimport type { SetTimer } from \"@qntx/wallet/host\";\n/**\n * `@qntx/wallet-native`: the RN host entry. `createNativeWallet` adapts a `KernelHost` (Nitro\n * hybrid from W2b, or any object with the same shape) to the wallet `connectKernel` entry — the\n * returned `Wallet` is the same type as the web/node host.\n */\nimport { connectKernel } from \"@qntx/wallet/host\";\n\nimport { spawnFromHost } from \"./host.ts\";\nimport type { KernelHostLike } from \"./host.ts\";\nimport type { KernelHost } from \"./specs/kernel-host.nitro.ts\";\n\nexport type NativeWalletOptions = {\n readonly passkey: PasskeyConfig;\n readonly autoLock: AutoLockPolicy;\n /**\n * The Nitro `KernelHost` hybrid object (or a like-shaped fake for tests). Defaults to\n * `NitroModules.createHybridObject(\"KernelHost\")` — the only Nitro-registered object.\n */\n readonly host?: KernelHostLike;\n};\n\nconst setTimer: SetTimer = (delayMs, fn) => {\n const id = setTimeout(fn, delayMs);\n return () => {\n clearTimeout(id);\n };\n};\n\nasync function defaultHost(): Promise<KernelHostLike> {\n const { NitroModules } = await import(\"react-native-nitro-modules\");\n return NitroModules.createHybridObject<KernelHost>(\"KernelHost\");\n}\n\nexport async function createNativeWallet(options: NativeWalletOptions): Promise<Wallet> {\n const { autoLock } = options;\n const host = options.host ?? (await defaultHost());\n if (autoLock.platform !== \"native\") {\n throw new WalletError(\n \"invalid-input\",\n \"createNativeWallet accepts only the native autoLock policy\",\n );\n }\n // The native host owns the auto-lock signal: the platform layer calls host.lock() on\n // background expiry — the kernel never sees a presence source here.\n return connectKernel({\n spawn: () => spawnFromHost(host, autoLock.backgroundSeconds),\n factors: \"hardware\",\n passkey: options.passkey,\n ceremony: undefined,\n setTimer,\n });\n}\n\nexport { spawnFromHost, type KernelHostLike, type KernelExitReason } from \"./host.ts\";\nexport {\n encodeFrame,\n decodeFrame,\n CborError,\n FRAME_MAX_BYTES,\n type CborErrorCode,\n} from \"./cbor.ts\";\n"],"mappings":"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"}
|