Add public key verification to mobile unlock flow (#1717)

This commit is contained in:
Leendert de Borst
2026-02-16 18:12:26 +01:00
committed by Leendert de Borst
parent fa612dac3a
commit 804cb2a9de
8 changed files with 139 additions and 25 deletions

View File

@@ -91,10 +91,13 @@ const MobileUnlockModal: React.FC<IMobileUnlockModalProps> = ({
}
// 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,

View File

@@ -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<string> {
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<string> {
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;
}
}

View File

@@ -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<void> => {
const handleMobileLogin = async (requestId: string, expectedPublicKeyHash?: string) : Promise<void> => {
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'));

View File

@@ -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.

View File

@@ -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);
}
}, []);

View File

@@ -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<string> {
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;

View File

@@ -41,7 +41,7 @@
@if (!string.IsNullOrEmpty(_qrCodeUrl))
{
<div class="flex flex-col items-center mb-4">
<div id="@_qrElementId" data-url="@_qrCodeUrl" class="@(_isLoading ? "hidden" : "") mb-3 p-4 bg-white rounded-lg border-4 border-gray-200 dark:border-gray-600">
<div id="@_qrElementId" data-url="@_qrCodeUrl" class="@(_isLoading ? "hidden" : "") mb-3 bg-white rounded-lg border-4 border-gray-200 dark:border-gray-600">
<!-- QR code will be rendered here -->
</div>
@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();

View File

@@ -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;
/// <summary>
@@ -45,11 +48,11 @@ public sealed class MobileLoginUtility : IDisposable
}
/// <summary>
/// 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.
/// </summary>
/// <returns>The request ID.</returns>
/// <returns>Tuple containing the request ID and public key hash.</returns>
/// <exception cref="MobileLoginException">Thrown when the request fails.</exception>
public async Task<string> 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;
}
/// <inheritdoc/>
@@ -151,6 +159,21 @@ public sealed class MobileLoginUtility : IDisposable
Cleanup();
}
/// <summary>
/// Computes a SHA-256 hash of the public key and returns the first 16 characters.
/// </summary>
/// <param name="publicKey">The public key to hash.</param>
/// <returns>First 16 characters of the hex-encoded SHA-256 hash.</returns>
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<MobileLoginResult, Task> onSuccess, Action<MobileLoginErrorCode> onError)
{
if (string.IsNullOrEmpty(_requestId) || _cancellationTokenSource?.IsCancellationRequested == true)