diff --git a/apps/mobile-app/app/(tabs)/settings/_layout.tsx b/apps/mobile-app/app/(tabs)/settings/_layout.tsx
index 12dbc4ba7..99dede09f 100644
--- a/apps/mobile-app/app/(tabs)/settings/_layout.tsx
+++ b/apps/mobile-app/app/(tabs)/settings/_layout.tsx
@@ -126,6 +126,13 @@ export default function SettingsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
+
);
}
\ No newline at end of file
diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx
index 792c0641a..e1cb8cd80 100644
--- a/apps/mobile-app/app/(tabs)/settings/index.tsx
+++ b/apps/mobile-app/app/(tabs)/settings/index.tsx
@@ -479,6 +479,19 @@ export default function SettingsScreen() : React.ReactNode {
+ router.push('/(tabs)/settings/qr-scanner')}
+ >
+
+
+
+
+ {t('settings.qrScanner.title')}
+
+
+
+
router.push('/(tabs)/settings/security')}
diff --git a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx
new file mode 100644
index 000000000..37ec16869
--- /dev/null
+++ b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx
@@ -0,0 +1,517 @@
+import { Ionicons } from '@expo/vector-icons';
+import { CameraView, useCameraPermissions } from 'expo-camera';
+import { router, useLocalSearchParams } from 'expo-router';
+import { useState, useEffect } from 'react';
+import { View, TouchableOpacity, Alert, StyleSheet, Platform } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { useColors } from '@/hooks/useColorScheme';
+import { useTranslation } from '@/hooks/useTranslation';
+
+import { ThemedButton } from '@/components/themed/ThemedButton';
+import { ThemedContainer } from '@/components/themed/ThemedContainer';
+import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
+import { ThemedText } from '@/components/themed/ThemedText';
+import { ThemedView } from '@/components/themed/ThemedView';
+import { useApp } from '@/context/AppContext';
+import { useWebApi } from '@/context/WebApiContext';
+import NativeVaultManager from '@/specs/NativeVaultManager';
+
+// QR Code type prefixes
+const QR_CODE_PREFIXES = {
+ MOBILE_UNLOCK: 'aliasvault://mobile-unlock/',
+ // Future: PASSKEY: 'aliasvault://passkey/',
+ // Future: SHARE_CREDENTIAL: 'aliasvault://share/',
+} as const;
+
+type QRCodeType = keyof typeof QR_CODE_PREFIXES;
+
+/**
+ * Scanned QR code data.
+ */
+interface ScannedQRCode {
+ type: QRCodeType | null;
+ payload: string;
+ rawData: string;
+}
+
+/**
+ * Parse QR code data and determine its type.
+ */
+function parseQRCode(data: string): ScannedQRCode {
+ for (const [type, prefix] of Object.entries(QR_CODE_PREFIXES)) {
+ if (data.startsWith(prefix)) {
+ return {
+ type: type as QRCodeType,
+ payload: data.substring(prefix.length),
+ rawData: data,
+ };
+ }
+ }
+ return { type: null, payload: data, rawData: data };
+}
+
+/**
+ * General QR code scanner screen for AliasVault.
+ */
+export default function QRScannerScreen() : React.ReactNode {
+ const colors = useColors();
+ const { t } = useTranslation();
+ const { username } = useApp();
+ const webApi = useWebApi();
+ const insets = useSafeAreaInsets();
+ const [permission, requestPermission] = useCameraPermissions();
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [scannedData, setScannedData] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const { url } = useLocalSearchParams<{ url?: string }>();
+
+ // Request camera permission on mount
+ useEffect(() => {
+ const requestCameraPermission = async () : Promise => {
+ if (!permission) {
+ return; // Still loading permission status
+ }
+
+ if (!permission.granted && permission.canAskAgain) {
+ // Request permission
+ await requestPermission();
+ } else if (!permission.granted && !permission.canAskAgain) {
+ // Permission was permanently denied
+ Alert.alert(
+ t('settings.qrScanner.cameraPermissionTitle'),
+ t('settings.qrScanner.cameraPermissionMessage'),
+ [{ text: t('common.ok'), onPress: () => router.back() }]
+ );
+ }
+ };
+
+ requestCameraPermission();
+ }, [permission?.granted]);
+
+ // Handle QR code URL passed from deep link (e.g., from native camera)
+ useEffect(() => {
+ if (url && typeof url === 'string') {
+ handleBarcodeScanned({ data: url });
+ }
+ }, [url]);
+
+ /**
+ * Handle mobile unlock QR code.
+ */
+ const handleMobileUnlock = async (requestId: string) : Promise => {
+ try {
+ // Fetch the public key from server
+ const response = await webApi.authFetch<{ clientPublicKey: string }>(
+ `auth/mobile-unlock/request/${requestId}`,
+ { method: 'GET' }
+ );
+
+ const publicKeyJWK = response.clientPublicKey;
+
+ // Encrypt the decryption key using native module
+ // This ensures the decryption key never touches React Native code
+ const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileUnlock(publicKeyJWK);
+
+ // Submit the encrypted key to the server
+ await webApi.authFetch(
+ 'auth/mobile-unlock/submit',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ requestId,
+ encryptedDecryptionKey: encryptedKey,
+ username: username,
+ }),
+ }
+ );
+
+ // Success!
+ setSuccessMessage(t('settings.qrScanner.mobileUnlock.successDescription'));
+ } catch (error) {
+ console.error('Mobile unlock error:', error);
+ let errorMsg = t('settings.qrScanner.mobileUnlock.genericError');
+
+ if (error instanceof Error) {
+ if (error.message.includes('ENCRYPTION_ERROR')) {
+ errorMsg = t('settings.qrScanner.mobileUnlock.vaultLocked');
+ } else if (error.message.includes('404')) {
+ errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired');
+ } else if (error.message.includes('401') || error.message.includes('403')) {
+ errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized');
+ }
+ }
+
+ setErrorMessage(errorMsg);
+ }
+ };
+
+ /**
+ * Handle barcode scanned - show confirmation screen.
+ */
+ const handleBarcodeScanned = ({ data }: { data: string }) : void => {
+ // Prevent multiple scans
+ if (scannedData) {
+ return;
+ }
+
+ // Parse the QR code to determine its type
+ const parsedData = parseQRCode(data);
+
+ if (!parsedData.type) {
+ Alert.alert(
+ t('settings.qrScanner.invalidQrCode'),
+ t('settings.qrScanner.notAliasVaultQr'),
+ [{ text: t('common.ok'), onPress: () => router.back() }]
+ );
+ return;
+ }
+
+ // Show confirmation screen
+ setScannedData(parsedData);
+ };
+
+ /**
+ * Handle confirmation - authenticate user first, then process the scanned QR code.
+ */
+ const handleConfirm = async () : Promise => {
+ if (!scannedData) {
+ return;
+ }
+
+ setIsProcessing(true);
+
+ try {
+ let authenticated = false;
+
+ // Check which authentication method is available
+ const pinEnabled = await NativeVaultManager.isPinEnabled();
+
+ if (pinEnabled) {
+ // PIN is enabled, use PIN unlock
+ try {
+ await NativeVaultManager.showPinUnlock();
+ authenticated = true;
+ } catch (pinError: any) {
+ // User cancelled PIN or PIN failed
+ console.log('PIN unlock cancelled or failed:', pinError);
+ Alert.alert(
+ t('common.error'),
+ t('settings.qrScanner.mobileUnlock.authenticationFailed')
+ );
+ setIsProcessing(false);
+ return;
+ }
+ } else {
+ // Try biometric authentication
+ try {
+ authenticated = await NativeVaultManager.authenticateUser(
+ t('settings.qrScanner.mobileUnlock.authenticationRequired')
+ );
+ } catch (authError: any) {
+ console.error('Biometric authentication error:', authError);
+ Alert.alert(
+ t('common.error'),
+ t('settings.qrScanner.mobileUnlock.authenticationFailed')
+ );
+ setIsProcessing(false);
+ return;
+ }
+ }
+
+ if (!authenticated) {
+ Alert.alert(
+ t('common.error'),
+ t('settings.qrScanner.mobileUnlock.authenticationFailed')
+ );
+ setIsProcessing(false);
+ return;
+ }
+
+ // Route to appropriate handler based on type
+ switch (scannedData.type) {
+ case 'MOBILE_UNLOCK':
+ await handleMobileUnlock(scannedData.payload);
+ break;
+ // Future cases:
+ // case 'PASSKEY':
+ // await handlePasskey(scannedData.payload);
+ // break;
+ default:
+ Alert.alert(
+ t('common.error'),
+ t('settings.qrScanner.unsupportedQrType')
+ );
+ }
+ } catch (error) {
+ console.error('QR code processing error:', error);
+ Alert.alert(
+ t('common.error'),
+ error instanceof Error ? error.message : t('common.errors.unknownError')
+ );
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ /**
+ * Handle cancel - go back to scanning.
+ */
+ const handleCancel = () : void => {
+ setScannedData(null);
+ setSuccessMessage(null);
+ setErrorMessage(null);
+ };
+
+ /**
+ * Handle dismiss - go back to settings.
+ */
+ const handleDismiss = () : void => {
+ router.back();
+ };
+
+ const styles = StyleSheet.create({
+ camera: {
+ flex: 1,
+ },
+ cameraContainer: {
+ backgroundColor: colors.black,
+ flex: 1,
+ },
+ cameraOverlay: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ bottom: 0,
+ justifyContent: 'center',
+ left: 0,
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ },
+ cameraOverlayText: {
+ color: colors.white,
+ fontSize: 16,
+ marginTop: 20,
+ paddingHorizontal: 40,
+ textAlign: 'center',
+ },
+ closeButton: {
+ position: 'absolute',
+ right: 16,
+ top: 16,
+ zIndex: 10,
+ },
+ confirmationContainer: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ padding: 20,
+ },
+ confirmationTitle: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ marginBottom: 20,
+ textAlign: 'center',
+ },
+ confirmationText: {
+ fontSize: 16,
+ lineHeight: 24,
+ marginBottom: 12,
+ textAlign: 'center',
+ },
+ buttonContainer: {
+ gap: 12,
+ marginTop: 20,
+ paddingBottom: insets.bottom + 80,
+ paddingHorizontal: 20,
+ },
+ button: {
+ width: '100%',
+ },
+ cancelButton: {
+ backgroundColor: colors.secondary,
+ },
+ successContainer: {
+ alignItems: 'center',
+ backgroundColor: colors.success + '10',
+ borderColor: colors.success,
+ borderRadius: 12,
+ borderWidth: 2,
+ marginBottom: 20,
+ padding: 20,
+ },
+ successIcon: {
+ marginBottom: 16,
+ },
+ successTitle: {
+ color: colors.success,
+ fontSize: 20,
+ fontWeight: 'bold',
+ marginBottom: 8,
+ textAlign: 'center',
+ },
+ successText: {
+ fontSize: 14,
+ lineHeight: 20,
+ textAlign: 'center',
+ },
+ errorContainer: {
+ alignItems: 'center',
+ backgroundColor: colors.destructive + '10',
+ borderColor: colors.destructive,
+ borderRadius: 12,
+ borderWidth: 2,
+ marginBottom: 20,
+ padding: 20,
+ },
+ errorIcon: {
+ marginBottom: 16,
+ },
+ errorTitle: {
+ color: colors.destructive,
+ fontSize: 20,
+ fontWeight: 'bold',
+ marginBottom: 8,
+ textAlign: 'center',
+ },
+ errorText: {
+ fontSize: 14,
+ lineHeight: 20,
+ textAlign: 'center',
+ },
+ });
+
+ // Show confirmation/success/error screen after scanning
+ if (scannedData || successMessage || errorMessage) {
+ return (
+
+
+
+ {successMessage && (
+
+
+
+ {t('settings.qrScanner.mobileUnlock.successTitle')}
+
+
+ {successMessage}
+
+
+ )}
+
+ {errorMessage && (
+
+
+
+ {t('common.error')}
+
+
+ {errorMessage}
+
+
+ )}
+
+ {!successMessage && !errorMessage && scannedData?.type === 'MOBILE_UNLOCK' && (
+ <>
+
+ {t('settings.qrScanner.mobileUnlock.confirmTitle')}
+
+
+ {t('settings.qrScanner.mobileUnlock.confirmMessage', { username })}
+
+ >
+ )}
+
+
+
+ {(successMessage || errorMessage) ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
+ }
+
+ // Show loading or permission denied screen
+ if (!permission || !permission.granted) {
+ return (
+
+
+ {permission && !permission.granted && (
+ <>
+
+ {t('settings.qrScanner.cameraPermissionTitle')}
+
+
+ {t('settings.qrScanner.cameraPermissionMessage')}
+
+ >
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+ router.back()}
+ >
+
+
+
+
+
+
+ {t('settings.qrScanner.scanningMessage')}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx
index b4be22443..856938666 100644
--- a/apps/mobile-app/app/_layout.tsx
+++ b/apps/mobile-app/app/_layout.tsx
@@ -59,6 +59,37 @@ function RootLayoutNav() : React.ReactNode {
}, []);
useEffect(() => {
+ /**
+ * Handle deep link URL by navigating to the appropriate route.
+ */
+ const handleDeepLink = (url: string) : void => {
+ // Remove all supported URL schemes to get the path
+ let path = url
+ .replace('net.aliasvault.app://', '')
+ .replace('aliasvault://', '')
+ .replace('exp+aliasvault://', '');
+
+ // Handle mobile unlock QR code scans from native camera
+ if (path.startsWith('mobile-unlock/')) {
+ // Process the QR code directly by simulating what the scanner would do
+ // Since we already have the URL from the camera, we can process it immediately
+ router.push(`/(tabs)/settings/qr-scanner?url=${encodeURIComponent(`aliasvault://${path}`)}` as Href);
+ return;
+ }
+
+ // Handle credential detail routes
+ const isDetailRoute = path.includes('credentials/');
+ if (isDetailRoute) {
+ // First go to the credentials tab.
+ router.replace('/(tabs)/credentials');
+
+ // Then push the target route inside the credentials tab.
+ setTimeout(() => {
+ router.push(path as Href);
+ }, 0);
+ }
+ };
+
/**
* Redirect to a explicit target page if we have one (in case of non-happy path).
* Otherwise check for a deep link and simulate stack navigation.
@@ -73,29 +104,24 @@ function RootLayoutNav() : React.ReactNode {
// If we have an explicit redirect target, we navigate to it. This overrides potential deep link handling.
router.replace(redirectTarget as Href);
} else {
- // Check if we have an initial URL to handle (deep link from most likely the autofill extension).
+ // Check if we have an initial URL to handle (deep link from camera, autofill, etc.).
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
- /**
- * Check for certain supported deep link routes, and if found, ensure we simulate the stack navigation
- * as otherwise the "back" button for navigation will not work as expected.
- */
- const path = initialUrl.replace('net.aliasvault.app://', '');
- const isDetailRoute = path.includes('credentials/');
- if (isDetailRoute) {
- // First go to the credentials tab.
- router.replace('/(tabs)/credentials');
-
- // Then push the target route inside the credentials tab.
- setTimeout(() => {
- router.push(path as Href);
- }, 0);
- }
+ handleDeepLink(initialUrl);
}
}
};
redirect();
+
+ // Listen for deep links when app is already running
+ const subscription = Linking.addEventListener('url', ({ url }) => {
+ handleDeepLink(url);
+ });
+
+ return () => {
+ subscription.remove();
+ };
}, [bootComplete, redirectTarget, router]);
const styles = StyleSheet.create({
diff --git a/apps/mobile-app/constants/Colors.ts b/apps/mobile-app/constants/Colors.ts
index d61652227..c95c0413c 100644
--- a/apps/mobile-app/constants/Colors.ts
+++ b/apps/mobile-app/constants/Colors.ts
@@ -5,6 +5,7 @@
export const Colors = {
light: {
+ white: '#ffffff',
text: '#11181C',
textMuted: '#4b5563',
background: '#f3f2f7',
diff --git a/apps/mobile-app/ios/AliasVault/Info.plist b/apps/mobile-app/ios/AliasVault/Info.plist
index 2c69d78f1..2da9ca860 100644
--- a/apps/mobile-app/ios/AliasVault/Info.plist
+++ b/apps/mobile-app/ios/AliasVault/Info.plist
@@ -46,7 +46,14 @@
CFBundleURLSchemes
- net.aliasvault.app
+ aliasvault
+
+ CFBundleURLName
+ net.aliasvault.app
+
+
+ CFBundleURLSchemes
+
net.aliasvault.app
diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
index 7e4122cc4..cf90d24d0 100644
--- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
+++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm
@@ -273,4 +273,16 @@
[vaultManager showPinSetup:resolve rejecter:reject];
}
+// MARK: - Mobile Unlock
+
+- (void)encryptDecryptionKeyForMobileUnlock:(NSString *)publicKeyJWK resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
+ [vaultManager encryptDecryptionKeyForMobileUnlock:publicKeyJWK resolver:resolve rejecter:reject];
+}
+
+// MARK: - Re-authentication
+
+- (void)authenticateUser:(NSString *)reason resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
+ [vaultManager authenticateUser:reason resolver:resolve rejecter:reject];
+}
+
@end
diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
index 579136f5e..7ef396121 100644
--- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
+++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift
@@ -887,6 +887,34 @@ public class VaultManager: NSObject {
}
}
+ @objc
+ func encryptDecryptionKeyForMobileUnlock(_ publicKeyJWK: String,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ do {
+ // Get the encryption key and encrypt it with the provided public key
+ let encryptedData = try vaultStore.encryptDecryptionKeyForMobileUnlock(publicKeyJWK: publicKeyJWK)
+
+ // Return the encrypted data as base64 string
+ let base64Encrypted = encryptedData.base64EncodedString()
+ resolve(base64Encrypted)
+ } catch {
+ reject("ENCRYPTION_ERROR", "Failed to encrypt decryption key: \(error.localizedDescription)", error)
+ }
+ }
+
+ @objc
+ func authenticateUser(_ reason: String,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ do {
+ let authenticated = try vaultStore.authenticateUser(reason: reason)
+ resolve(authenticated)
+ } catch {
+ reject("AUTH_ERROR", "Authentication failed: \(error.localizedDescription)", error)
+ }
+ }
+
@objc
func requiresMainQueueSetup() -> Bool {
return false
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
index e6f800a95..e500974eb 100644
--- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
@@ -41,4 +41,49 @@ extension VaultStore {
public func getAuthMethods() -> AuthMethods {
return self.enabledAuthMethods
}
+
+ /// Authenticate the user using biometric authentication
+ /// Returns true if authentication succeeded, false otherwise
+ public func authenticateUser(reason: String) throws -> Bool {
+ // Check if biometric authentication is enabled
+ guard self.enabledAuthMethods.contains(.faceID) else {
+ print("Biometric authentication not enabled")
+ throw NSError(
+ domain: "VaultStore",
+ code: 100,
+ userInfo: [NSLocalizedDescriptionKey: "Biometric authentication not enabled"]
+ )
+ }
+
+ let context = LAContext()
+ var error: NSError?
+
+ // Check if biometric authentication is available
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
+ print("Biometric authentication not available: \(error?.localizedDescription ?? "unknown error")")
+ throw NSError(
+ domain: "VaultStore",
+ code: 100,
+ userInfo: [NSLocalizedDescriptionKey: "Biometric authentication not available"]
+ )
+ }
+
+ // Perform biometric authentication synchronously
+ var authenticated = false
+ let semaphore = DispatchSemaphore(value: 0)
+
+ context.evaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ localizedReason: reason
+ ) { success, authError in
+ authenticated = success
+ if let authError = authError {
+ print("Biometric authentication failed: \(authError.localizedDescription)")
+ }
+ semaphore.signal()
+ }
+
+ semaphore.wait()
+ return authenticated
+ }
}
diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+PublicKeyCrypto.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+PublicKeyCrypto.swift
new file mode 100644
index 000000000..f1a253700
--- /dev/null
+++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+PublicKeyCrypto.swift
@@ -0,0 +1,188 @@
+import Foundation
+import Security
+
+/// Extension for the VaultStore class to handle RSA public key encryption
+extension VaultStore {
+ /// Encrypts the vault's encryption key using an RSA public key for mobile unlock
+ /// This method gets the internal encryption key and encrypts it with the provided public key
+ /// - Parameter publicKeyJWK: The RSA public key in JWK format (JSON string)
+ /// - Returns: The encrypted encryption key
+ public func encryptDecryptionKeyForMobileUnlock(publicKeyJWK: String) throws -> Data {
+ // Get the current encryption key from the vault store
+ // This will only work if the vault is unlocked (encryption key is in memory)
+ let encryptionKey = try getEncryptionKey()
+
+ // Encrypt the encryption key with the provided public key
+ return try encryptWithPublicKey(data: encryptionKey, publicKeyJWK: publicKeyJWK)
+ }
+
+ /// Encrypts data using an RSA public key
+ /// - Parameters:
+ /// - data: The data to encrypt
+ /// - publicKeyBase64: The RSA public key in JWK format (JSON string, base64 encoded after conversion)
+ /// - Returns: The encrypted data
+ internal func encryptWithPublicKey(data: Data, publicKeyJWK: String) throws -> Data {
+ // Parse the JWK JSON
+ guard let jwkData = publicKeyJWK.data(using: .utf8),
+ let jwk = try? JSONSerialization.jsonObject(with: jwkData) as? [String: Any],
+ let modulusB64 = jwk["n"] as? String,
+ let exponentB64 = jwk["e"] as? String else {
+ throw NSError(domain: "VaultStore", code: 100, userInfo: [NSLocalizedDescriptionKey: "Invalid JWK format"])
+ }
+
+ // Decode modulus and exponent from base64url
+ guard let modulusData = base64UrlDecode(modulusB64),
+ let exponentData = base64UrlDecode(exponentB64) else {
+ throw NSError(domain: "VaultStore", code: 101, userInfo: [NSLocalizedDescriptionKey: "Failed to decode JWK components"])
+ }
+
+ // Create RSA public key
+ let publicKey = try createPublicKey(modulus: modulusData, exponent: exponentData)
+
+ // Encrypt the data using RSA-OAEP with SHA-256
+ var error: Unmanaged?
+ guard let encryptedData = SecKeyCreateEncryptedData(
+ publicKey,
+ .rsaEncryptionOAEPSHA256,
+ data as CFData,
+ &error
+ ) as Data? else {
+ let errorDescription = error?.takeRetainedValue().localizedDescription ?? "Unknown error"
+ throw NSError(domain: "VaultStore", code: 102, userInfo: [NSLocalizedDescriptionKey: "RSA encryption failed: \(errorDescription)"])
+ }
+
+ return encryptedData
+ }
+
+ /// Creates an RSA public key from modulus and exponent
+ private func createPublicKey(modulus: Data, exponent: Data) throws -> SecKey {
+ // Create the key attributes
+ let keyDict: [String: Any] = [
+ kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
+ kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
+ kSecAttrKeySizeInBits as String: modulus.count * 8
+ ]
+
+ // Create the public key data in ASN.1 format
+ let publicKeyData = createPublicKeyASN1(modulus: modulus, exponent: exponent)
+
+ var error: Unmanaged?
+ guard let publicKey = SecKeyCreateWithData(
+ publicKeyData as CFData,
+ keyDict as CFDictionary,
+ &error
+ ) else {
+ let errorDescription = error?.takeRetainedValue().localizedDescription ?? "Unknown error"
+ throw NSError(domain: "VaultStore", code: 103, userInfo: [NSLocalizedDescriptionKey: "Failed to create public key: \(errorDescription)"])
+ }
+
+ return publicKey
+ }
+
+ /// Creates ASN.1 DER encoded public key data from modulus and exponent
+ private func createPublicKeyASN1(modulus: Data, exponent: Data) -> Data {
+ // RSA Public Key ASN.1 structure:
+ // SEQUENCE {
+ // SEQUENCE {
+ // OBJECT IDENTIFIER rsaEncryption
+ // NULL
+ // }
+ // BIT STRING {
+ // SEQUENCE {
+ // INTEGER modulus
+ // INTEGER exponent
+ // }
+ // }
+ // }
+
+ var result = Data()
+
+ // Inner sequence: modulus and exponent
+ let modulusEncoded = encodeASN1Integer(modulus)
+ let exponentEncoded = encodeASN1Integer(exponent)
+ let innerSequence = encodeASN1Sequence(modulusEncoded + exponentEncoded)
+
+ // Bit string containing the inner sequence
+ let bitString = encodeASN1BitString(innerSequence)
+
+ // Algorithm identifier sequence
+ let algorithmOID = Data([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]) // rsaEncryption OID
+ let algorithmNull = Data([0x05, 0x00])
+ let algorithmSequence = encodeASN1Sequence(algorithmOID + algorithmNull)
+
+ // Outer sequence
+ result = encodeASN1Sequence(algorithmSequence + bitString)
+
+ return result
+ }
+
+ /// Encodes data as ASN.1 SEQUENCE
+ private func encodeASN1Sequence(_ data: Data) -> Data {
+ return encodeASN1(tag: 0x30, data: data)
+ }
+
+ /// Encodes data as ASN.1 INTEGER
+ private func encodeASN1Integer(_ data: Data) -> Data {
+ var integerData = data
+
+ // Remove leading zeros
+ while integerData.count > 1 && integerData[0] == 0 {
+ integerData = integerData.dropFirst()
+ }
+
+ // Add padding byte if the high bit is set (to keep it positive)
+ if let firstByte = integerData.first, firstByte >= 0x80 {
+ integerData.insert(0x00, at: 0)
+ }
+
+ return encodeASN1(tag: 0x02, data: integerData)
+ }
+
+ /// Encodes data as ASN.1 BIT STRING
+ private func encodeASN1BitString(_ data: Data) -> Data {
+ var bitStringData = Data([0x00]) // No unused bits
+ bitStringData.append(data)
+ return encodeASN1(tag: 0x03, data: bitStringData)
+ }
+
+ /// Encodes data with ASN.1 tag and length
+ private func encodeASN1(tag: UInt8, data: Data) -> Data {
+ var result = Data([tag])
+ result.append(encodeASN1Length(data.count))
+ result.append(data)
+ return result
+ }
+
+ /// Encodes length in ASN.1 format
+ private func encodeASN1Length(_ length: Int) -> Data {
+ if length < 128 {
+ return Data([UInt8(length)])
+ }
+
+ var lengthBytes = Data()
+ var len = length
+ while len > 0 {
+ lengthBytes.insert(UInt8(len & 0xFF), at: 0)
+ len >>= 8
+ }
+
+ var result = Data([UInt8(0x80 | lengthBytes.count)])
+ result.append(lengthBytes)
+ return result
+ }
+
+ /// Decodes base64url string to Data
+ private func base64UrlDecode(_ base64url: String) -> Data? {
+ var base64 = base64url
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+
+ // Add padding if needed
+ let remainder = base64.count % 4
+ if remainder > 0 {
+ base64 += String(repeating: "=", count: 4 - remainder)
+ }
+
+ return Data(base64Encoded: base64)
+ }
+}
diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts
index 4ae8fe30f..cfa4846e4 100644
--- a/apps/mobile-app/specs/NativeVaultManager.ts
+++ b/apps/mobile-app/specs/NativeVaultManager.ts
@@ -95,6 +95,12 @@ export interface Spec extends TurboModule {
removeAndDisablePin(): Promise;
showPinUnlock(): Promise;
showPinSetup(): Promise;
+
+ // Mobile unlock methods
+ encryptDecryptionKeyForMobileUnlock(publicKeyJWK: string): Promise;
+
+ // Re-authentication methods
+ authenticateUser(reason: string): Promise;
}
export default TurboModuleRegistry.getEnforcing('NativeVaultManager');
diff --git a/apps/mobile-app/utils/dist/shared/models/webapi/index.d.ts b/apps/mobile-app/utils/dist/shared/models/webapi/index.d.ts
index 37b362306..8757ef03f 100644
--- a/apps/mobile-app/utils/dist/shared/models/webapi/index.d.ts
+++ b/apps/mobile-app/utils/dist/shared/models/webapi/index.d.ts
@@ -103,15 +103,19 @@ type ValidateLoginRequest2Fa = {
clientPublicEphemeral: string;
clientSessionProof: string;
};
+/**
+ * Token model type.
+ */
+type TokenModel = {
+ token: string;
+ refreshToken: string;
+};
/**
* Validate login response type.
*/
type ValidateLoginResponse = {
requiresTwoFactor: boolean;
- token?: {
- token: string;
- refreshToken: string;
- };
+ token?: TokenModel;
serverSessionProof: string;
};
@@ -380,4 +384,37 @@ declare enum AuthEventType {
AccountDeletion = 99
}
-export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };
+/**
+ * Mobile unlock initiate request type.
+ */
+type MobileUnlockInitiateRequest = {
+ clientPublicKey: string;
+};
+/**
+ * Mobile unlock initiate response type.
+ */
+type MobileUnlockInitiateResponse = {
+ requestId: string;
+};
+/**
+ * Mobile unlock submit request type.
+ */
+type MobileUnlockSubmitRequest = {
+ requestId: string;
+ encryptedDecryptionKey: string;
+ username: string;
+};
+/**
+ * Mobile unlock poll response type.
+ */
+type MobileUnlockPollResponse = {
+ fulfilled: boolean;
+ encryptedDecryptionKey: string | null;
+ username: string | null;
+ token: TokenModel | null;
+ salt: string | null;
+ encryptionType: string | null;
+ encryptionSettings: string | null;
+};
+
+export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type MobileUnlockInitiateRequest, type MobileUnlockInitiateResponse, type MobileUnlockPollResponse, type MobileUnlockSubmitRequest, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type TokenModel, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };