Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a086a6a
chore: scaffold cloud backup types and services
0xnullifier Mar 26, 2026
5bce7ee
feat: add Google Drive provider with OAuth (extension + iOS PKCE)
0xnullifier Mar 27, 2026
b8049e8
feat: wire cloud backup through intercom and add test UI in settings
0xnullifier Mar 27, 2026
a46587a
feat: cloud backup restore and import flow
0xnullifier Mar 31, 2026
4ce7334
feat: passkey based backup
0xnullifier Apr 5, 2026
92bb11a
fix: importStore direct dump instead of `StoreSnapshot`
0xnullifier Apr 6, 2026
4054836
feat: autobackup
0xnullifier Apr 7, 2026
74b0b77
feat: native iOS passkey with PRF and cross-platform bridge
0xnullifier Apr 10, 2026
7f5d72c
chore: update translation files
github-actions[bot] Apr 10, 2026
b95406d
feat: simplify Google auth (remove userinfo fetch), add silent auth r…
0xnullifier Apr 11, 2026
db3ab43
fix: make initial account private and update wallet accounts on canon…
0xnullifier Apr 12, 2026
cae4975
feat: enable auto-backup by default after cloud restore
0xnullifier Apr 12, 2026
786c192
refactor: replace markDirty with explicit triggerBackup
0xnullifier Apr 12, 2026
3361742
feat: enable mobile sync and clean up mobile adapter types
0xnullifier Apr 12, 2026
b653d4d
fix: persist wallet settings by reading from vault storage
0xnullifier Apr 13, 2026
551c699
chore: add autobackup debug log and reformat imports
0xnullifier Apr 13, 2026
efae95d
debug: include client_secret in refresh and log raw refresh token
0xnullifier Apr 13, 2026
5239e89
fix: canonicalize race, stale client, and missing auth keys after res…
0xnullifier Apr 13, 2026
28b0973
fix: auto-backup hooks register on all platforms; skip initial backup…
0xnullifier Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
Expand Down
3 changes: 3 additions & 0 deletions android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')

include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')

include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')

