Skip to content

Commit ee8a3c0

Browse files
committed
feat: native iOS passkey with PRF and cross-platform bridge
1 parent 8b8f2e0 commit ee8a3c0

7 files changed

Lines changed: 514 additions & 58 deletions

File tree

ios/App/App.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6B2865F76F213517CFCCD1 /* AppViewController.swift */; };
1919
B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */; };
2020
C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */; };
21+
D1A2B3C4E5F607890A1B2C3D /* PasskeyPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */; };
2122
/* End PBXBuildFile section */
2223

2324
/* Begin PBXFileReference section */
@@ -35,6 +36,7 @@
3536
873F0344C8952CB5585102E0 /* App.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
3637
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
3738
C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BarcodeScannerPlugin.swift; sourceTree = "<group>"; };
39+
D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyPlugin.swift; sourceTree = "<group>"; };
3840
/* End PBXFileReference section */
3941

4042
/* Begin PBXFrameworksBuildPhase section */
@@ -79,6 +81,7 @@
7981
50B271D01FEDC1A000F3C39B /* public */,
8082
21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */,
8183
C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */,
84+
D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */,
8285
1A6B2865F76F213517CFCCD1 /* AppViewController.swift */,
8386
873F0344C8952CB5585102E0 /* App.entitlements */,
8487
);
@@ -170,6 +173,7 @@
170173
B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */,
171174
C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */,
172175
83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */,
176+
D1A2B3C4E5F607890A1B2C3D /* PasskeyPlugin.swift in Sources */,
173177
);
174178
runOnlyForDeploymentPostprocessing = 0;
175179
};

ios/App/App/App.entitlements

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,9 @@
66
<array>
77
<string>$(AppIdentifierPrefix)com.miden.wallet</string>
88
</array>
9+
<key>com.apple.developer.associated-domains</key>
10+
<array>
11+
<string>webcredentials:api.midenbrowserwallet.com</string>
12+
</array>
913
</dict>
1014
</plist>

ios/App/App/AppViewController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ class AppViewController: CAPBridgeViewController {
55
override open func capacitorDidLoad() {
66
bridge?.registerPluginInstance(LocalBiometricPlugin())
77
bridge?.registerPluginInstance(BarcodeScannerPlugin())
8+
bridge?.registerPluginInstance(PasskeyPlugin())
89
}
910
}

