From 804cb2a9de7cdb90080095350b1c033aea3da14e Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 16 Feb 2026 18:12:26 +0100 Subject: [PATCH] Add public key verification to mobile unlock flow (#1717) --- .../components/Dialogs/MobileUnlockModal.tsx | 9 +++-- .../popup/utils/MobileLoginUtility.ts | 28 +++++++++++++-- .../(tabs)/settings/mobile-unlock/[id].tsx | 34 ++++++++++++++----- .../(tabs)/settings/mobile-unlock/result.tsx | 11 +++++- .../app/(tabs)/settings/qr-scanner.tsx | 31 +++++++++++++++-- apps/mobile-app/utils/EncryptionUtility.ts | 13 +++++++ .../Auth/Components/MobileUnlockModal.razor | 7 ++-- .../Auth/Services/MobileLoginUtility.cs | 31 ++++++++++++++--- 8 files changed, 139 insertions(+), 25 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx index 398376d47..9541367a3 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx @@ -91,10 +91,13 @@ const MobileUnlockModal: React.FC = ({ } // Initiate mobile login and get QR code data - const requestId = await mobileLoginRef.current.initiate(); + const { requestId, publicKeyHash } = await mobileLoginRef.current.initiate(); - // Generate QR code with AliasVault prefix for mobile login - const qrData = `aliasvault://open/mobile-unlock/${requestId}`; + /* + * Generate QR code with AliasVault prefix for mobile login. + * Include public key hash as query parameter for security verification. + */ + const qrData = `aliasvault://open/mobile-unlock/${requestId}?pk=${publicKeyHash}`; const qrDataUrl = await QRCode.toDataURL(qrData, { width: 256, margin: 2, diff --git a/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts b/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts index c48fb66d8..249a4c281 100644 --- a/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts +++ b/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts @@ -15,6 +15,7 @@ export class MobileLoginUtility { private pollingInterval: NodeJS.Timeout | null = null; private requestId: string | null = null; private privateKey: string | null = null; + private publicKeyHash: string | null = null; /** * Constructor for the MobileLoginUtility class. @@ -25,16 +26,33 @@ export class MobileLoginUtility { this.webApi = webApi; } + /** + * Computes a SHA-256 hash of the public key and returns the first 16 characters. + */ + private async computePublicKeyHash(publicKey: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(publicKey); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + // Return first 16 characters for a compact but secure fingerprint + return hashHex.substring(0, 16); + } + /** * Initiates a mobile login request and returns the QR code data + * @returns Object containing requestId and publicKeyHash for QR code generation * @throws {MobileLoginErrorCode} If initiation fails */ - public async initiate(): Promise { + public async initiate(): Promise<{ requestId: string; publicKeyHash: string }> { try { // Generate RSA key pair const keyPair = await EncryptionUtility.generateRsaKeyPair(); this.privateKey = keyPair.privateKey; + // Compute hash of public key for QR code binding + this.publicKeyHash = await this.computePublicKeyHash(keyPair.publicKey); + // Send public key to server (no auth required) const response = await this.webApi.rawFetch('auth/mobile-login/initiate', { method: 'POST', @@ -53,8 +71,11 @@ export class MobileLoginUtility { const data = await response.json() as MobileLoginInitiateResponse; this.requestId = data.requestId; - // Return QR code data (request ID) - return this.requestId; + // Return QR code data (request ID and public key hash) + return { + requestId: this.requestId, + publicKeyHash: this.publicKeyHash, + }; } catch (error) { if (typeof error === 'string' && Object.values(MobileLoginErrorCode).includes(error as MobileLoginErrorCode)) { throw error; @@ -197,5 +218,6 @@ export class MobileLoginUtility { this.stopPolling(); this.privateKey = null; this.requestId = null; + this.publicKeyHash = null; } } diff --git a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx index cbf3c66ee..64d6a9f8f 100644 --- a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx +++ b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { View, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import EncryptionUtility from '@/utils/EncryptionUtility'; import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper'; import { useColors } from '@/hooks/useColorScheme'; @@ -27,8 +28,7 @@ export default function MobileUnlockConfirmScreen() : React.ReactNode { const { showAlert } = useDialog(); const webApi = useWebApi(); const insets = useSafeAreaInsets(); - const { id } = useLocalSearchParams(); - + const { id, pk } = useLocalSearchParams<{ id: string; pk?: string }>(); const [isProcessing, setIsProcessing] = useState(false); const [isValidating, setIsValidating] = useState(true); @@ -79,17 +79,34 @@ export default function MobileUnlockConfirmScreen() : React.ReactNode { /** * Handle mobile login QR code. + * @param requestId - The request ID from the QR code + * @param expectedPublicKeyHash - Optional public key hash from QR code for security verification */ - const handleMobileLogin = async (id: string) : Promise => { + const handleMobileLogin = async (requestId: string, expectedPublicKeyHash?: string) : Promise => { try { // Fetch the public key from server const response = await webApi.authFetch<{ clientPublicKey: string }>( - `auth/mobile-login/request/${id}`, + `auth/mobile-login/request/${requestId}`, { method: 'GET' } ); const publicKeyJWK = response.clientPublicKey; + /* + * Security verification: if QR code contained a public key hash, verify it matches what + * the server provided. + */ + if (expectedPublicKeyHash) { + const computedHash = await EncryptionUtility.computeSha256Hash(publicKeyJWK); + if (computedHash !== expectedPublicKeyHash) { + console.error('Public key hash mismatch - possible security attack'); + throw new Error('PUBLIC_KEY_MISMATCH'); + } + } + /* + * TODO: In v1.0+, remove backwards compatibility and require public key hash verification + * for all QR codes. + */ // Encrypt the decryption key using native module const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileLogin(publicKeyJWK); @@ -102,7 +119,7 @@ export default function MobileUnlockConfirmScreen() : React.ReactNode { 'Content-Type': 'application/json', }, body: JSON.stringify({ - requestId: id, + requestId: requestId, encryptedDecryptionKey: encryptedKey, }), } @@ -118,14 +135,13 @@ export default function MobileUnlockConfirmScreen() : React.ReactNode { }); } catch (error) { console.error('Mobile login error:', error); - let errorMsg = t('common.errors.unknownErrorTryAgain'); // Error! Navigate to result page router.replace({ pathname: '/(tabs)/settings/mobile-unlock/result', params: { success: 'false', - message: errorMsg, + message: t('common.errors.unknownErrorTryAgain'), }, }); } @@ -153,8 +169,8 @@ export default function MobileUnlockConfirmScreen() : React.ReactNode { return; } - // Process the mobile login - await handleMobileLogin(id as string); + // Process the mobile login with optional public key hash verification + await handleMobileLogin(id as string, pk); } catch (error) { console.error('Authentication or QR code processing error:', error); showAlert(t('common.error'), error instanceof Error ? error.message : t('common.errors.unknownError')); diff --git a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx index e6f21cdee..f91c8c587 100644 --- a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx +++ b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; -import { router, useLocalSearchParams } from 'expo-router'; +import { router, useLocalSearchParams, useNavigation } from 'expo-router'; +import { useEffect } from 'react'; import { View, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -17,11 +18,19 @@ import { ThemedText } from '@/components/themed/ThemedText'; export default function MobileUnlockResultScreen() : React.ReactNode { const colors = useColors(); const { t } = useTranslation(); + const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { success, message } = useLocalSearchParams<{ success: string; message?: string }>(); const isSuccess = success === 'true'; + // Set dynamic header title based on success/error state + useEffect(() => { + navigation.setOptions({ + title: isSuccess ? t('common.success') : t('common.error'), + }); + }, [navigation, isSuccess, t]); + /** * Handle dismiss - navigate to settings tab. * Uses replace to handle cases where this page is reached via deep link navigation. diff --git a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx index ac7985a14..8e003d908 100644 --- a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx +++ b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx @@ -30,18 +30,41 @@ type ScannedQRCode = { type: QRCodeType | null; payload: string; rawData: string; + /** Public key hash from QR code for security verification (if present) */ + publicKeyHash?: string; } /** * Parse QR code data and determine its type. + * Extracts public key hash from query parameter if present for security verification. */ function parseQRCode(data: string): ScannedQRCode { for (const [type, prefix] of Object.entries(QR_CODE_PREFIXES)) { if (data.startsWith(prefix)) { + const afterPrefix = data.substring(prefix.length); + + /* + * Parse the path and query parameters + * Format: {requestId} or {requestId}?pk={hash} + */ + const queryIndex = afterPrefix.indexOf('?'); + let payload: string; + let publicKeyHash: string | undefined; + + if (queryIndex !== -1) { + payload = afterPrefix.substring(0, queryIndex); + const queryString = afterPrefix.substring(queryIndex + 1); + const params = new URLSearchParams(queryString); + publicKeyHash = params.get('pk') ?? undefined; + } else { + payload = afterPrefix; + } + return { type: type as QRCodeType, - payload: data.substring(prefix.length), + payload, rawData: data, + publicKeyHash, }; } } @@ -83,7 +106,11 @@ export default function QRScannerScreen() : React.ReactNode { * This creates a smoother transition without returning to settings first */ if (parsedData.type === 'MOBILE_UNLOCK') { - router.push(`/(tabs)/settings/mobile-unlock/${parsedData.payload}` as Href); + // Pass public key hash as query parameter if present for security verification + const route = parsedData.publicKeyHash + ? `/(tabs)/settings/mobile-unlock/${parsedData.payload}?pk=${parsedData.publicKeyHash}` + : `/(tabs)/settings/mobile-unlock/${parsedData.payload}`; + router.push(route as Href); } }, []); diff --git a/apps/mobile-app/utils/EncryptionUtility.ts b/apps/mobile-app/utils/EncryptionUtility.ts index bb7683a40..bac15fe1a 100644 --- a/apps/mobile-app/utils/EncryptionUtility.ts +++ b/apps/mobile-app/utils/EncryptionUtility.ts @@ -333,6 +333,19 @@ class EncryptionUtility { throw new Error(err instanceof Error ? err.message : 'Failed to decrypt attachment'); } } + + /** + * Computes a SHA-256 hash of a string and returns the first 16 characters of the hex digest. + * Used for verifying public key integrity in mobile login QR codes. + */ + public static async computeSha256Hash(data: string): Promise { + const encoder = new TextEncoder(); + const dataBytes = encoder.encode(data); + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex.substring(0, 16); + } } export default EncryptionUtility; diff --git a/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor b/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor index ced3ed2c0..832dc2990 100644 --- a/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor +++ b/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor @@ -41,7 +41,7 @@ @if (!string.IsNullOrEmpty(_qrCodeUrl)) {
-
+
@if (!_isLoading) @@ -167,10 +167,11 @@ _mobileLoginUtility = new MobileLoginUtility(Http, JsInteropService, utilityLogger); // Initiate mobile login and get QR code data - var requestId = await _mobileLoginUtility.InitiateAsync(); + var (requestId, publicKeyHash) = await _mobileLoginUtility.InitiateAsync(); // Generate QR code with AliasVault prefix for mobile login - _qrCodeUrl = $"aliasvault://open/mobile-unlock/{requestId}"; + // Include public key hash as query parameter for security verification + _qrCodeUrl = $"aliasvault://open/mobile-unlock/{requestId}?pk={publicKeyHash}"; // Render QR code while showing loading StateHasChanged(); diff --git a/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs b/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs index 87134c0ce..f1b9446f6 100644 --- a/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs +++ b/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs @@ -8,6 +8,8 @@ namespace AliasVault.Client.Auth.Services; using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -29,6 +31,7 @@ public sealed class MobileLoginUtility : IDisposable private Timer? _pollingTimer; private string? _requestId; private string? _privateKey; + private string? _publicKeyHash; private CancellationTokenSource? _cancellationTokenSource; /// @@ -45,11 +48,11 @@ public sealed class MobileLoginUtility : IDisposable } /// - /// Initiates a mobile login request and returns the request ID for QR code generation. + /// Initiates a mobile login request and returns the request ID and public key hash for QR code generation. /// - /// The request ID. + /// Tuple containing the request ID and public key hash. /// Thrown when the request fails. - public async Task InitiateAsync() + public async Task<(string RequestId, string PublicKeyHash)> InitiateAsync() { try { @@ -57,6 +60,10 @@ public sealed class MobileLoginUtility : IDisposable var keyPair = await _jsInteropService.GenerateRsaKeyPair(); _privateKey = keyPair.PrivateKey; + // Compute hash of public key for QR code binding + // This allows mobile app to verify the public key hasn't been swapped by the server + _publicKeyHash = ComputePublicKeyHash(keyPair.PublicKey); + // Send public key to server var request = new MobileLoginInitiateRequest { @@ -76,7 +83,7 @@ public sealed class MobileLoginUtility : IDisposable } _requestId = result.RequestId; - return _requestId; + return (_requestId, _publicKeyHash); } catch (MobileLoginException) { @@ -143,6 +150,7 @@ public sealed class MobileLoginUtility : IDisposable StopPolling(); _privateKey = null; _requestId = null; + _publicKeyHash = null; } /// @@ -151,6 +159,21 @@ public sealed class MobileLoginUtility : IDisposable Cleanup(); } + /// + /// Computes a SHA-256 hash of the public key and returns the first 16 characters. + /// + /// The public key to hash. + /// First 16 characters of the hex-encoded SHA-256 hash. + private static string ComputePublicKeyHash(string publicKey) + { + var bytes = Encoding.UTF8.GetBytes(publicKey); + var hashBytes = SHA256.HashData(bytes); + var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant(); + + // Return first 16 characters for a compact but secure fingerprint + return hashHex[..16]; + } + private async Task PollServerAsync(Func onSuccess, Action onError) { if (string.IsNullOrEmpty(_requestId) || _cancellationTokenSource?.IsCancellationRequested == true)