Expand Down
4 changes: 4 additions & 0 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6B2865F76F213517CFCCD1 /* AppViewController.swift */; };
B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */; };
C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */; };
D1A2B3C4E5F607890A1B2C3D /* PasskeyPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -36,6 +37,7 @@
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
BFB20C26958B0AB36D108D0E /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BarcodeScannerPlugin.swift; sourceTree = "<group>"; };
D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyPlugin.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -81,6 +83,7 @@
50B271D01FEDC1A000F3C39B /* public */,
21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */,
C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */,
D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */,
1A6B2865F76F213517CFCCD1 /* AppViewController.swift */,
873F0344C8952CB5585102E0 /* App.entitlements */,
);
Expand Down Expand Up @@ -180,6 +183,7 @@
B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */,
C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */,
83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */,
D1A2B3C4E5F607890A1B2C3D /* PasskeyPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
4 changes: 4 additions & 0 deletions ios/App/App/App.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
<array>
<string>$(AppIdentifierPrefix)com.miden.wallet</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:api.midenbrowserwallet.com</string>
</array>
</dict>
</plist>
1 change: 1 addition & 0 deletions ios/App/App/AppViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ class AppViewController: CAPBridgeViewController {
override open func capacitorDidLoad() {
bridge?.registerPluginInstance(LocalBiometricPlugin())
bridge?.registerPluginInstance(BarcodeScannerPlugin())
bridge?.registerPluginInstance(PasskeyPlugin())
}
}
9 changes: 9 additions & 0 deletions ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.849882985138-gbl44m5nmvuim6eiv4vmtg5rvoq4knqi</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
Expand Down
278 changes: 278 additions & 0 deletions ios/App/App/PasskeyPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import Foundation
import Capacitor
import AuthenticationServices
import CryptoKit
import os.log

private let logger = OSLog(subsystem: "com.miden.wallet", category: "Passkey")

/// Native Capacitor plugin for passkey operations using Apple's ASAuthorization API
/// with PRF (Pseudo-Random Function) extension support.
///
/// WKWebView's JavaScript WebAuthn bridge does not pass through the PRF extension,
/// so we bypass it entirely and call the native API directly.
///
/// Requires iOS 18.0+ for PRF support.
@objc(PasskeyPlugin)
public class PasskeyPlugin: CAPPlugin, CAPBridgedPlugin, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
public let identifier = "PasskeyPlugin"
public let jsName = "Passkey"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "register", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise)
]

// Strong reference to prevent ASAuthorizationController deallocation mid-flow
private var authController: ASAuthorizationController?
private var currentCall: CAPPluginCall?
private var isRegistration = false

// MARK: - ASAuthorizationControllerPresentationContextProviding

public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
return self.bridge?.viewController?.view.window ?? ASPresentationAnchor()
}

// MARK: - Plugin Methods

@objc func isAvailable(_ call: CAPPluginCall) {
os_log("[Passkey] isAvailable called", log: logger, type: .debug)
if #available(iOS 18.0, *) {
call.resolve(["available": true])
} else {
os_log("[Passkey] iOS 18.0+ required for PRF support", log: logger, type: .info)
call.resolve(["available": false])
}
}

@objc func register(_ call: CAPPluginCall) {
os_log("[Passkey] register called", log: logger, type: .debug)

guard #available(iOS 18.0, *) else {
call.reject("Passkey PRF requires iOS 18.0+")
return
}

guard let rpId = call.getString("rpId"),
let userName = call.getString("userName"),
let _ = call.getString("userDisplayName"),
let userIdBase64 = call.getString("userId"),
let challengeBase64 = call.getString("challenge"),
let prfSaltBase64 = call.getString("prfSalt") else {
call.reject("Missing required parameters")
return
}

guard let userId = Data(base64Encoded: userIdBase64),
let challenge = Data(base64Encoded: challengeBase64),
let prfSalt = Data(base64Encoded: prfSaltBase64) else {
call.reject("Invalid base64 encoding")
return
}

self.currentCall = call
self.isRegistration = true

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
let request = provider.createCredentialRegistrationRequest(
challenge: challenge,
name: userName,
userID: userId
)

// Attach PRF with salt so registration returns the PRF output directly.
let saltValues = ASAuthorizationPublicKeyCredentialPRFAssertionInput.InputValues(saltInput1: prfSalt)
request.prf = .inputValues(saltValues)

DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
self.authController = controller
controller.performRequests()
}
}

@objc func authenticate(_ call: CAPPluginCall) {
os_log("[Passkey] authenticate called", log: logger, type: .debug)

guard #available(iOS 18.0, *) else {
call.reject("Passkey PRF requires iOS 18.0+")
return
}

guard let rpId = call.getString("rpId"),
let credentialIdBase64 = call.getString("credentialId"),
let challengeBase64 = call.getString("challenge"),
let prfSaltBase64 = call.getString("prfSalt") else {
call.reject("Missing required parameters")
return
}

guard let credentialId = Data(base64Encoded: credentialIdBase64),
let challenge = Data(base64Encoded: challengeBase64),
let prfSalt = Data(base64Encoded: prfSaltBase64) else {
call.reject("Invalid base64 encoding")
return
}

self.currentCall = call
self.isRegistration = false

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
let request = provider.createCredentialAssertionRequest(challenge: challenge)

request.allowedCredentials = [
ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialId)
]

let saltValues = ASAuthorizationPublicKeyCredentialPRFAssertionInput.InputValues(saltInput1: prfSalt)
request.prf = .inputValues(saltValues)

DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
self.authController = controller
controller.performRequests()
}
}

// MARK: - ASAuthorizationControllerDelegate

public func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
os_log("[Passkey] Authorization completed", log: logger, type: .debug)

guard let call = currentCall else {
os_log("[Passkey] No pending call", log: logger, type: .error)
return
}

if #available(iOS 18.0, *) {
if let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
handleRegistrationResult(registration, call: call)
} else if let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
handleAssertionResult(assertion, call: call)
} else {
call.reject("Unexpected credential type")
cleanup()
}
} else {
call.reject("iOS 18.0+ required")
cleanup()
}
}

public func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: Error
) {
os_log("[Passkey] Authorization error: %{public}@", log: logger, type: .error, error.localizedDescription)

guard let call = currentCall else { return }

let nsError = error as NSError
if nsError.domain == ASAuthorizationError.errorDomain,
let code = ASAuthorizationError.Code(rawValue: nsError.code) {
switch code {
case .canceled:
call.reject("Passkey operation was cancelled", "CANCELLED")
case .failed:
call.reject("Passkey operation failed", "FAILED")
case .invalidResponse:
call.reject("Invalid response from authenticator", "INVALID_RESPONSE")
case .notHandled:
call.reject("Request not handled", "NOT_HANDLED")
case .notInteractive:
call.reject("Not interactive", "NOT_INTERACTIVE")
@unknown default:
call.reject("Authorization error: \(error.localizedDescription)")
}
} else {
call.reject("Passkey error: \(error.localizedDescription)")
}

cleanup()
}

// MARK: - Result Handlers

@available(iOS 18.0, *)
private func handleRegistrationResult(
_ registration: ASAuthorizationPlatformPublicKeyCredentialRegistration,
call: CAPPluginCall
) {
let credentialId = registration.credentialID
os_log("[Passkey] Registration succeeded, credentialId length: %d", log: logger, type: .debug, credentialId.count)

guard let prfOutput = registration.prf else {
os_log("[Passkey] No PRF output from registration", log: logger, type: .error)
call.reject("PRF extension not supported by this authenticator")
cleanup()
return
}

guard prfOutput.isSupported else {
os_log("[Passkey] PRF not supported by authenticator", log: logger, type: .error)
call.reject("PRF extension not supported by this authenticator")
cleanup()
return
}

guard let prfKey = prfOutput.first else {
os_log("[Passkey] PRF output has no first key", log: logger, type: .error)
call.reject("PRF output not available from registration")
cleanup()
return
}

let prfData = prfKey.withUnsafeBytes { Data(Array($0)) }
os_log("[Passkey] PRF output obtained from registration, length: %d", log: logger, type: .debug, prfData.count)

call.resolve([
"credentialId": credentialId.base64EncodedString(),
"prfOutput": prfData.base64EncodedString()
])

cleanup()
}

@available(iOS 18.0, *)
private func handleAssertionResult(
_ assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion,
call: CAPPluginCall
) {
os_log("[Passkey] Assertion completed", log: logger, type: .debug)

guard let prfResult = assertion.prf else {
os_log("[Passkey] No PRF output in assertion result", log: logger, type: .error)
call.reject("PRF output not available")
cleanup()
return
}

let prfData = prfResult.first.withUnsafeBytes { Data(Array($0)) }
os_log("[Passkey] PRF output obtained, length: %d", log: logger, type: .debug, prfData.count)

call.resolve([
"credentialId": assertion.credentialID.base64EncodedString(),
"prfOutput": prfData.base64EncodedString()
])

cleanup()
}

// MARK: - Cleanup

private func cleanup() {
currentCall = nil
authController = nil
isRegistration = false
}
}
2 changes: 2 additions & 0 deletions ios/App/CapApp-SPM/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.0.1"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorBrowser", path: "../../../node_modules/@capacitor/browser"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
Expand All @@ -29,6 +30,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBrowser", package: "CapacitorBrowser"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@
"build:devnet": "rimraf ./dist && yarn clear:webpack-cache && cross-env MIDEN_NETWORK=devnet DISABLE_TS_CHECKER=true NODE_ENV=development MODE_ENV=production MANIFEST_VERSION=3 webpack",
"dev:devnet": "cross-env MIDEN_NETWORK=devnet DISABLE_TS_CHECKER=true NODE_ENV=development MODE_ENV=development MANIFEST_VERSION=3 webpack --watch --progress",
"build:mobile:devnet": "rimraf ./dist/mobile && cross-env MIDEN_NETWORK=devnet DISABLE_TS_CHECKER=true NODE_ENV=development MODE_ENV=production webpack --config webpack.mobile.config.js",
"mobile:ios:devnet": "yarn build:mobile:devnet && npx cap sync ios && npx cap open ios",
"build:desktop:devnet": "rimraf ./dist/desktop && cross-env MIDEN_NETWORK=devnet DISABLE_TS_CHECKER=true NODE_ENV=development MODE_ENV=production webpack --config webpack.desktop.config.js",
"tauri": "tauri"
},
"dependencies": {
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
"@capacitor/browser": "^8.0.3",
"@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.0.0",
"@capacitor/haptics": "^8.0.0",
Expand Down
Loading
Loading