ios/App/App/PasskeyPlugin.swift

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import Foundation
2+
import Capacitor
3+
import AuthenticationServices
4+
import CryptoKit
5+
import os.log
6+
7+
private let logger = OSLog(subsystem: "com.miden.wallet", category: "Passkey")
8+
9+
/// Native Capacitor plugin for passkey operations using Apple's ASAuthorization API
10+
/// with PRF (Pseudo-Random Function) extension support.
11+
///
12+
/// WKWebView's JavaScript WebAuthn bridge does not pass through the PRF extension,
13+
/// so we bypass it entirely and call the native API directly.
14+
///
15+
/// Requires iOS 18.0+ for PRF support.
16+
@objc(PasskeyPlugin)
17+
public class PasskeyPlugin: CAPPlugin, CAPBridgedPlugin, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
18+
public let identifier = "PasskeyPlugin"
19+
public let jsName = "Passkey"
20+
public let pluginMethods: [CAPPluginMethod] = [
21+
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
22+
CAPPluginMethod(name: "register", returnType: CAPPluginReturnPromise),
23+
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise)
24+
]
25+
26+
// Strong reference to prevent ASAuthorizationController deallocation mid-flow
27+
private var authController: ASAuthorizationController?
28+
private var currentCall: CAPPluginCall?
29+
private var isRegistration = false
30+
31+
// MARK: - ASAuthorizationControllerPresentationContextProviding
32+
33+
public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
34+
return self.bridge?.viewController?.view.window ?? ASPresentationAnchor()
35+
}
36+
37+
// MARK: - Plugin Methods
38+
39+
@objc func isAvailable(_ call: CAPPluginCall) {
40+
os_log("[Passkey] isAvailable called", log: logger, type: .debug)
41+
if #available(iOS 18.0, *) {
42+
call.resolve(["available": true])
43+
} else {
44+
os_log("[Passkey] iOS 18.0+ required for PRF support", log: logger, type: .info)
45+
call.resolve(["available": false])
46+
}
47+
}
48+
49+
@objc func register(_ call: CAPPluginCall) {
50+
os_log("[Passkey] register called", log: logger, type: .debug)
51+
52+
guard #available(iOS 18.0, *) else {
53+
call.reject("Passkey PRF requires iOS 18.0+")
54+
return
55+
}
56+
57+
guard let rpId = call.getString("rpId"),
58+
let userName = call.getString("userName"),
59+
let _ = call.getString("userDisplayName"),
60+
let userIdBase64 = call.getString("userId"),
61+
let challengeBase64 = call.getString("challenge"),
62+
let prfSaltBase64 = call.getString("prfSalt") else {
63+
call.reject("Missing required parameters")
64+
return
65+
}
66+
67+
guard let userId = Data(base64Encoded: userIdBase64),
68+
let challenge = Data(base64Encoded: challengeBase64),
69+
let prfSalt = Data(base64Encoded: prfSaltBase64) else {
70+
call.reject("Invalid base64 encoding")
71+
return
72+
}
73+
74+
self.currentCall = call
75+
self.isRegistration = true
76+
77+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
78+
let request = provider.createCredentialRegistrationRequest(
79+
challenge: challenge,
80+
name: userName,
81+
userID: userId
82+
)
83+
84+
// Attach PRF with salt so registration returns the PRF output directly.
85+
let saltValues = ASAuthorizationPublicKeyCredentialPRFAssertionInput.InputValues(saltInput1: prfSalt)
86+
request.prf = .inputValues(saltValues)
87+
88+
DispatchQueue.main.async { [weak self] in
89+
guard let self = self else { return }
90+
let controller = ASAuthorizationController(authorizationRequests: [request])
91+
controller.delegate = self
92+
controller.presentationContextProvider = self
93+
self.authController = controller
94+
controller.performRequests()
95+
}
96+
}
97+
98+
@objc func authenticate(_ call: CAPPluginCall) {
99+
os_log("[Passkey] authenticate called", log: logger, type: .debug)
100+
101+
guard #available(iOS 18.0, *) else {
102+
call.reject("Passkey PRF requires iOS 18.0+")
103+
return
104+
}
105+
106+
guard let rpId = call.getString("rpId"),
107+
let credentialIdBase64 = call.getString("credentialId"),
108+
let challengeBase64 = call.getString("challenge"),
109+
let prfSaltBase64 = call.getString("prfSalt") else {
110+
call.reject("Missing required parameters")
111+
return
112+
}
113+
114+
guard let credentialId = Data(base64Encoded: credentialIdBase64),
115+
let challenge = Data(base64Encoded: challengeBase64),
116+
let prfSalt = Data(base64Encoded: prfSaltBase64) else {
117+
call.reject("Invalid base64 encoding")
118+
return
119+
}
120+
121+
self.currentCall = call
122+
self.isRegistration = false
123+
124+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
125+
let request = provider.createCredentialAssertionRequest(challenge: challenge)
126+
127+
request.allowedCredentials = [
128+
ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialId)
129+
]
130+
131+
let saltValues = ASAuthorizationPublicKeyCredentialPRFAssertionInput.InputValues(saltInput1: prfSalt)
132+
request.prf = .inputValues(saltValues)
133+
134+
DispatchQueue.main.async { [weak self] in
135+
guard let self = self else { return }
136+
let controller = ASAuthorizationController(authorizationRequests: [request])
137+
controller.delegate = self
138+
controller.presentationContextProvider = self
139+
self.authController = controller
140+
controller.performRequests()
141+
}
142+
}
143+
144+
// MARK: - ASAuthorizationControllerDelegate
145+
146+
public func authorizationController(
147+
controller: ASAuthorizationController,
148+
didCompleteWithAuthorization authorization: ASAuthorization
149+
) {
150+
os_log("[Passkey] Authorization completed", log: logger, type: .debug)
151+
152+
guard let call = currentCall else {
153+
os_log("[Passkey] No pending call", log: logger, type: .error)
154+
return
155+
}
156+
157+
if #available(iOS 18.0, *) {
158+
if let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
159+
handleRegistrationResult(registration, call: call)
160+
} else if let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
161+
handleAssertionResult(assertion, call: call)
162+
} else {
163+
call.reject("Unexpected credential type")
164+
cleanup()
165+
}
166+
} else {
167+
call.reject("iOS 18.0+ required")
168+
cleanup()
169+
}
170+
}
171+
172+
public func authorizationController(
173+
controller: ASAuthorizationController,
174+
didCompleteWithError error: Error
175+
) {
176+
os_log("[Passkey] Authorization error: %{public}@", log: logger, type: .error, error.localizedDescription)
177+
178+
guard let call = currentCall else { return }
179+
180+
let nsError = error as NSError
181+
if nsError.domain == ASAuthorizationError.errorDomain,
182+
let code = ASAuthorizationError.Code(rawValue: nsError.code) {
183+
switch code {
184+
case .canceled:
185+
call.reject("Passkey operation was cancelled", "CANCELLED")
186+
case .failed:
187+
call.reject("Passkey operation failed", "FAILED")
188+
case .invalidResponse:
189+
call.reject("Invalid response from authenticator", "INVALID_RESPONSE")
190+
case .notHandled:
191+
call.reject("Request not handled", "NOT_HANDLED")
192+
case .notInteractive:
193+
call.reject("Not interactive", "NOT_INTERACTIVE")
194+
@unknown default:
195+
call.reject("Authorization error: \(error.localizedDescription)")
196+
}
197+
} else {
198+
call.reject("Passkey error: \(error.localizedDescription)")
199+
}
200+
201+
cleanup()
202+
}
203+
204+
// MARK: - Result Handlers
205+
206+
@available(iOS 18.0, *)
207+
private func handleRegistrationResult(
208+
_ registration: ASAuthorizationPlatformPublicKeyCredentialRegistration,
209+
call: CAPPluginCall
210+
) {
211+
let credentialId = registration.credentialID
212+
os_log("[Passkey] Registration succeeded, credentialId length: %d", log: logger, type: .debug, credentialId.count)
213+
214+
guard let prfOutput = registration.prf else {
215+
os_log("[Passkey] No PRF output from registration", log: logger, type: .error)
216+
call.reject("PRF extension not supported by this authenticator")
217+
cleanup()
218+
return
219+
}
220+
221+
guard prfOutput.isSupported else {
222+
os_log("[Passkey] PRF not supported by authenticator", log: logger, type: .error)
223+
call.reject("PRF extension not supported by this authenticator")
224+
cleanup()
225+
return
226+
}
227+
228+
guard let prfKey = prfOutput.first else {
229+
os_log("[Passkey] PRF output has no first key", log: logger, type: .error)
230+
call.reject("PRF output not available from registration")
231+
cleanup()
232+
return
233+
}
234+
235+
let prfData = prfKey.withUnsafeBytes { Data(Array($0)) }
236+
os_log("[Passkey] PRF output obtained from registration, length: %d", log: logger, type: .debug, prfData.count)
237+
238+
call.resolve([
239+
"credentialId": credentialId.base64EncodedString(),
240+
"prfOutput": prfData.base64EncodedString()
241+
])
242+
243+
cleanup()
244+
}
245+
246+
@available(iOS 18.0, *)
247+
private func handleAssertionResult(
248+
_ assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion,
249+
call: CAPPluginCall
250+
) {
251+
os_log("[Passkey] Assertion completed", log: logger, type: .debug)
252+
253+
guard let prfResult = assertion.prf else {
254+
os_log("[Passkey] No PRF output in assertion result", log: logger, type: .error)
255+
call.reject("PRF output not available")
256+
cleanup()
257+
return
258+
}
259+
260+
let prfData = prfResult.first.withUnsafeBytes { Data(Array($0)) }
261+
os_log("[Passkey] PRF output obtained, length: %d", log: logger, type: .debug, prfData.count)
262+
263+
call.resolve([
264+
"credentialId": assertion.credentialID.base64EncodedString(),
265+
"prfOutput": prfData.base64EncodedString()
266+
])
267+
268+
cleanup()
269+
}
270+
271+
// MARK: - Cleanup
272+
273+
private func cleanup() {
274+
currentCall = nil
275+
authController = nil
276+
isRegistration = false
277+
}
278+
}

