mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-09 15:56:11 -04:00
Add public key verification to mobile unlock flow (#1717)
This commit is contained in:
committed by
Leendert de Borst
parent
fa612dac3a
commit
804cb2a9de
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user