src/lib/intercom/mobile-adapter.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import * as Actions from 'lib/miden/back/actions';
2+
import {
3+
disableAutoBackup,
4+
enableAutoBackup,
5+
getStatus as getAutoBackupStatus
6+
} from 'lib/miden/back/auto-backup-manager';
27
import { store, toFront } from 'lib/miden/back/store';
8+
import { GoogleDriveProvider } from 'lib/miden/backup/google-drive-provider';
9+
import { probeCloudBackup, restoreCloudBackup, RestoreEncryptionArgs } from 'lib/miden/backup/restore-service';
310
import { MidenMessageType } from 'lib/miden/types';
11+
import { b64ToU8 } from 'lib/shared/helpers';
412
import { WalletMessageType, WalletRequest, WalletResponse } from 'lib/shared/types';
513

614
type SubscriptionCallback = (data: any) => void;
@@ -166,6 +174,44 @@ export class MobileIntercomAdapter {
166174
}
167175
break;
168176

177+
case WalletMessageType.CloudBackupRestoreRequest: {
178+
const restoreProvider = new GoogleDriveProvider(req.accessToken);
179+
const restoreArgs: RestoreEncryptionArgs =
180+
req.encryption.method === 'password'
181+
? { type: 'password', backupPassword: req.encryption.backupPassword }
182+
: { type: 'passkey', keyMaterial: b64ToU8(req.encryption.keyMaterial) };
183+
const content = await restoreCloudBackup(restoreArgs, restoreProvider);
184+
return {
185+
type: WalletMessageType.CloudBackupRestoreResponse,
186+
walletAccounts: content.walletAccounts,
187+
walletSettings: content.walletSettings
188+
};
189+
}
190+
191+
case WalletMessageType.CloudBackupProbeRequest: {
192+
const probeProvider = new GoogleDriveProvider(req.accessToken);
193+
const probe = await probeCloudBackup(probeProvider);
194+
return { type: WalletMessageType.CloudBackupProbeResponse, ...probe };
195+
}
196+
197+
case WalletMessageType.CloudBackupRegisterRequest: {
198+
await Actions.registerFromCloudBackup(req.password ?? '', req.mnemonic, req.walletAccounts, req.walletSettings);
199+
return { type: WalletMessageType.CloudBackupRegisterResponse };
200+
}
201+
202+
case WalletMessageType.AutoBackupSetEnabledRequest: {
203+
if (req.enabled && req.encryption && req.accessToken && req.expiresAt) {
204+
await enableAutoBackup(req.encryption, req.accessToken, req.expiresAt);
205+
} else {
206+
await disableAutoBackup();
207+
}
208+
return { type: WalletMessageType.AutoBackupSetEnabledResponse };
209+
}
210+
211+
case WalletMessageType.AutoBackupStatusRequest: {
212+
return { type: WalletMessageType.AutoBackupStatusResponse, ...getAutoBackupStatus() };
213+
}
214+
169215
default:
170216
console.warn('MobileIntercomAdapter: Unknown request type', req?.type);
171217
}

0 commit comments

Comments
 (0)