diff --git a/.vscode/AliasVault.code-workspace b/.vscode/AliasVault.code-workspace index 6c1c3e307..eec6c1292 100644 --- a/.vscode/AliasVault.code-workspace +++ b/.vscode/AliasVault.code-workspace @@ -24,6 +24,7 @@ } ], "settings": { - "java.configuration.updateBuildConfiguration": "disabled" + "java.configuration.updateBuildConfiguration": "disabled", + "i18n-ally.keystyle": "nested" } } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5f1228e53..9cede33eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,8 +10,6 @@ The basic premise is that the master password chosen by the user upon registrati - **Vault Data**: Your entire vault (passwords, usernames, notes, passkeys, email addresses, etc.) is fully encrypted client-side before being sent to the server. The server cannot decrypt any vault contents. - **Email Contents**: When emails are received by the server, their contents are immediately encrypted with your public key (from your vault) before being saved. Only you can decrypt and read these emails with your private key. -*Note: email aliases are stored on the server as "claims" which are linked to internal user IDs for routing purposes.* - This ensures that even if the AliasVault servers are compromised, vault contents and email messages remain secure and unreadable. ## Encryption algorithms @@ -25,6 +23,7 @@ The following encryption algorithms and standards are used by AliasVault: ### Additional Features - [RSA-OAEP](#rsa-oaep) - Email encryption - [Passkeys (WebAuthn)](#passkeys-webauthn) - Passwordless authentication +- [Login with Mobile](#login-with-mobile) - Unlock vault in web app / browser extension via mobile app Below is a detailed explanation of each encryption algorithm and standard. @@ -138,3 +137,31 @@ All implementations follow the WebAuthn Level 2 specification and use: - Eliminates phishing risks through cryptographic domain binding More information about WebAuthn can be found on the [WebAuthn specification](https://www.w3.org/TR/webauthn-2/) page. + +### Login with Mobile +AliasVault provides a secure "Login with Mobile" feature that allows users to unlock their vault on web browsers or browser extensions by scanning a QR code with their authenticated mobile app. This convenient authentication method maintains zero-knowledge security through hybrid encryption. + +#### Implementation Details +The mobile login system combines RSA-2048 asymmetric encryption with AES-256-GCM symmetric encryption: + +1. **Initiation**: Browser/extension client generates an RSA-2048 key pair locally and sends the public key to the server, which returns a unique request ID displayed as a QR code. + +2. **Authorization**: Mobile app scans the QR code, encrypts the user's vault decryption key with the RSA public key, and sends it to the server. + +3. **Retrieval**: Browser client polls the server for completion. When ready, the server: + - Generates fresh JWT tokens for the session + - Creates a random AES-256 symmetric key + - Encrypts tokens and username with the symmetric key + - Encrypts the symmetric key with the client's RSA public key + - Returns encrypted data and immediately purges it from the database + +4. **Decryption**: Client uses its RSA private key to decrypt the symmetric key, then uses the symmetric key to decrypt tokens and username, and the RSA private key to decrypt the vault decryption key. + +#### Security Properties +- **Zero-Knowledge**: Server never accesses the vault decryption key in plaintext +- **One-Time Use**: Requests cannot be retrieved twice; data is immediately cleared after retrieval +- **Automatic Expiration**: Unfulfilled requests expire after 2 minutes client-side (3 minutes server-side); fulfilled but unretrieved requests auto-delete within 24 hours +- **MITM Protection**: Only the client with the RSA private key can decrypt the response +- **Limited Attack Surface**: Short timeout window minimizes QR code interception risks + +More information about the mobile login flow can be found in the [Architecture Documentation](https://docs.aliasvault.net/architecture/#6-login-with-mobile). diff --git a/apps/browser-extension/package-lock.json b/apps/browser-extension/package-lock.json index 635ee2fe2..efa3e883d 100644 --- a/apps/browser-extension/package-lock.json +++ b/apps/browser-extension/package-lock.json @@ -15,6 +15,7 @@ "globals": "^16.0.0", "i18next": "^25.3.1", "otpauth": "^9.3.6", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.57.0", @@ -30,6 +31,7 @@ "@types/chrome": "^0.0.280", "@types/jsdom": "^21.1.7", "@types/node": "^22.13.10", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@types/sql.js": "^1.4.9", @@ -2116,6 +2118,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.4", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", @@ -2973,7 +2985,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4101,7 +4112,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4114,7 +4124,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -4428,6 +4437,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", @@ -4571,6 +4589,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -6000,7 +6024,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6810,7 +6833,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8701,6 +8723,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -8830,7 +8861,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8990,6 +9020,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9291,6 +9330,191 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quansync": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", @@ -9647,12 +9871,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -9976,6 +10205,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -11900,6 +12135,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index c66e6fd7f..6514ee13e 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -32,6 +32,7 @@ "globals": "^16.0.0", "i18next": "^25.3.1", "otpauth": "^9.3.6", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.57.0", @@ -47,6 +48,7 @@ "@types/chrome": "^0.0.280", "@types/jsdom": "^21.1.7", "@types/node": "^22.13.10", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@types/sql.js": "^1.4.9", diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index 9fd47c208..1cdd55547 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -112,7 +112,7 @@ const AppContent: React.FC<{ {loadingOverlay} {message && ( -

{message}

+

{message}

)} {routesComponent}
@@ -135,8 +135,8 @@ const AppContent: React.FC<{ }} > {message && ( -
-

{message}

+
+

{message}

)} {routesComponent} diff --git a/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx new file mode 100644 index 000000000..ca105b085 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx @@ -0,0 +1,244 @@ +import QRCode from 'qrcode'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { MobileLoginErrorCode } from '@/entrypoints/popup/types/MobileLoginErrorCode'; +import { MobileLoginUtility } from '@/entrypoints/popup/utils/MobileLoginUtility'; + +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; +import type { WebApiService } from '@/utils/WebApiService'; + +interface IMobileUnlockModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (result: MobileLoginResult) => Promise; + webApi: WebApiService; + mode?: 'login' | 'unlock'; +} + +/** + * Modal component for mobile login/unlock via QR code scanning. + */ +const MobileUnlockModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + webApi, + mode = 'login' +}) => { + const { t } = useTranslation(); + const [qrCodeUrl, setQrCodeUrl] = useState(null); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(120); // 2 minutes in seconds + const mobileLoginRef = useRef(null); + const countdownIntervalRef = useRef(null); + + /** + * Get translated error message for error code. + */ + const getErrorMessage = (errorCode: MobileLoginErrorCode): string => { + switch (errorCode) { + case MobileLoginErrorCode.TIMEOUT: + return t('auth.errors.mobileLoginRequestExpired'); + case MobileLoginErrorCode.GENERIC: + default: + return t('common.errors.unknownError'); + } + }; + + // Countdown timer effect + useEffect(() => { + if (qrCodeUrl && timeRemaining > 0 && isOpen) { + countdownIntervalRef.current = setInterval(() => { + setTimeRemaining(prev => { + if (prev <= 1) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + return 0; + } + return prev - 1; + }); + }, 1000); + + return (): void => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + }; + } + }, [qrCodeUrl, timeRemaining, isOpen]); + + // Initialize mobile login when modal opens + useEffect(() => { + if (!isOpen) { + return; + } + + /** + * Initialize mobile login on modal open. + */ + const initiateMobileLogin = async (): Promise => { + try { + setError(null); + setQrCodeUrl(null); + setTimeRemaining(120); + + // Initialize mobile login utility + if (!mobileLoginRef.current) { + mobileLoginRef.current = new MobileLoginUtility(webApi); + } + + // Initiate mobile login and get QR code data + const requestId = await mobileLoginRef.current.initiate(); + + // Generate QR code with AliasVault prefix for mobile login + const qrData = `aliasvault://open/mobile-unlock/${requestId}`; + const qrDataUrl = await QRCode.toDataURL(qrData, { + width: 256, + margin: 2, + }); + + setQrCodeUrl(qrDataUrl); + + // Start polling for response + await mobileLoginRef.current.startPolling( + async (result: MobileLoginResult) => { + try { + // Call success callback (parent handles loading state) + await onSuccess(result); + // Close modal after successful processing + handleClose(); + } catch { + // Show error if success handler fails and hide QR code + setQrCodeUrl(null); + setError(MobileLoginErrorCode.GENERIC); + } + }, + (errorCode) => { + // Hide QR code when error occurs + setQrCodeUrl(null); + setError(errorCode); + } + ); + } catch (err) { + // err is a MobileLoginErrorCode thrown by initiate() + if (typeof err === 'string' && Object.values(MobileLoginErrorCode).includes(err as MobileLoginErrorCode)) { + setError(err as MobileLoginErrorCode); + } else { + setError(MobileLoginErrorCode.GENERIC); + } + } + }; + + initiateMobileLogin(); + + // Cleanup on unmount or when modal closes + return (): void => { + if (mobileLoginRef.current) { + mobileLoginRef.current.cleanup(); + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + /** + * Handle modal close. + */ + const handleClose = (): void => { + if (mobileLoginRef.current) { + mobileLoginRef.current.cleanup(); + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + setQrCodeUrl(null); + setError(null); + setTimeRemaining(120); + onClose(); + }; + + /** + * Format time remaining as MM:SS. + */ + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + if (!isOpen) { + return null; + } + + const title = mode === 'unlock' ? t('auth.unlockWithMobile') : t('auth.loginWithMobile'); + const description = t('auth.scanQrCode'); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Close button */} + + + {/* Content */} +
+

+ {title} +

+

+ {description} +

+ + {error && ( +
+ {getErrorMessage(error)} +
+ )} + + {qrCodeUrl && ( +
+ QR Code +
+ {formatTime(timeRemaining)} +
+
+ )} + + {!qrCodeUrl && !error && ( +
+
+
+ )} + + +
+
+
+
+ ); +}; + +export default MobileUnlockModal; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx index 54b094d41..afe748458 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Button from '@/entrypoints/popup/components/Button'; +import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo'; @@ -21,6 +22,7 @@ import { AppInfo } from '@/utils/AppInfo'; import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi'; import EncryptionUtility from '@/utils/EncryptionUtility'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; import { storage } from '#imports'; @@ -47,6 +49,7 @@ const Login: React.FC = () => { const [twoFactorCode, setTwoFactorCode] = useState(''); const [clientUrl, setClientUrl] = useState(null); const [error, setError] = useState(null); + const [showMobileLoginModal, setShowMobileLoginModal] = useState(false); const webApi = useWebApi(); const srpUtil = new SrpUtility(webApi); @@ -272,6 +275,63 @@ const Login: React.FC = () => { } }; + /** + * Handle successful mobile login + */ + const handleMobileLoginSuccess = async (result: MobileLoginResult): Promise => { + showLoading(); + try { + // Clear global message if set + app.clearGlobalMessage(); + + // Fetch vault from server with the new auth token + const vaultResponse = await webApi.authFetch('Vault', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${result.token}`, + }, + }); + + // Store auth tokens and username + await app.setAuthTokens(result.username, result.token, result.refreshToken); + + // Store the encryption key and derivation params + await dbContext.storeEncryptionKey(result.decryptionKey); + await dbContext.storeEncryptionKeyDerivationParams({ + salt: result.salt, + encryptionType: result.encryptionType, + encryptionSettings: result.encryptionSettings, + }); + + // Initialize the database with the vault data + const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey); + + // Check for pending migrations + try { + if (await sqliteClient.hasPendingMigrations()) { + navigate('/upgrade', { replace: true }); + hideLoading(); + setIsInitialLoading(false); + return; + } + } catch (err) { + await app.logout(); + setError(err instanceof Error ? err.message : t('common.errors.unknownError')); + hideLoading(); + return; + } + + // Navigate to reinitialize page + hideLoading(); + setIsInitialLoading(false); + navigate('/reinitialize', { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : t('common.errors.unknownError')); + hideLoading(); + throw err; // Re-throw to let modal show error + } + }; + /** * Handle change */ @@ -330,7 +390,7 @@ const Login: React.FC = () => { }} variant="secondary" > - {t('auth.cancel')} + {t('common.cancel')}

@@ -342,82 +402,113 @@ const Login: React.FC = () => { } return ( -

-
- {error && ( -
- {error} +
+
+ + {/* Title */} +
+

{t('auth.loginTitle')}

+
- )} -

{t('auth.loginTitle')}

- -
- - -
-
- -
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + +
+ -
-
-
- -
-
+
+ +
+ + +
+
+
+ +
+ -
- -
- {t('auth.noAccount')}{' '} - - {t('auth.createVault')} - + + {/* Mobile Login Button */} + + +
+ {t('auth.noAccount')}{' '} + + {t('auth.createVault')} + +
+ + + {/* Mobile Login Modal */} + setShowMobileLoginModal(false)} + onSuccess={handleMobileLoginSuccess} + webApi={webApi} + mode="login" + />
); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx index 5b5ca3210..6b1f0beff 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'; import AlertMessage from '@/entrypoints/popup/components/AlertMessage'; import Button from '@/entrypoints/popup/components/Button'; +import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; import UsernameAvatar from '@/entrypoints/popup/components/Unlock/UsernameAvatar'; @@ -31,6 +32,7 @@ import { unlockWithPin } from '@/utils/PinUnlockService'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; import { storage } from '#imports'; @@ -71,6 +73,9 @@ const Unlock: React.FC = () => { const [error, setError] = useState(null); const { showLoading, hideLoading, setIsInitialLoading } = useLoading(); + // Mobile unlock state + const [showMobileUnlockModal, setShowMobileUnlockModal] = useState(false); + /** * Make status call to API which acts as health check. * This runs only once during component mount. @@ -356,6 +361,59 @@ const Unlock: React.FC = () => { app.logout(); }; + /** + * Handle successful mobile unlock + */ + const handleMobileUnlockSuccess = async (result: MobileLoginResult): Promise => { + showLoading(); + try { + // Revoke current tokens before setting new ones (since we're already logged in) + await webApi.revokeTokens(); + + // Set new auth tokens + await authContext.setAuthTokens(result.username, result.token, result.refreshToken); + + // Fetch vault from server with the new auth token + const vaultResponse = await webApi.get('Vault'); + + // Store the encryption key and derivation params + await dbContext.storeEncryptionKey(result.decryptionKey); + await dbContext.storeEncryptionKeyDerivationParams({ + salt: result.salt, + encryptionType: result.encryptionType, + encryptionSettings: result.encryptionSettings, + }); + + // Initialize the database with the vault data + const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey); + + // Check if there are pending migrations + if (await sqliteClient.hasPendingMigrations()) { + navigate('/upgrade', { replace: true }); + hideLoading(); + return; + } + + // Clear dismiss until + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0); + + // Reset PIN failed attempts on successful unlock + await resetFailedAttempts(); + + navigate('/reinitialize', { replace: true }); + } catch (err) { + // Check if it's a version incompatibility error + if (err instanceof VaultVersionIncompatibleError) { + await app.logout(err.message); + } else { + setError(t('common.errors.unknownErrorTryAgain')); + } + console.error('Mobile unlock error:', err); + } finally { + hideLoading(); + } + }; + /** * Switch to password mode */ @@ -524,6 +582,18 @@ const Unlock: React.FC = () => { {t('auth.unlockVault')} + {/* Mobile Unlock Button */} + +
{t('auth.switchAccounts')}
@@ -535,6 +605,15 @@ const Unlock: React.FC = () => {
)} + + {/* Mobile Unlock Modal */} + setShowMobileUnlockModal(false)} + onSuccess={handleMobileUnlockSuccess} + webApi={webApi} + mode="unlock" + />
); }; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx b/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx index a9ce2affa..bc83c405d 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx @@ -176,381 +176,7 @@ const Settings: React.FC = () => { }; return ( -
-
-

{t('settings.title')}

-
- - {/* User Menu Section */} -
-
-
-
-
-
-
- - {app.username?.[0]?.toUpperCase() || '?'} - -
-
-
-

- {app.username} -

-

- {t('settings.loggedIn')} -

-
-
-
- - -
-
-
-
-
- - {/* Settings Navigation Section */} -
-
-
- {/* Vault Unlock Method */} - - - {/* Auto-lock Settings */} - - - {/* Autofill Settings */} - - - {/* Passkey Settings */} - -
-
-
- - {/* Additional Settings Section */} -
-
-
- {/* Clipboard Settings */} - - - {/* Context Menu Settings */} - - - {/* Language Settings */} - -
-
-
- - {/* Appearance Settings Section */} -
-

{t('settings.appearance')}

-
-
-
-

{t('settings.theme')}

-
- - - -
-
-
-
-
- - {/* Keyboard Shortcuts Section */} - {import.meta.env.CHROME && ( -
-

{t('settings.keyboardShortcuts')}

-
-
-
-
-

{t('settings.configureKeyboardShortcuts')}

-
- -
-
-
-
- )} - -
- {t('settings.versionPrefix')}{AppInfo.VERSION} ({getDisplayUrl()}) -
- + <> {/* Logout Confirmation Modal */} {showLogoutConfirm && (
@@ -578,7 +204,382 @@ const Settings: React.FC = () => {
)} -
+
+
+

{t('settings.title')}

+
+ + {/* User Menu Section */} +
+
+
+
+
+
+
+ + {app.username?.[0]?.toUpperCase() || '?'} + +
+
+
+

+ {app.username} +

+

+ {t('settings.loggedIn')} +

+
+
+
+ + +
+
+
+
+
+ + {/* Settings Navigation Section */} +
+
+
+ {/* Vault Unlock Method */} + + + {/* Auto-lock Settings */} + + + {/* Autofill Settings */} + + + {/* Passkey Settings */} + +
+
+
+ + {/* Additional Settings Section */} +
+
+
+ {/* Clipboard Settings */} + + + {/* Context Menu Settings */} + + + {/* Language Settings */} + +
+
+
+ + {/* Appearance Settings Section */} +
+

{t('settings.appearance')}

+
+
+
+

{t('settings.theme')}

+
+ + + +
+
+
+
+
+ + {/* Keyboard Shortcuts Section */} + {import.meta.env.CHROME && ( +
+

{t('settings.keyboardShortcuts')}

+
+
+
+
+

{t('settings.configureKeyboardShortcuts')}

+
+ +
+
+
+
+ )} + +
+ {t('settings.versionPrefix')}{AppInfo.VERSION} ({getDisplayUrl()}) +
+
+ ); }; diff --git a/apps/browser-extension/src/entrypoints/popup/types/MobileLoginErrorCode.ts b/apps/browser-extension/src/entrypoints/popup/types/MobileLoginErrorCode.ts new file mode 100644 index 000000000..53ad241cf --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/types/MobileLoginErrorCode.ts @@ -0,0 +1,15 @@ +/** + * Error codes for mobile login operations. + * These codes are used to provide translatable error messages to users. + */ +export enum MobileLoginErrorCode { + /** + * The mobile login request has timed out after 2 minutes. + */ + TIMEOUT = 'TIMEOUT', + + /** + * A generic error occurred during mobile login. + */ + GENERIC = 'GENERIC', +} diff --git a/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts b/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts new file mode 100644 index 000000000..b5cdaddc7 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/utils/MobileLoginUtility.ts @@ -0,0 +1,201 @@ +import { Buffer } from 'buffer'; + +import { MobileLoginErrorCode } from '@/entrypoints/popup/types/MobileLoginErrorCode'; + +import type { LoginResponse, MobileLoginInitiateResponse, MobileLoginPollResponse } from '@/utils/dist/shared/models/webapi'; +import EncryptionUtility from '@/utils/EncryptionUtility'; +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; +import type { WebApiService } from '@/utils/WebApiService'; + +/** + * Utility class for mobile login operations + */ +export class MobileLoginUtility { + private webApi: WebApiService; + private pollingInterval: NodeJS.Timeout | null = null; + private requestId: string | null = null; + private privateKey: string | null = null; + + /** + * Constructor for the MobileLoginUtility class. + * + * @param {WebApiService} webApi - The WebApiService instance. + */ + public constructor(webApi: WebApiService) { + this.webApi = webApi; + } + + /** + * Initiates a mobile login request and returns the QR code data + * @throws {MobileLoginErrorCode} If initiation fails + */ + public async initiate(): Promise { + try { + // Generate RSA key pair + const keyPair = await EncryptionUtility.generateRsaKeyPair(); + this.privateKey = keyPair.privateKey; + + // Send public key to server (no auth required) + const response = await this.webApi.rawFetch('auth/mobile-login/initiate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + clientPublicKey: keyPair.publicKey, + }), + }); + + if (!response.ok) { + throw MobileLoginErrorCode.GENERIC; + } + + const data = await response.json() as MobileLoginInitiateResponse; + this.requestId = data.requestId; + + // Return QR code data (request ID) + return this.requestId; + } catch (error) { + if (typeof error === 'string' && Object.values(MobileLoginErrorCode).includes(error as MobileLoginErrorCode)) { + throw error; + } + throw MobileLoginErrorCode.GENERIC; + } + } + + /** + * Starts polling the server for mobile login response + */ + public async startPolling( + onSuccess: (result: MobileLoginResult) => void, + onError: (errorCode: MobileLoginErrorCode) => void + ): Promise { + if (!this.requestId || !this.privateKey) { + throw new Error('Must call initiate() before starting polling'); + } + + /** + * Polls the server for mobile login response + */ + const pollFn = async (): Promise => { + try { + if (!this.requestId) { + this.stopPolling(); + return; + } + + const response = await this.webApi.rawFetch( + `auth/mobile-login/poll/${this.requestId}`, + { + method: 'GET', + } + ); + + if (!response.ok) { + if (response.status === 404) { + // Request expired or not found + this.stopPolling(); + this.privateKey = null; + this.requestId = null; + onError(MobileLoginErrorCode.TIMEOUT); + return; + } + throw new Error(`Polling failed: ${response.status}`); + } + + const data = await response.json() as MobileLoginPollResponse; + + if (data.fulfilled && data.encryptedSymmetricKey) { + // Stop polling + this.stopPolling(); + + // Decrypt the encrypted decryption key with RSA private key + const decryptionKeyBytes = await EncryptionUtility.decryptWithPrivateKey(data.encryptedDecryptionKey!, this.privateKey!); + const decryptionKey = Buffer.from(decryptionKeyBytes).toString('base64'); + + // Decrypt the other encrypted fields with the symmetric key + const symmetricKeyBytes = await EncryptionUtility.decryptWithPrivateKey(data.encryptedSymmetricKey, this.privateKey!); + const symmetricKey = Buffer.from(symmetricKeyBytes).toString('base64'); + + const token = await EncryptionUtility.symmetricDecrypt(data.encryptedToken!, symmetricKey); + const refreshToken = await EncryptionUtility.symmetricDecrypt(data.encryptedRefreshToken!, symmetricKey); + const username = await EncryptionUtility.symmetricDecrypt(data.encryptedUsername!, symmetricKey); + + // Clear sensitive data + this.privateKey = null; + this.requestId = null; + + // Call /login endpoint with username to get salt and encryption settings + const loginResponse = await this.webApi.rawFetch('auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + }), + }); + + if (!loginResponse.ok) { + onError(MobileLoginErrorCode.GENERIC); + return; + } + + const loginData = await loginResponse.json() as LoginResponse; + + // Create result object using the MobileLoginResult type + const result: MobileLoginResult = { + username: username, + token: token, + refreshToken: refreshToken, + decryptionKey: decryptionKey, + salt: loginData.salt, + encryptionType: loginData.encryptionType, + encryptionSettings: loginData.encryptionSettings, + }; + + // Call success callback with result object + onSuccess(result); + + } + } catch { + this.stopPolling(); + this.privateKey = null; + this.requestId = null; + onError(MobileLoginErrorCode.GENERIC); + } + }; + + // Poll every 3 seconds + this.pollingInterval = setInterval(pollFn, 3000); + + // Stop polling after 3.5 minutes (adds 1.5 minute buffer to default 2 minute timer for edge cases) + setTimeout(() => { + if (this.pollingInterval) { + this.stopPolling(); + this.privateKey = null; + this.requestId = null; + onError(MobileLoginErrorCode.TIMEOUT); + } + }, 210000); + } + + /** + * Stops polling the server + */ + public stopPolling(): void { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + } + + /** + * Cleans up resources + */ + public cleanup(): void { + this.stopPolling(); + this.privateKey = null; + this.requestId = null; + } +} diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 02e3a95c0..6a49a1fe6 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -6,17 +6,16 @@ "password": "Password", "passwordPlaceholder": "Enter your password", "rememberMe": "Remember me", - "loginButton": "Login", + "loginButton": "Log in", "noAccount": "No account yet?", "createVault": "Create new vault", "twoFactorTitle": "Please enter the authentication code from your authenticator app.", "authCode": "Authentication Code", "authCodePlaceholder": "Enter 6-digit code", "verify": "Verify", - "cancel": "Cancel", "twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.", "masterPassword": "Master Password", - "unlockVault": "Unlock Vault", + "unlockVault": "Unlock", "unlockWithPin": "Unlock with PIN", "enterPinToUnlock": "Enter your PIN to unlock your vault", "useMasterPassword": "Use Master Password", @@ -30,13 +29,17 @@ "connectingTo": "Connecting to", "switchAccounts": "Switch accounts?", "loggedIn": "Logged in", + "loginWithMobile": "Log in using Mobile App", + "unlockWithMobile": "Unlock using Mobile App", + "scanQrCode": "Scan this QR code with your AliasVault mobile app to log in and unlock your vault.", "errors": { "invalidCode": "Please enter a valid 6-digit authentication code.", "serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.", "wrongPassword": "Incorrect password. Please try again.", "accountLocked": "Account temporarily locked due to too many failed attempts.", "networkError": "Network error. Please check your connection and try again.", - "sessionExpired": "Your session has expired. Please log in again." + "sessionExpired": "Your session has expired. Please log in again.", + "mobileLoginRequestExpired": "Mobile login request timed out. Please reload the page and try again." } }, "menu": { @@ -95,6 +98,7 @@ "clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.", "browserExtensionOutdated": "This browser extension is outdated and cannot be used to access this vault. Please update this browser extension to continue.", "serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.", + "serverVersionTooOld": "The AliasVault server needs to be updated to a newer version in order to use this feature. Please contact the server admin if you need help.", "unknownError": "An unknown error occurred", "unknownErrorTryAgain": "An unknown error occurred. Please try again.", "vaultNotAvailable": "Vault not available", diff --git a/apps/browser-extension/src/utils/dist/shared/models/webapi/index.d.ts b/apps/browser-extension/src/utils/dist/shared/models/webapi/index.d.ts index 37b362306..af8a6cbfd 100644 --- a/apps/browser-extension/src/utils/dist/shared/models/webapi/index.d.ts +++ b/apps/browser-extension/src/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; }; @@ -350,6 +354,10 @@ declare enum AuthEventType { * Represents a user logout event. */ Logout = 3, + /** + * Represents a mobile login attempt (login via QR code from mobile app). + */ + MobileLogin = 4, /** * Represents JWT access token refresh event issued by client to API. */ @@ -380,4 +388,35 @@ 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 login initiate request type. + */ +type MobileLoginInitiateRequest = { + clientPublicKey: string; +}; +/** + * Mobile login initiate response type. + */ +type MobileLoginInitiateResponse = { + requestId: string; +}; +/** + * Mobile login submit request type. + */ +type MobileLoginSubmitRequest = { + requestId: string; + encryptedDecryptionKey: string; +}; +/** + * Mobile login poll response type. + */ +type MobileLoginPollResponse = { + fulfilled: boolean; + encryptedSymmetricKey: string | null; + encryptedToken: string | null; + encryptedRefreshToken: string | null; + encryptedDecryptionKey: string | null; + encryptedUsername: 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 MobileLoginInitiateRequest, type MobileLoginInitiateResponse, type MobileLoginPollResponse, type MobileLoginSubmitRequest, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type TokenModel, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse }; diff --git a/apps/browser-extension/src/utils/dist/shared/models/webapi/index.js b/apps/browser-extension/src/utils/dist/shared/models/webapi/index.js index 501fdc30b..245e270a4 100644 --- a/apps/browser-extension/src/utils/dist/shared/models/webapi/index.js +++ b/apps/browser-extension/src/utils/dist/shared/models/webapi/index.js @@ -7,6 +7,7 @@ var AuthEventType = /* @__PURE__ */ ((AuthEventType2) => { AuthEventType2[AuthEventType2["Login"] = 1] = "Login"; AuthEventType2[AuthEventType2["TwoFactorAuthentication"] = 2] = "TwoFactorAuthentication"; AuthEventType2[AuthEventType2["Logout"] = 3] = "Logout"; + AuthEventType2[AuthEventType2["MobileLogin"] = 4] = "MobileLogin"; AuthEventType2[AuthEventType2["TokenRefresh"] = 10] = "TokenRefresh"; AuthEventType2[AuthEventType2["PasswordReset"] = 20] = "PasswordReset"; AuthEventType2[AuthEventType2["PasswordChange"] = 21] = "PasswordChange"; diff --git a/apps/browser-extension/src/utils/types/messaging/MobileLoginResult.ts b/apps/browser-extension/src/utils/types/messaging/MobileLoginResult.ts new file mode 100644 index 000000000..906f1d713 --- /dev/null +++ b/apps/browser-extension/src/utils/types/messaging/MobileLoginResult.ts @@ -0,0 +1,39 @@ +/** + * Result of a successful mobile login containing decrypted authentication data. + */ +export type MobileLoginResult = { + /** + * The username. + */ + username: string; + + /** + * The JWT access token. + */ + token: string; + + /** + * The refresh token. + */ + refreshToken: string; + + /** + * The vault decryption key (base64 encoded). + */ + decryptionKey: string; + + /** + * The user's salt for key derivation. + */ + salt: string; + + /** + * The encryption type (e.g., "Argon2id"). + */ + encryptionType: string; + + /** + * The encryption settings JSON string. + */ + encryptionSettings: string; +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt index 5c6cd43bf..dc98c2176 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt @@ -149,7 +149,7 @@ class MainActivity : ReactActivity() { try { vaultStore.storeEncryptionKey(encryptionKeyBase64) vaultStore.unlockVault() - promise.resolve(null) + promise.resolve(true) } catch (e: Exception) { promise.reject("UNLOCK_ERROR", "Failed to unlock vault: ${e.message}", e) } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt index 4640d2380..261eb7530 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt @@ -398,7 +398,7 @@ class AutofillService : AutofillService() { val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" // Create deep link URL - val deepLinkUrl = "net.aliasvault.app://credentials/add-edit-page?serviceUrl=$encodedUrl" + val deepLinkUrl = "aliasvault://credentials/add-edit-page?serviceUrl=$encodedUrl" // Add a click listener to open AliasVault app with deep link val intent = Intent(Intent.ACTION_VIEW).apply { @@ -477,7 +477,7 @@ class AutofillService : AutofillService() { val dataSetBuilder = Dataset.Builder(presentation) // Create deep link URL - val deepLinkUrl = "net.aliasvault.app://reinitialize" + val deepLinkUrl = "aliasvault://reinitialize" // Add a click listener to open AliasVault app with deep link val intent = Intent(Intent.ACTION_VIEW).apply { @@ -526,7 +526,7 @@ class AutofillService : AutofillService() { val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" // Create deep link URL to credentials page with service URL - val deepLinkUrl = "net.aliasvault.app://credentials?serviceUrl=$encodedUrl" + val deepLinkUrl = "aliasvault://credentials?serviceUrl=$encodedUrl" // Add a click listener to open AliasVault app with deep link val intent = Intent(Intent.ACTION_VIEW).apply { @@ -569,7 +569,7 @@ class AutofillService : AutofillService() { // Create deep link URL to open the credentials page val appInfo = fieldFinder.getAppInfo() val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" - val deepLinkUrl = "net.aliasvault.app://credentials?serviceUrl=$encodedUrl" + val deepLinkUrl = "aliasvault://credentials?serviceUrl=$encodedUrl" // Add a click listener to open AliasVault app with deep link val intent = Intent(Intent.ACTION_VIEW).apply { diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index 95e092df5..0b3b1e44b 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -284,6 +284,26 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : } } + /** + * Encrypt the decryption key for mobile login. + * @param publicKeyJWK The public key in JWK format + * @param promise The promise to resolve + */ + @ReactMethod + override fun encryptDecryptionKeyForMobileLogin(publicKeyJWK: String, promise: Promise) { + try { + val encryptedKey = vaultStore.encryptDecryptionKeyForMobileLogin(publicKeyJWK) + promise.resolve(encryptedKey) + } catch (e: Exception) { + Log.e(TAG, "Error encrypting key for mobile login", e) + promise.reject( + "ERR_ENCRYPT_KEY_MOBILE_LOGIN", + "Failed to encrypt key for mobile login: ${e.message}", + e, + ) + } + } + /** * Check if the encrypted database exists. * @param promise The promise to resolve @@ -1163,6 +1183,24 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : } } + // MARK: - Server Version Management + + /** + * Check if the stored server version is greater than or equal to the specified version. + * @param targetVersion The version to compare against (e.g., "0.25.0") + * @param promise The promise to resolve. + */ + @ReactMethod + override fun isServerVersionGreaterThanOrEqualTo(targetVersion: String, promise: Promise) { + try { + val isGreaterOrEqual = vaultStore.metadata.isServerVersionGreaterThanOrEqualTo(targetVersion) + promise.resolve(isGreaterOrEqual) + } catch (e: Exception) { + Log.e(TAG, "Error comparing server version", e) + promise.reject("ERR_COMPARE_SERVER_VERSION", "Failed to compare server version: ${e.message}", e) + } + } + // MARK: - Offline Mode Management /** @@ -1388,4 +1426,63 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : val intent = Intent(activity, net.aliasvault.app.pinunlock.PinUnlockActivity::class.java) activity.startActivityForResult(intent, PIN_UNLOCK_REQUEST_CODE) } + + /** + * Authenticate the user using biometric or PIN unlock. + * This method automatically detects which authentication method is enabled and uses it. + * Returns true if authentication succeeded, false otherwise. + * + * @param title The title for authentication. If null or empty, uses default. + * @param subtitle The subtitle for authentication. If null or empty, uses default. + * @param promise The promise to resolve with authentication result. + */ + @ReactMethod + override fun authenticateUser(title: String?, subtitle: String?, promise: Promise) { + CoroutineScope(Dispatchers.Main).launch { + try { + // Check if PIN is enabled first + val pinEnabled = vaultStore.isPinEnabled() + + if (pinEnabled) { + // PIN is enabled, show PIN unlock UI + try { + // Store promise for later resolution by MainActivity + pendingActivityResultPromise = promise + + // Launch PIN unlock activity + val activity = currentActivity + if (activity == null) { + promise.reject("NO_ACTIVITY", "No activity available", null) + return@launch + } + + val intent = Intent(activity, net.aliasvault.app.pinunlock.PinUnlockActivity::class.java) + // Add custom title/subtitle if provided + if (!title.isNullOrEmpty()) { + intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_CUSTOM_TITLE, title) + } + if (!subtitle.isNullOrEmpty()) { + intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_CUSTOM_SUBTITLE, subtitle) + } + activity.startActivityForResult(intent, PIN_UNLOCK_REQUEST_CODE) + } catch (e: Exception) { + Log.e(TAG, "PIN authentication failed", e) + promise.reject("AUTH_ERROR", "PIN authentication failed: ${e.message}", e) + } + } else { + // Use biometric authentication + try { + val authenticated = vaultStore.issueBiometricAuthentication(title) + promise.resolve(authenticated) + } catch (e: Exception) { + Log.e(TAG, "Biometric authentication failed", e) + promise.resolve(false) + } + } + } catch (e: Exception) { + Log.e(TAG, "Authentication failed", e) + promise.reject("AUTH_ERROR", "Authentication failed: ${e.message}", e) + } + } + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt index c73f4864a..d1255ef2f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt @@ -64,6 +64,12 @@ class PinUnlockActivity : AppCompatActivity() { /** Intent extra key for the encryption key to use during setup. */ const val EXTRA_SETUP_ENCRYPTION_KEY = "setup_encryption_key" + /** Intent extra key for custom title (optional). */ + const val EXTRA_CUSTOM_TITLE = "custom_title" + + /** Intent extra key for custom subtitle (optional). */ + const val EXTRA_CUSTOM_SUBTITLE = "custom_subtitle" + /** Mode: Unlock vault with existing PIN. */ const val MODE_UNLOCK = "unlock" @@ -113,9 +119,11 @@ class PinUnlockActivity : AppCompatActivity() { else -> PinMode.UNLOCK } setupEncryptionKey = intent.getStringExtra(EXTRA_SETUP_ENCRYPTION_KEY) + val customTitle = intent.getStringExtra(EXTRA_CUSTOM_TITLE) + val customSubtitle = intent.getStringExtra(EXTRA_CUSTOM_SUBTITLE) // Initialize configuration - configuration = viewModel.initializeConfiguration(mode) + configuration = viewModel.initializeConfiguration(mode, customTitle, customSubtitle) // Initialize views initializeViews() diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinViewModel.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinViewModel.kt index fcb88ead9..f35b8a535 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinViewModel.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinViewModel.kt @@ -17,23 +17,30 @@ class PinViewModel( ) { /** * Initialize PIN configuration based on mode. + * @param mode The PIN mode (unlock or setup) + * @param customTitle Optional custom title to override default + * @param customSubtitle Optional custom subtitle to override default */ - fun initializeConfiguration(mode: PinMode): PinConfiguration { + fun initializeConfiguration( + mode: PinMode, + customTitle: String? = null, + customSubtitle: String? = null, + ): PinConfiguration { return when (mode) { PinMode.UNLOCK -> { val pinLength = vaultStore.getPinLength() PinConfiguration( mode = PinMode.UNLOCK, - title = context.getString(R.string.pin_unlock_vault), - subtitle = context.getString(R.string.pin_enter_to_unlock), + title = customTitle ?: context.getString(R.string.pin_unlock_vault), + subtitle = customSubtitle ?: context.getString(R.string.pin_enter_to_unlock), pinLength = pinLength, ) } PinMode.SETUP -> { PinConfiguration( mode = PinMode.SETUP, - title = context.getString(R.string.pin_setup_title), - subtitle = context.getString(R.string.pin_setup_description), + title = customTitle ?: context.getString(R.string.pin_setup_title), + subtitle = customSubtitle ?: context.getString(R.string.pin_setup_description), pinLength = null, // Allow any length from 4-8 setupStep = PinSetupStep.ENTER_NEW, ) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt index aaceae810..f20ce0bdc 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt @@ -10,6 +10,7 @@ import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreProvider import net.aliasvault.app.vaultstore.storageprovider.StorageProvider import org.json.JSONObject +import java.math.BigInteger import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec @@ -250,4 +251,63 @@ class VaultCrypto( } // endregion + + // region Mobile Login + + /** + * Encrypts the vault's encryption key using an RSA public key for mobile login. + */ + fun encryptDecryptionKeyForMobileLogin(publicKeyJWK: String, authMethods: String): String { + var result: String? = null + var error: Exception? = null + val latch = java.util.concurrent.CountDownLatch(1) + + getEncryptionKey( + object : CryptoOperationCallback { + override fun onSuccess(key: String) { + try { + val keyBytes = Base64.decode(key, Base64.NO_WRAP) + result = encryptWithPublicKey(keyBytes, publicKeyJWK) + } catch (e: Exception) { + error = e + Log.e(TAG, "Error encrypting key for mobile login", e) + } finally { + latch.countDown() + } + } + + override fun onError(e: Exception) { + error = e + Log.e(TAG, "Error getting encryption key", e) + latch.countDown() + } + }, + authMethods, + ) + + latch.await() + error?.let { throw it } + return result ?: throw Exception("Failed to encrypt key for mobile login") + } + + private fun encryptWithPublicKey(data: ByteArray, publicKeyJWK: String): String { + val jwk = JSONObject(publicKeyJWK) + val nStr = jwk.getString("n") + val eStr = jwk.getString("e") + + val modulus = BigInteger(1, Base64.decode(nStr, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + val exponent = BigInteger(1, Base64.decode(eStr, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + + val keySpec = java.security.spec.RSAPublicKeySpec(modulus, exponent) + val keyFactory = java.security.KeyFactory.getInstance("RSA") + val publicKey = keyFactory.generatePublic(keySpec) + + val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + + val encryptedBytes = cipher.doFinal(data) + return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP) + } + + // endregion } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMetadataManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMetadataManager.kt index 971c888ea..2f71b4ea8 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMetadataManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultMetadataManager.kt @@ -3,6 +3,7 @@ package net.aliasvault.app.vaultstore import android.util.Log import net.aliasvault.app.vaultstore.models.VaultMetadata import net.aliasvault.app.vaultstore.storageprovider.StorageProvider +import net.aliasvault.app.vaultstore.utils.VersionComparison import org.json.JSONArray import org.json.JSONObject @@ -103,6 +104,41 @@ class VaultMetadataManager( // endregion + // region Server Version Management + + /** + * Set the server API version. + */ + fun setServerVersion(version: String) { + storageProvider.setServerVersion(version) + } + + /** + * Get the server API version. + */ + fun getServerVersion(): String? { + return storageProvider.getServerVersion() + } + + /** + * Clear the server version. + */ + fun clearServerVersion() { + storageProvider.clearServerVersion() + } + + /** + * Check if the stored server version is greater than or equal to the specified version. + * @param targetVersion The version to compare against (e.g., "0.25.0") + * @return true if stored server version >= targetVersion, false if server version not available or less than target + */ + fun isServerVersionGreaterThanOrEqualTo(targetVersion: String): Boolean { + val serverVersion = getServerVersion() ?: return false // No server version stored yet + return VersionComparison.isGreaterThanOrEqualTo(serverVersion, targetVersion) + } + + // endregion + // region Internal Helpers /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt index 552c80fe1..89fec195d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt @@ -2,12 +2,16 @@ package net.aliasvault.app.vaultstore import android.os.Handler import android.os.Looper +import android.util.Log import io.requery.android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.suspendCancellableCoroutine import net.aliasvault.app.vaultstore.interfaces.CredentialOperationCallback import net.aliasvault.app.vaultstore.interfaces.CryptoOperationCallback +import net.aliasvault.app.vaultstore.keystoreprovider.BiometricAuthCallback import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreProvider import net.aliasvault.app.vaultstore.models.Credential import net.aliasvault.app.vaultstore.storageprovider.StorageProvider +import kotlin.coroutines.resume /** * The vault store that manages the encrypted vault and all input/output operations on it. @@ -65,7 +69,7 @@ class VaultStore( private val crypto = VaultCrypto(keystoreProvider, storageProvider) private val databaseComponent = VaultDatabase(storageProvider, crypto) private val query = VaultQuery(databaseComponent) - private val metadata = VaultMetadataManager(storageProvider) + internal val metadata = VaultMetadataManager(storageProvider) private val auth = VaultAuth(storageProvider) { cache.clearCache() } private val sync = VaultSync(databaseComponent, metadata, crypto) private val mutate = VaultMutate(databaseComponent, query, metadata) @@ -196,6 +200,13 @@ class VaultStore( return crypto.deriveKeyFromPassword(password, salt, encryptionType, encryptionSettings) } + /** + * Encrypts the vault's encryption key using an RSA public key for mobile login. + */ + fun encryptDecryptionKeyForMobileLogin(publicKeyJWK: String): String { + return crypto.encryptDecryptionKeyForMobileLogin(publicKeyJWK, auth.getAuthMethods()) + } + // endregion // region Database Methods @@ -619,4 +630,52 @@ class VaultStore( } // endregion + + // region Re-authentication + + /** + * Authenticate the user using biometric authentication only. + * Note: This method only handles biometric authentication. + * Returns true if authentication succeeded, false otherwise. + * + * @param title The title for authentication. Optional, defaults to "Unlock Vault". + * @return True if biometric authentication succeeded, false if authentication failed. + */ + suspend fun issueBiometricAuthentication(title: String?): Boolean { + // Use title if provided, otherwise default + val authReason = title?.takeIf { it.isNotEmpty() } ?: "Unlock Vault" + + // Check if biometric authentication is enabled + val authMethods = auth.getAuthMethods() + val isBiometricEnabled = authMethods.contains("faceid") + + if (!isBiometricEnabled) { + Log.e("VaultStore", "No authentication method enabled") + return false + } + + // Check if biometric is available + if (!keystoreProvider.isBiometricAvailable()) { + Log.e("VaultStore", "Biometric authentication not available") + return false + } + + // Trigger biometric authentication with a custom prompt + return suspendCancellableCoroutine { continuation -> + keystoreProvider.authenticateWithBiometric( + authReason, + object : BiometricAuthCallback { + override fun onSuccess() { + continuation.resume(true) + } + + override fun onFailure() { + continuation.resume(false) + } + }, + ) + } + } + + // endregion } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt index 6049b2f48..f8fac296f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultSync.kt @@ -121,6 +121,9 @@ class VaultSync( throw VaultSyncError.ServerVersionNotSupported() } + // Store server version in metadata + metadata.setServerVersion(status.serverVersion) + validateSrpSalt(status.srpSalt) return status } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt index 932f8be17..63b5e8eba 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt @@ -356,4 +356,57 @@ class AndroidKeystoreProvider( // Show biometric prompt biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) } + + /** + * Trigger standalone biometric authentication (no key retrieval). + * This is used for re-authentication before sensitive operations. + * @param title The title to show in the biometric prompt + * @param callback The callback to handle the result + */ + override fun authenticateWithBiometric(title: String, callback: BiometricAuthCallback) { + val currentActivity = getCurrentActivity() as? FragmentActivity + if (currentActivity == null) { + Log.e(TAG, "Current activity is not a FragmentActivity") + callback.onFailure() + return + } + + // Create BiometricPrompt + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + .build() + + val biometricPrompt = BiometricPrompt( + currentActivity, + _executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) { + _mainHandler.post { + callback.onSuccess() + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + Log.e(TAG, "Biometric authentication error: $errString") + _mainHandler.post { + callback.onFailure() + } + } + + override fun onAuthenticationFailed() { + // Don't call callback here, user can retry + Log.w(TAG, "Biometric authentication failed, user can retry") + } + }, + ) + + // Show biometric prompt + biometricPrompt.authenticate(promptInfo) + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt index 5f5d4e682..0646d1504 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/KeystoreProvider.kt @@ -40,6 +40,29 @@ interface KeystoreProvider { * Clear all stored keys. */ fun clearKeys() + + /** + * Trigger standalone biometric authentication (no key retrieval). + * This is used for re-authentication before sensitive operations. + * @param title The title to show in the biometric prompt + * @param callback The callback to handle the result + */ + fun authenticateWithBiometric(title: String, callback: BiometricAuthCallback) +} + +/** + * Callback interface for standalone biometric authentication. + */ +interface BiometricAuthCallback { + /** + * Called when authentication succeeds. + */ + fun onSuccess() + + /** + * Called when authentication fails or is cancelled. + */ + fun onFailure() } /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt index 6829801a0..dd922206a 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/TestKeystoreProvider.kt @@ -28,4 +28,9 @@ class TestKeystoreProvider : KeystoreProvider { override fun clearKeys() { // Do nothing in test implementation } + + override fun authenticateWithBiometric(title: String, callback: BiometricAuthCallback) { + // Always fail in test implementation since biometrics are not available + callback.onFailure() + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt index 9bcda970c..92a9dff99 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt @@ -131,4 +131,23 @@ class AndroidStorageProvider(private val context: Context) : StorageProvider { val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE) return sharedPreferences.getBoolean("offline_mode", false) } + + override fun setServerVersion(version: String) { + val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE) + sharedPreferences.edit { + putString("server_version", version) + } + } + + override fun getServerVersion(): String? { + val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE) + return sharedPreferences.getString("server_version", null) + } + + override fun clearServerVersion() { + val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE) + sharedPreferences.edit { + remove("server_version") + } + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt index b32eef88f..8bf1dc7fd 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt @@ -106,4 +106,21 @@ interface StorageProvider { * @return True if app is in offline mode, false otherwise */ fun getOfflineMode(): Boolean + + /** + * Set the server API version. + * @param version The server version to store + */ + fun setServerVersion(version: String) + + /** + * Get the server API version. + * @return The server version or null if not set + */ + fun getServerVersion(): String? + + /** + * Clear the server version. + */ + fun clearServerVersion() } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt index eca7f0518..9ea027b5c 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt @@ -15,6 +15,7 @@ class TestStorageProvider : StorageProvider { private var tempAutoLockTimeout = defaultAutoLockTimeout private var username: String? = null private var offlineMode: Boolean = false + private var serverVersion: String? = null override fun getEncryptedDatabaseFile(): File = tempFile @@ -87,4 +88,16 @@ class TestStorageProvider : StorageProvider { override fun getOfflineMode(): Boolean { return offlineMode } + + override fun setServerVersion(version: String) { + serverVersion = version + } + + override fun getServerVersion(): String? { + return serverVersion + } + + override fun clearServerVersion() { + serverVersion = null + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt index 8b4604297..1929196f3 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/VersionComparison.kt @@ -13,9 +13,10 @@ import net.aliasvault.app.utils.AppInfo */ object VersionComparison { /** - * Checks if version1 is greater than or equal to version2, following SemVer rules. + * Checks if version1 is greater than or equal to version2, ignoring pre-release suffixes. * - * Pre-release versions (e.g., -alpha, -beta, -dev) are considered lower than release versions. + * Pre-release suffixes (e.g., -alpha, -beta, -dev) are stripped from version1 before comparison. + * This allows server versions like "0.25.0-alpha-dev" to be treated as "0.25.0". * * @param version1 First version string (e.g., "1.2.3" or "1.2.3-beta") * @param version2 Second version string (e.g., "1.2.0" or "1.2.0-alpha") @@ -24,26 +25,23 @@ object VersionComparison { * Example: * ```kotlin * VersionComparison.isGreaterThanOrEqualTo("1.2.3", "1.2.0") // true - * VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // false - * VersionComparison.isGreaterThanOrEqualTo("1.2.0", "1.2.0-alpha") // true + * VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // true (ignores -alpha) + * VersionComparison.isGreaterThanOrEqualTo("1.2.0-dev", "1.2.1") // false (0.25.0 < 0.25.1) * ``` */ fun isGreaterThanOrEqualTo(version1: String, version2: String): Boolean { - // Split versions into core and pre-release parts + // Strip pre-release suffix from version1 (server version) val components1 = version1.split("-", limit = 2) val components2 = version2.split("-", limit = 2) val core1 = components1[0] val core2 = components2[0] - val preRelease1 = components1.getOrNull(1) - val preRelease2 = components2.getOrNull(1) - // Parse core version numbers val parts1 = core1.split(".").mapNotNull { it.toIntOrNull() } val parts2 = core2.split(".").mapNotNull { it.toIntOrNull() } - // Compare core versions first + // Compare core versions only val maxLength = maxOf(parts1.size, parts2.size) for (i in 0 until maxLength) { val part1 = parts1.getOrElse(i) { 0 } @@ -55,14 +53,8 @@ object VersionComparison { } } - // If core versions are equal, check pre-release versions - // No pre-release (release version) is greater than pre-release version - return when { - preRelease1 == null && preRelease2 != null -> true - preRelease1 != null && preRelease2 == null -> false - preRelease1 == null && preRelease2 == null -> true - else -> preRelease1!! >= preRelease2!! - } + // Core versions are equal + return true } /** diff --git a/apps/mobile-app/android/build.gradle b/apps/mobile-app/android/build.gradle index 1f7dc252b..645a977ea 100644 --- a/apps/mobile-app/android/build.gradle +++ b/apps/mobile-app/android/build.gradle @@ -56,5 +56,10 @@ allprojects { google() mavenCentral() maven { url 'https://www.jitpack.io' } + + maven { + // expo-camera bundles a custom com.google.android:cameraview + url "$rootDir/../node_modules/expo-camera/android/maven" + } } } diff --git a/apps/mobile-app/app.json b/apps/mobile-app/app.json index 234317d01..2369dbd74 100644 --- a/apps/mobile-app/app.json +++ b/apps/mobile-app/app.json @@ -5,7 +5,7 @@ "version": "0.25.0-alpha", "orientation": "portrait", "icon": "./assets/images/icon.png", - "scheme": "net.aliasvault.app", + "scheme": "aliasvault", "userInterfaceStyle": "automatic", "newArchEnabled": true, "platforms": [ diff --git a/apps/mobile-app/app/(tabs)/settings/_layout.tsx b/apps/mobile-app/app/(tabs)/settings/_layout.tsx index 12dbc4ba7..47f78bc03 100644 --- a/apps/mobile-app/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/settings/_layout.tsx @@ -126,6 +126,27 @@ 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..662bf4bc9 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons'; import { router, useFocusEffect } from 'expo-router'; import { useRef, useState, useCallback } from 'react'; import { StyleSheet, View, ScrollView, TouchableOpacity, Animated, Platform, Alert, Linking } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useApiUrl } from '@/utils/ApiUrlUtility'; import { AppInfo } from '@/utils/AppInfo'; @@ -24,6 +25,7 @@ import { useApp } from '@/context/AppContext'; export default function SettingsScreen() : React.ReactNode { const colors = useColors(); const { t } = useTranslation(); + const insets = useSafeAreaInsets(); const { getAuthMethodDisplayKey, shouldShowAutofillReminder, logout } = useApp(); const { getAutoLockTimeout, getClipboardClearTimeout } = useApp(); const { loadApiUrl, getDisplayUrl } = useApiUrl(); @@ -227,6 +229,22 @@ export default function SettingsScreen() : React.ReactNode { }; const styles = StyleSheet.create({ + fab: { + alignItems: 'center', + backgroundColor: colors.primary, + borderRadius: 28, + bottom: Platform.OS === 'ios' ? insets.bottom + 60 : 16, + elevation: 4, + height: 56, + justifyContent: 'center', + position: 'absolute', + right: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + width: 56, + }, scrollContent: { paddingBottom: 80, paddingTop: Platform.OS === 'ios' ? 42 : 16, @@ -511,6 +529,15 @@ export default function SettingsScreen() : React.ReactNode { {t('settings.appVersion', { version: AppInfo.VERSION, url: getDisplayUrl() })} + + {/* Floating Action Button for QR Scanner - shown for testing both options */} + router.push('/(tabs)/settings/qr-scanner')} + activeOpacity={0.8} + > + + ); } \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx new file mode 100644 index 000000000..1e59b4c01 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx @@ -0,0 +1,290 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { useState, useEffect } from 'react'; +import { View, Alert, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper'; + +import { useColors } from '@/hooks/useColorScheme'; +import { useTranslation } from '@/hooks/useTranslation'; + +import LoadingIndicator from '@/components/LoadingIndicator'; +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 { UsernameDisplay } from '@/components/ui/UsernameDisplay'; +import { useWebApi } from '@/context/WebApiContext'; +import NativeVaultManager from '@/specs/NativeVaultManager'; + +/** + * QR Code confirmation screen for mobile login. + */ +export default function MobileUnlockConfirmScreen() : React.ReactNode { + const colors = useColors(); + const { t } = useTranslation(); + const webApi = useWebApi(); + const insets = useSafeAreaInsets(); + const { id } = useLocalSearchParams(); + + const [isProcessing, setIsProcessing] = useState(false); + const [isValidating, setIsValidating] = useState(true); + + /* + * Validate request on component mount + */ + useEffect(() => { + if (!id) { + Alert.alert( + t('common.error'), + t('common.errors.unknownErrorTryAgain'), + [ + { + text: t('common.ok'), + /** + * Navigate back to settings. + */ + onPress: (): void => router.back(), + }, + ] + ); + return; + } + + /** + * Validate the mobile login request. + */ + const validateRequest = async () : Promise => { + try { + // Check server version compatibility first + const isVersionSupported = await NativeVaultManager.isServerVersionGreaterThanOrEqualTo('0.25.0'); + + if (!isVersionSupported) { + Alert.alert( + t('common.error'), + t('common.errors.serverVersionTooOld'), + [ + { + text: t('common.ok'), + /** + * Navigate back to settings. + */ + onPress: (): void => router.back(), + }, + ] + ); + return; + } + + // Validate the request exists by fetching from server + await webApi.authFetch<{ clientPublicKey: string }>( + `auth/mobile-login/request/${id}`, + { method: 'GET' } + ); + + // Request is valid + setIsValidating(false); + } catch (error) { + console.error('Request validation error:', error); + let errorMsg = t('common.errors.unknownErrorTryAgain'); + + if (error instanceof Error && error.message.includes('404')) { + errorMsg = t('auth.errors.mobileLoginRequestExpired'); + } + + Alert.alert( + t('common.error'), + errorMsg, + [ + { + text: t('common.ok'), + /** + * Navigate back to settings. + */ + onPress: (): void => router.back(), + }, + ] + ); + } + }; + + validateRequest(); + }, [id, webApi, t]); + + /** + * Handle mobile login QR code. + */ + const handleMobileLogin = async (id: string) : Promise => { + try { + // Fetch the public key from server + const response = await webApi.authFetch<{ clientPublicKey: string }>( + `auth/mobile-login/request/${id}`, + { method: 'GET' } + ); + + const publicKeyJWK = response.clientPublicKey; + + // Encrypt the decryption key using native module + const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileLogin(publicKeyJWK); + + // Submit the encrypted key to the server + await webApi.authFetch( + 'auth/mobile-login/submit', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requestId: id, + encryptedDecryptionKey: encryptedKey, + }), + } + ); + + // Success! Navigate to result page + router.replace({ + pathname: '/(tabs)/settings/mobile-unlock/result', + params: { + success: 'true', + message: t('settings.qrScanner.mobileLogin.successDescription'), + }, + }); + } 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, + }, + }); + } + }; + + /** + * Handle confirmation - authenticate user first, then process the scanned QR code. + */ + const handleConfirm = async () : Promise => { + if (!id) { + return; + } + + setIsProcessing(true); + + try { + // Authenticate user with either biometric or PIN (automatically detected) + const authenticated = await VaultUnlockHelper.authenticateForAction( + t('settings.qrScanner.mobileLogin.confirmTitle'), + t('settings.qrScanner.mobileLogin.confirmSubtitle') + ); + + if (!authenticated) { + setIsProcessing(false); + return; + } + + // Process the mobile login + await handleMobileLogin(id as string); + } catch (error) { + console.error('Authentication or QR code processing error:', error); + Alert.alert( + t('common.error'), + error instanceof Error ? error.message : t('common.errors.unknownError') + ); + } finally { + setIsProcessing(false); + } + }; + + /** + * Handle dismiss - navigate to settings tab. + * Uses replace to handle cases where this page is the first in the navigation stack (deep link). + */ + const handleDismiss = () : void => { + router.replace('/(tabs)/settings'); + }; + + const styles = StyleSheet.create({ + confirmationContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 20, + }, + confirmationTitle: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + confirmationText: { + fontSize: 16, + lineHeight: 24, + marginBottom: 16, + textAlign: 'center', + }, + usernameDisplayContainer: { + marginBottom: 12, + width: '100%', + }, + buttonContainer: { + gap: 12, + marginTop: 20, + paddingBottom: insets.bottom + 80, + paddingHorizontal: 20, + }, + button: { + width: '100%', + }, + cancelButton: { + backgroundColor: colors.secondary, + }, + }); + + // Show loading during validation or processing + if (isValidating || isProcessing) { + return ( + + + + + + ); + } + + // Show confirmation screen + return ( + + + + + {t('settings.qrScanner.mobileLogin.confirmTitle')} + + + {t('settings.qrScanner.mobileLogin.confirmMessage')} + + + + + + + + + + + + + ); +} diff --git a/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx new file mode 100644 index 000000000..e6f21cdee --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx @@ -0,0 +1,110 @@ +import { Ionicons } from '@expo/vector-icons'; +import { router, useLocalSearchParams } from 'expo-router'; +import { View, StyleSheet } 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'; + +/** + * QR Code result screen - shows success or error after mobile unlock attempt. + */ +export default function MobileUnlockResultScreen() : React.ReactNode { + const colors = useColors(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { success, message } = useLocalSearchParams<{ success: string; message?: string }>(); + + const isSuccess = success === 'true'; + + /** + * Handle dismiss - navigate to settings tab. + * Uses replace to handle cases where this page is reached via deep link navigation. + */ + const handleDismiss = () : void => { + router.replace('/(tabs)/settings'); + }; + + const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 20, + }, + resultContainer: { + alignItems: 'center', + backgroundColor: (isSuccess ? colors.success : colors.destructive) + '10', + borderColor: isSuccess ? colors.success : colors.destructive, + borderRadius: 12, + borderWidth: 2, + marginBottom: 20, + padding: 20, + width: '100%', + }, + icon: { + marginBottom: 16, + }, + title: { + color: isSuccess ? colors.success : colors.destructive, + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + message: { + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + buttonContainer: { + marginTop: 20, + paddingBottom: insets.bottom + 80, + paddingHorizontal: 20, + width: '100%', + }, + button: { + width: '100%', + }, + }); + + return ( + + + + + + + {isSuccess + ? t('common.success') + : t('common.error')} + + + {message || (isSuccess + ? t('settings.qrScanner.mobileLogin.successDescription') + : t('common.errors.unknownErrorTryAgain'))} + + + + + + + + + + ); +} 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..68b3b2893 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx @@ -0,0 +1,214 @@ +import { Ionicons } from '@expo/vector-icons'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { Href, router, useLocalSearchParams } from 'expo-router'; +import { useEffect, useCallback, useRef } from 'react'; +import { View, Alert, StyleSheet } from 'react-native'; + +import { useColors } from '@/hooks/useColorScheme'; +import { useTranslation } from '@/hooks/useTranslation'; + +import LoadingIndicator from '@/components/LoadingIndicator'; +import { ThemedContainer } from '@/components/themed/ThemedContainer'; +import { ThemedText } from '@/components/themed/ThemedText'; + +// QR Code type prefixes +const QR_CODE_PREFIXES = { + MOBILE_UNLOCK: 'aliasvault://open/mobile-unlock/', + /* + * Future actions: + * PASSKEY_AUTH: 'aliasvault://open/passkey-auth/', + * SHARE_CREDENTIAL: 'aliasvault://open/share-credential/', + */ +} as const; + +type QRCodeType = keyof typeof QR_CODE_PREFIXES; + +/** + * Scanned QR code data. + */ +type 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 [permission, requestPermission] = useCameraPermissions(); + const { url } = useLocalSearchParams<{ url?: string }>(); + const hasProcessedUrl = useRef(false); + const processedUrls = useRef(new Set()); + + // Request camera permission on mount + useEffect(() => { + /** + * Request camera permission. + */ + 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'), /** + * Go back to the settings tab. + */ + onPress: (): void => router.back() }] + ); + } + }; + + requestCameraPermission(); + }, [permission, requestPermission, t]); + + /* + * Handle barcode scanned - parse and navigate to appropriate page. + * Only processes AliasVault QR codes, silently ignores others. + * Validation is handled by the destination page. + */ + const handleBarcodeScanned = useCallback(({ data }: { data: string }) : void => { + // Prevent processing the same URL multiple times + if (processedUrls.current.has(data)) { + return; + } + + // Parse the QR code to determine its type + const parsedData = parseQRCode(data); + + // Silently ignore non-AliasVault QR codes + if (!parsedData.type) { + return; + } + + // Mark this URL as processed + processedUrls.current.add(data); + + /* + * Navigate to the appropriate page based on QR code type + * Validation will be handled by the destination page + */ + if (parsedData.type === 'MOBILE_UNLOCK') { + router.replace(`/(tabs)/settings/mobile-unlock/${parsedData.payload}` as Href); + } + }, []); + + /** + * Reset hasProcessedUrl when URL changes to allow processing new URLs. + */ + useEffect(() => { + hasProcessedUrl.current = false; + }, [url]); + + /** + * Handle QR code URL passed from deep link (e.g., from native camera). + */ + useEffect(() => { + if (url && typeof url === 'string' && !hasProcessedUrl.current) { + hasProcessedUrl.current = true; + handleBarcodeScanned({ data: url }); + } + }, [url, handleBarcodeScanned]); + + const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 0, + }, + 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, + }, + loadingContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 20, + }, + }); + + // Show permission request screen + if (!permission || !permission.granted) { + return ( + + + + + + ); + } + + return ( + + + + + + + {t('settings.qrScanner.scanningMessage')} + + + + + + ); +} diff --git a/apps/mobile-app/app/+not-found.tsx b/apps/mobile-app/app/+not-found.tsx index 5802a8ef0..e890148dd 100644 --- a/apps/mobile-app/app/+not-found.tsx +++ b/apps/mobile-app/app/+not-found.tsx @@ -1,4 +1,5 @@ -import { Link, Stack } from 'expo-router'; +import { Link, Stack, usePathname, useGlobalSearchParams } from 'expo-router'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native'; @@ -10,7 +11,17 @@ import { ThemedView } from '@/components/themed/ThemedView'; */ export default function NotFoundScreen() : React.ReactNode { const { t } = useTranslation(); - + const pathname = usePathname(); + const params = useGlobalSearchParams(); + + useEffect(() => { + console.error('[NotFound] 404 - Page not found:', { + pathname, + params, + timestamp: new Date().toISOString(), + }); + }, [pathname, params]); + return ( <> diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index b4be22443..ce294b2bf 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -1,6 +1,6 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useFonts } from 'expo-font'; -import { Href, Stack, useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { useEffect, useRef, useState } from 'react'; import { Linking, StyleSheet, Platform } from 'react-native'; @@ -12,12 +12,14 @@ import { install } from 'react-native-quick-crypto'; import { useColors, useColorScheme } from '@/hooks/useColorScheme'; import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf'; +import LoadingIndicator from '@/components/LoadingIndicator'; import { ThemedView } from '@/components/themed/ThemedView'; import { AliasVaultToast } from '@/components/Toast'; import { AppProvider } from '@/context/AppContext'; import { AuthProvider } from '@/context/AuthContext'; import { ClipboardCountdownProvider } from '@/context/ClipboardCountdownContext'; import { DbProvider } from '@/context/DbContext'; +import { NavigationProvider, useNavigation } from '@/context/NavigationContext'; import { WebApiProvider } from '@/context/WebApiContext'; import { initI18n } from '@/i18n'; @@ -30,9 +32,9 @@ function RootLayoutNav() : React.ReactNode { const colorScheme = useColorScheme(); const colors = useColors(); const router = useRouter(); + const navigation = useNavigation(); const [bootComplete, setBootComplete] = useState(false); - const [redirectTarget, setRedirectTarget] = useState(null); const hasBooted = useRef(false); useEffect(() => { @@ -51,52 +53,38 @@ function RootLayoutNav() : React.ReactNode { await initI18n(); hasBooted.current = true; - setRedirectTarget('/initialize'); + + // Check if we have a pending deep link and pass it to initialize + const initialUrl = await Linking.getInitialURL(); + if (initialUrl) { + const path = initialUrl + .replace('net.aliasvault.app://', '') + .replace('aliasvault://', '') + .replace('exp+aliasvault://', ''); + + navigation.setReturnUrl({ path }); + } + setBootComplete(true); }; initializeApp(); - }, []); + }, [navigation, router]); useEffect(() => { /** * 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. - * If neither is present, we let the router redirect us to the default route. */ const redirect = async () : Promise => { if (!bootComplete) { return; } - if (redirectTarget) { - // 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). - 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); - } - } - } + router.replace('/initialize'); }; redirect(); - }, [bootComplete, redirectTarget, router]); + }, [bootComplete, router]); const styles = StyleSheet.create({ container: { @@ -110,6 +98,7 @@ function RootLayoutNav() : React.ReactNode { return ( {/* Loading state while booting */} + ); } @@ -182,18 +171,20 @@ export default function RootLayout() : React.ReactNode { } return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index ebd98b734..e215cdd9d 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper'; + import { useColors } from '@/hooks/useColorScheme'; import { useVaultSync } from '@/hooks/useVaultSync'; @@ -11,6 +13,7 @@ import LoadingIndicator from '@/components/LoadingIndicator'; import { ThemedView } from '@/components/themed/ThemedView'; import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; +import { useNavigation } from '@/context/NavigationContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; /** @@ -27,6 +30,7 @@ export default function Initialize() : React.ReactNode { const abortControllerRef = useRef(null); const { t } = useTranslation(); const app = useApp(); + const navigation = useNavigation(); const { syncVault } = useVaultSync(); const dbContext = useDb(); const colors = useColors(); @@ -72,7 +76,7 @@ export default function Initialize() : React.ReactNode { // Don't show the alert if we're already in offline mode if (app.isOffline) { console.debug('Already in offline mode, skipping offline flow alert'); - router.replace('/(tabs)/credentials'); + navigation.navigateAfterUnlock(); return; } @@ -127,8 +131,8 @@ export default function Initialize() : React.ReactNode { return; } - // Success - navigate to credentials - router.replace('/(tabs)/credentials'); + // Success - use centralized navigation logic + navigation.navigateAfterUnlock(); } catch (err) { console.error('Error during offline vault unlock:', err); router.replace('/unlock'); @@ -169,7 +173,7 @@ export default function Initialize() : React.ReactNode { } ] ); - }, [dbContext, router, app, t, updateStatus]); + }, [dbContext, router, app, navigation, t, updateStatus]); useEffect(() => { // Ensure this only runs once. @@ -206,33 +210,30 @@ export default function Initialize() : React.ReactNode { const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); if (hasEncryptedDatabase) { - const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + // Attempt automatic unlock using centralized helper + updateStatus(t('app.status.unlockingVault')); + const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault }); - // Only attempt to unlock if FaceID is enabled - if (isFaceIDEnabled) { - // Unlock vault FIRST (before network sync) - this is not skippable - updateStatus(t('app.status.unlockingVault')); - const isUnlocked = await dbContext.unlockVault(); - - if (!isUnlocked) { - // Failed to unlock, redirect to unlock screen - router.replace('/unlock'); - return; + if (!unlockResult.success) { + /* + * Unlock failed or cancelled, redirect to unlock screen. + * Only log non-cancellation errors to avoid noise. + */ + if (!unlockResult.error?.includes('cancelled')) { + console.error('Automatic unlock failed:', unlockResult.error); } - - // Check if the vault needs migration before syncing - if (await dbContext.hasPendingMigrations()) { - router.replace('/upgrade'); - return; - } - - // Vault unlocked successfully - now allow skip button for network operations - canShowSkipButtonRef.current = true; - } else { - // No FaceID, redirect to unlock screen for manual unlock router.replace('/unlock'); return; } + + // Check if the vault needs migration before syncing + if (await dbContext.hasPendingMigrations()) { + router.replace('/upgrade'); + return; + } + + // Vault unlocked successfully - now allow skip button for network operations + canShowSkipButtonRef.current = true; } } catch (err) { console.error('Error during initial vault unlock:', err); @@ -272,8 +273,8 @@ export default function Initialize() : React.ReactNode { * Handle successful vault sync. */ onSuccess: async () => { - // Vault already unlocked, just navigate to credentials - router.replace('/(tabs)/credentials'); + // Use centralized navigation logic + navigation.navigateAfterUnlock(); }, /** * Handle offline state and prompt user for action. @@ -317,7 +318,7 @@ export default function Initialize() : React.ReactNode { clearTimeout(skipButtonTimeoutRef.current); } }; - }, [dbContext, syncVault, app, router, t, handleOfflineFlow, updateStatus]); + }, [dbContext, syncVault, app, router, navigation, t, handleOfflineFlow, updateStatus]); /** * Handle skip button press by calling the offline handler. diff --git a/apps/mobile-app/app/open/[...path].tsx b/apps/mobile-app/app/open/[...path].tsx new file mode 100644 index 000000000..a9818ab46 --- /dev/null +++ b/apps/mobile-app/app/open/[...path].tsx @@ -0,0 +1,68 @@ +import { Href, useRouter, useLocalSearchParams, useGlobalSearchParams } from 'expo-router'; +import { useEffect, useState } from 'react'; + +/** + * Action-based deep link handler for special actions triggered from outside the app. + * + * URL structure: aliasvault://open/[action]/[...params] + * + * Supported actions: + * - mobile-unlock/[requestId] - Mobile device unlock via QR code + * + * This route exists to handle deep links that Expo Router processes before our + * Linking.addEventListener can intercept them. It provides proper navigation + * flow for each action type. + */ +export default function ActionHandler() : null { + const router = useRouter(); + const params = useGlobalSearchParams(); + const localParams = useLocalSearchParams(); + const [hasNavigated, setHasNavigated] = useState(false); + + useEffect(() => { + if (hasNavigated) { + return; + } + + // Get the path segments (first segment is the action) + const pathSegments = (params.path || localParams.path) as string[] | string | undefined; + const pathArray = Array.isArray(pathSegments) ? pathSegments : pathSegments ? [pathSegments] : []; + + if (pathArray.length === 0) { + // No action specified, go to credentials + router.replace('/(tabs)/credentials'); + setHasNavigated(true); + return; + } + + const [action, ...actionParams] = pathArray; + + // Handle different action types + switch (action) { + case 'mobile-unlock': { + // Mobile unlock action: $/mobile-unlock/[requestId] + const requestId = actionParams[0]; + if (!requestId) { + console.error('[ActionHandler] mobile-unlock requires requestId'); + router.replace('/(tabs)/settings'); + setHasNavigated(true); + return; + } + + // First navigate to settings tab to establish correct navigation stack + router.replace(`/(tabs)/settings/mobile-unlock/${requestId}` as Href); + setHasNavigated(true); + break; + } + + default: + // Unknown action, log and go to credentials + console.warn('[ActionHandler] Unknown action:', action); + router.replace('/(tabs)/credentials'); + setHasNavigated(true); + break; + } + }, [params, localParams, router, hasNavigated]); + + return null; +} diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index 5bd7eb703..0b2b46b30 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -1,9 +1,11 @@ import { Ionicons } from '@expo/vector-icons'; -import { Href, router } from 'expo-router'; +import { router } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, Alert, TouchableOpacity } from 'react-native'; +import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper'; + import { useColors } from '@/hooks/useColorScheme'; import { useVaultSync } from '@/hooks/useVaultSync'; @@ -12,6 +14,7 @@ import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; +import { useNavigation } from '@/context/NavigationContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; /** @@ -21,6 +24,7 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; export default function ReinitializeScreen() : React.ReactNode { const app = useApp(); const dbContext = useDb(); + const navigation = useNavigation(); const { syncVault } = useVaultSync(); const [status, setStatus] = useState(''); const [showSkipButton, setShowSkipButton] = useState(false); @@ -120,38 +124,8 @@ export default function ReinitializeScreen() : React.ReactNode { return; } - // Handle navigation based on return URL - if (!app.returnUrl?.path) { - router.replace('/(tabs)/credentials'); - return; - } - - // Navigate to return URL - const path = app.returnUrl.path as string; - const isDetailRoute = path.includes('credentials/'); - - if (!isDetailRoute) { - router.replace({ - pathname: path as '/', - params: app.returnUrl.params as Record - }); - app.setReturnUrl(null); - return; - } - - // Handle detail routes - const params = app.returnUrl.params as Record; - router.replace('/(tabs)/credentials'); - setTimeout(() => { - if (params.serviceUrl) { - router.push(`${path}?serviceUrl=${params.serviceUrl}` as Href); - } else if (params.id) { - router.push(`${path}?id=${params.id}` as Href); - } else { - router.push(path as Href); - } - }, 0); - app.setReturnUrl(null); + // Use centralized navigation logic + navigation.navigateAfterUnlock(); } catch (err) { console.error('Error during offline vault unlock:', err); router.replace('/unlock'); @@ -186,7 +160,7 @@ export default function ReinitializeScreen() : React.ReactNode { } ] ); - }, [app, dbContext, t, updateStatus]); + }, [app, dbContext, navigation, t, updateStatus]); useEffect(() => { if (hasInitialized.current) { @@ -195,49 +169,6 @@ export default function ReinitializeScreen() : React.ReactNode { hasInitialized.current = true; - /** - * Redirect to the return URL. - */ - function redirectToReturnUrl() : void { - /** - * Simulate stack navigation. - */ - function simulateStackNavigation(from: string, to: string) : void { - router.replace(from as Href); - setTimeout(() => { - router.push(to as Href); - }, 0); - } - - if (app.returnUrl?.path) { - // Type assertion needed due to router type limitations - const path = app.returnUrl.path as '/'; - const isDetailRoute = path.includes('credentials/'); - if (isDetailRoute) { - // If there is a "serviceUrl" or "id" param from the return URL, use it. - const params = app.returnUrl.params as Record; - - if (params.serviceUrl) { - simulateStackNavigation('/(tabs)/credentials', `${path}?serviceUrl=${params.serviceUrl}`); - } else if (params.id) { - simulateStackNavigation('/(tabs)/credentials', `${path}?id=${params.id}`); - } else { - simulateStackNavigation('/(tabs)/credentials', path as string); - } - } else { - router.replace({ - pathname: path, - params: app.returnUrl.params as Record - }); - } - // Clear the return URL after using it - app.setReturnUrl(null); - } else { - // If there is no return URL, navigate to the credentials tab as default entry page. - router.replace('/(tabs)/credentials'); - } - } - /** * Initialize the app. */ @@ -257,43 +188,35 @@ export default function ReinitializeScreen() : React.ReactNode { const isAlreadyUnlocked = await NativeVaultManager.isVaultUnlocked(); if (!isAlreadyUnlocked) { - // Check if we have an encrypted database and if FaceID is enabled + // Check if we have an encrypted database try { const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); if (hasEncryptedDatabase) { - const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + // Attempt automatic unlock using centralized helper + updateStatus(t('app.status.unlockingVault')); + const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault }); - // Only attempt to unlock if FaceID is enabled - if (isFaceIDEnabled) { - // Unlock vault FIRST (before network sync) - this is not skippable - updateStatus(t('app.status.unlockingVault')); - const isUnlocked = await dbContext.unlockVault(); - - if (!isUnlocked) { - // Failed to unlock, redirect to unlock screen - router.replace('/unlock'); - return; - } - - // Add small delay for UX - await new Promise(resolve => setTimeout(resolve, 500)); - updateStatus(t('app.status.decryptingVault')); - await new Promise(resolve => setTimeout(resolve, 750)); - - // Check if the vault needs migration before syncing - if (await dbContext.hasPendingMigrations()) { - router.replace('/upgrade'); - return; - } - - // Vault unlocked successfully - now allow skip button for network operations - canShowSkipButtonRef.current = true; - } else { - // No FaceID, redirect to unlock screen + if (!unlockResult.success) { + // Unlock failed, redirect to unlock screen + console.error('Automatic unlock failed:', unlockResult.error); router.replace('/unlock'); return; } + + // Add small delay for UX + await new Promise(resolve => setTimeout(resolve, 300)); + updateStatus(t('app.status.decryptingVault')); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check if the vault needs migration before syncing + if (await dbContext.hasPendingMigrations()) { + router.replace('/upgrade'); + return; + } + + // Vault unlocked successfully - now allow skip button for network operations + canShowSkipButtonRef.current = true; } else { // No encrypted database, redirect to unlock screen router.replace('/unlock'); @@ -333,8 +256,7 @@ export default function ReinitializeScreen() : React.ReactNode { * Handle successful vault sync. */ onSuccess: async () => { - // Vault already unlocked, just navigate to return URL - redirectToReturnUrl(); + navigation.navigateAfterUnlock(); }, /** * Handle error during vault sync. @@ -342,8 +264,8 @@ export default function ReinitializeScreen() : React.ReactNode { */ onError: (error: string) => { console.error('Vault sync error during reinitialize:', error); - // Even if sync fails, vault is already unlocked, so navigate to return URL - redirectToReturnUrl(); + // Even if sync fails, vault is already unlocked, use centralized navigation + navigation.navigateAfterUnlock(); }, /** * Handle offline state and prompt user for action. @@ -361,7 +283,7 @@ export default function ReinitializeScreen() : React.ReactNode { }; initialize(); - }, [syncVault, app, dbContext, t, handleOfflineFlow, updateStatus]); + }, [syncVault, app, dbContext, navigation, t, handleOfflineFlow, updateStatus]); /** * Handle skip button press by calling the offline handler. diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 688b7fe64..6af94798c 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -20,6 +20,7 @@ import { Avatar } from '@/components/ui/Avatar'; import { RobustPressable } from '@/components/ui/RobustPressable'; import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; +import { useNavigation } from '@/context/NavigationContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; /** @@ -28,6 +29,7 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; export default function UnlockScreen() : React.ReactNode { const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams, logout } = useApp(); const dbContext = useDb(); + const navigation = useNavigation(); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isBiometricsAvailable, setIsBiometricsAvailable] = useState(false); @@ -56,90 +58,30 @@ export default function UnlockScreen() : React.ReactNode { return params; }, [logout, getEncryptionKeyDerivationParams]); - /** - * Handle PIN unlock using native UI. - * Falls back to showing password input on cancel. - */ - const handlePinUnlock = useCallback(async () : Promise => { - try { - /* - * Show native PIN unlock UI - * This will handle the unlock internally and store the encryption key - */ - await NativeVaultManager.showPinUnlock(); - - /* - * Check if the vault is ready - */ - if (dbContext.dbAvailable) { - // Check if the vault is up to date, if not, redirect to the upgrade page. - if (await dbContext.hasPendingMigrations()) { - router.replace('/upgrade'); - return; - } - - /* - * Navigate to initialize page which will handle vault sync and then navigate to credentials - * This ensures we always check for vault updates even after local unlock - */ - router.replace('/initialize'); - } else { - // If db is not available for whatever reason, fallback to password unlock. - setIsLoading(false); - Alert.alert( - t('common.error'), - t('auth.errors.incorrectPassword'), - [{ text: t('common.ok'), style: 'default' }] - ); - } - } catch (err: unknown) { - // User cancelled or error occurred - setIsLoading(false); - - if (err && typeof err === 'object' && 'code' in err) { - // Show password input as fallback on error - console.error('PIN unlock failed:', err); - - // Check if PIN is still enabled and update state accordingly - const pinStillEnabled = await NativeVaultManager.isPinEnabled(); - setPinAvailable(pinStillEnabled); - return; - } - } - }, [dbContext, t, setPinAvailable]); - useEffect(() => { getKeyDerivationParams(); /** - * Fetch the biometric config and PIN availability, then attempt unlock. + * Fetch the biometric config and PIN availability. */ - const fetchConfigAndUnlock = async () : Promise => { + const fetchConfig = async () : Promise => { + // Check if biometrics is available const enabled = await isBiometricsEnabled(); setIsBiometricsAvailable(enabled); const displayName = await getBiometricDisplayName(); setBiometricDisplayName(displayName); - // Check PIN availability + // Check if PIN is enabled const pinEnabled = await NativeVaultManager.isPinEnabled(); setPinAvailable(pinEnabled); - /* - * If PIN is enabled, automatically try PIN unlock first - * Show loading state, then launch native PIN UI - * If user cancels or PIN is not available, loading stops and password input shows - */ - if (pinEnabled) { - await handlePinUnlock(); - } else { - // No PIN available, stop loading to show password input - setIsLoading(false); - } + // Stop loading to show password input + setIsLoading(false); }; - fetchConfigAndUnlock(); + fetchConfig(); - }, [isBiometricsEnabled, getKeyDerivationParams, getBiometricDisplayName, t, handlePinUnlock]); + }, [isBiometricsEnabled, getKeyDerivationParams, getBiometricDisplayName]); /** * Handle the unlock. @@ -188,10 +130,10 @@ export default function UnlockScreen() : React.ReactNode { } /* - * Navigate to initialize page which will handle vault sync and then navigate to credentials - * This ensures we always check for vault updates even after local unlock + * Navigate using centralized navigation logic + * This ensures we handle pending deep links and return URLs correctly */ - router.replace('/initialize'); + navigation.navigateAfterUnlock(); } else { Alert.alert( t('common.error'), @@ -231,7 +173,7 @@ export default function UnlockScreen() : React.ReactNode { /** * Handle the biometrics retry. */ - const handleBiometricsRetry = async () : Promise => { + const handleUnlockRetry = async () : Promise => { router.replace('/reinitialize'); }; @@ -458,7 +400,7 @@ export default function UnlockScreen() : React.ReactNode { {isBiometricsAvailable && ( {t('auth.tryBiometricAgain', { biometric: biometricDisplayName })} @@ -466,15 +408,12 @@ export default function UnlockScreen() : React.ReactNode { {/* Use PIN Button */} {pinAvailable && ( - { - setIsLoading(true); - handlePinUnlock(); - }} + - {t('auth.unlockWithPin')} - + {t('auth.tryPinAgain')} + )} diff --git a/apps/mobile-app/components/LoadingIndicator.tsx b/apps/mobile-app/components/LoadingIndicator.tsx index 142570027..dc8e0f030 100644 --- a/apps/mobile-app/components/LoadingIndicator.tsx +++ b/apps/mobile-app/components/LoadingIndicator.tsx @@ -4,13 +4,13 @@ import { StyleSheet, View, Text, Animated, useColorScheme } from 'react-native'; import { useColors } from '@/hooks/useColorScheme'; type LoadingIndicatorProps = { - status: string; + status?: string | null; }; /** * Loading indicator component. */ -export default function LoadingIndicator({ status }: LoadingIndicatorProps): React.ReactNode { +export default function LoadingIndicator({ status = '' }: LoadingIndicatorProps): React.ReactNode { const colors = useColors(); const dot1Anim = useRef(new Animated.Value(0)).current; const dot2Anim = useRef(new Animated.Value(0)).current; @@ -89,8 +89,9 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea * If the status ends with a pipe character (|), don't show any dots * This provides an explicit way to disable the loading dots animation */ - const statusTrimmed = status.endsWith('|') ? status.slice(0, -1) : status; - const shouldShowDots = !status.endsWith('|'); + const statusText = status || ''; + const statusTrimmed = statusText.endsWith('|') ? statusText.slice(0, -1) : statusText; + const shouldShowDots = statusText.length > 0 && !statusText.endsWith('|'); const backgroundColor = colorScheme === 'dark' ? 'transparent' : '#fff'; const shadowColor = '#000'; diff --git a/apps/mobile-app/components/credentials/CredentialCard.tsx b/apps/mobile-app/components/credentials/CredentialCard.tsx index 5b8a61453..d28878280 100644 --- a/apps/mobile-app/components/credentials/CredentialCard.tsx +++ b/apps/mobile-app/components/credentials/CredentialCard.tsx @@ -83,8 +83,6 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar const handleContextMenuAction = async (event: OnPressMenuItemEvent): Promise => { const { name } = event.nativeEvent; - console.log('handleContextMenuAction', name); - switch (name) { case t('credentials.contextMenu.edit'): Keyboard.dismiss(); 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/context/AppContext.tsx b/apps/mobile-app/context/AppContext.tsx index b62db73fe..3c8493ed3 100644 --- a/apps/mobile-app/context/AppContext.tsx +++ b/apps/mobile-app/context/AppContext.tsx @@ -34,9 +34,6 @@ type AppContextType = { // Autofill methods shouldShowAutofillReminder: boolean; markAutofillConfigured: () => Promise; - // Return URL methods - returnUrl: { path: string; params?: object } | null; - setReturnUrl: (url: { path: string; params?: object } | null) => void; } export type AuthMethod = 'faceid' | 'password'; @@ -105,7 +102,6 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children username: auth.username, isOffline: auth.isOffline, shouldShowAutofillReminder: auth.shouldShowAutofillReminder, - returnUrl: auth.returnUrl, // Wrap auth methods logout, initializeAuth: auth.initializeAuth, @@ -126,14 +122,12 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children verifyPassword: auth.verifyPassword, getEncryptionKeyDerivationParams: auth.getEncryptionKeyDerivationParams, markAutofillConfigured: auth.markAutofillConfigured, - setReturnUrl: auth.setReturnUrl, }), [ auth.isInitialized, auth.isLoggedIn, auth.username, auth.isOffline, auth.shouldShowAutofillReminder, - auth.returnUrl, auth.initializeAuth, auth.setAuthTokens, auth.login, @@ -151,8 +145,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children auth.verifyPassword, auth.getEncryptionKeyDerivationParams, auth.markAutofillConfigured, - auth.setReturnUrl, - logout, + logout ]); return ( diff --git a/apps/mobile-app/context/AuthContext.tsx b/apps/mobile-app/context/AuthContext.tsx index 5f9d4119e..7a821d821 100644 --- a/apps/mobile-app/context/AuthContext.tsx +++ b/apps/mobile-app/context/AuthContext.tsx @@ -3,10 +3,8 @@ import { Buffer } from 'buffer'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { NavigationContainerRef, ParamListBase } from '@react-navigation/native'; import * as LocalAuthentication from 'expo-local-authentication'; -import { router, useGlobalSearchParams, usePathname } from 'expo-router'; -import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { Alert, AppState, Platform } from 'react-native'; - +import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { Alert, Platform } from 'react-native'; import EncryptionUtility from '@/utils/EncryptionUtility'; import { useDb } from '@/context/DbContext'; @@ -43,9 +41,6 @@ type AuthContextType = { // Autofill methods shouldShowAutofillReminder: boolean; markAutofillConfigured: () => Promise; - // Return URL methods - returnUrl: { path: string; params?: object } | null; - setReturnUrl: (url: { path: string; params?: object } | null) => void; } const AUTOFILL_CONFIGURED_KEY = 'autofill_configured'; @@ -59,22 +54,15 @@ const AuthContext = createContext(undefined); /** * AuthProvider to provide the authentication state to the app that components can use. */ -export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const AuthProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [username, setUsername] = useState(null); const [shouldShowAutofillReminder, setShouldShowAutofillReminder] = useState(false); - const [returnUrl, setReturnUrl] = useState<{ path: string; params?: object } | null>(null); const [isOffline, setIsOffline] = useState(false); - const appState = useRef(AppState.currentState); const dbContext = useDb(); - const pathname = usePathname(); - const params = useGlobalSearchParams(); - const lastRouteRef = useRef<{ path: string, params?: object }>({ path: pathname, params }); - - useEffect(() => { - lastRouteRef.current = { path: pathname, params }; - }, [pathname, params]); /** * Get enabled auth methods from the native module @@ -196,7 +184,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children * This is called by AppContext after revoking tokens on the server. */ const clearAuth = useCallback(async (errorMessage?: string): Promise => { - console.log('Clearing auth --- attempt'); // Clear credential identity store (password and passkey autofill metadata) try { await NativeVaultManager.removeCredentialIdentities(); @@ -364,18 +351,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, []); - /** - * Check if the vault is unlocked. - */ - const isVaultUnlocked = useCallback(async (): Promise => { - try { - return await NativeVaultManager.isVaultUnlocked(); - } catch (error) { - console.error('Failed to check vault status:', error); - return false; - } - }, []); - /** * Get the encryption key derivation parameters from native storage. * Returns parsed parameters or null if not available. @@ -426,46 +401,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children return currentPasswordHashBase64; }, [dbContext, getEncryptionKeyDerivationParams]); - // Handle app state changes - useEffect(() => { - const subscription = AppState.addEventListener('change', async (nextAppState) => { - if (appState.current.match(/inactive|background/) && nextAppState === 'active') { - /** - * App coming to foreground - * Skip vault re-initialization checks during unlock, login, initialize, and reinitialize flows to prevent race conditions - * where the AppState listener fires during app initialization, especially on iOS release builds. - */ - if (!pathname?.includes('unlock') && !pathname?.includes('login') && !pathname?.includes('initialize') && !pathname?.includes('reinitialize')) { - try { - // Check if vault is unlocked. - const isUnlocked = await isVaultUnlocked(); - if (!isUnlocked) { - // Get current full URL including query params - const currentRoute = lastRouteRef.current; - if (currentRoute?.path) { - setReturnUrl({ - path: currentRoute.path, - params: currentRoute.params - }); - } - - // Database connection failed, navigate to reinitialize flow - router.replace('/reinitialize'); - } - } catch { - // Database query failed, navigate to reinitialize flow - router.replace('/reinitialize'); - } - } - } - appState.current = nextAppState; - }); - - return (): void => { - subscription.remove(); - }; - }, [isVaultUnlocked, pathname]); - /** * Load autofill state from storage */ @@ -500,7 +435,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children isInitialized, username, shouldShowAutofillReminder, - returnUrl, isOffline, getEnabledAuthMethods, isBiometricsEnabled, @@ -517,7 +451,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setClipboardClearTimeout, getBiometricDisplayName, markAutofillConfigured, - setReturnUrl, verifyPassword, getEncryptionKeyDerivationParams, setOfflineMode, @@ -526,7 +459,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children isInitialized, username, shouldShowAutofillReminder, - returnUrl, isOffline, getEnabledAuthMethods, isBiometricsEnabled, @@ -543,7 +475,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setClipboardClearTimeout, getBiometricDisplayName, markAutofillConfigured, - setReturnUrl, verifyPassword, getEncryptionKeyDerivationParams, setOfflineMode, diff --git a/apps/mobile-app/context/NavigationContext.tsx b/apps/mobile-app/context/NavigationContext.tsx new file mode 100644 index 000000000..d274d6b18 --- /dev/null +++ b/apps/mobile-app/context/NavigationContext.tsx @@ -0,0 +1,229 @@ +import { Href, useRouter, usePathname, useGlobalSearchParams } from 'expo-router'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { AppState } from 'react-native'; + +import NativeVaultManager from '@/specs/NativeVaultManager'; + +type NavigationContextType = { + /** + * Return URL to navigate to after successful vault unlock. + * This is set when the app is backgrounded and vault is locked. + */ + returnUrl: { path: string; params?: Record } | null; + + /** + * Set the return URL for post-unlock navigation. + */ + setReturnUrl: (url: { path: string; params?: Record } | null) => void; + + /** + * Navigate to the appropriate destination after successful vault unlock. + * Handles return URLs and default navigation to credentials tab. + */ + navigateAfterUnlock: () => void; +} + +const NavigationContext = createContext(undefined); + +/** + * NavigationProvider to provide centralized navigation logic, particularly for post-unlock flows. + */ +export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const router = useRouter(); + const pathname = usePathname(); + const params = useGlobalSearchParams(); + const [returnUrl, setReturnUrl] = useState<{ path: string; params?: Record } | null>(null); + const appState = useRef(AppState.currentState); + const lastRouteRef = useRef<{ path: string, params?: object }>({ path: pathname, params }); + + // Track current route for vault lock recovery + useEffect(() => { + lastRouteRef.current = { path: pathname, params }; + }, [pathname, params]); + + /** + * Navigate to the appropriate destination after successful vault unlock. + * Priority order: + * 1. Return URL (from reinitialize flow or _layout.tsx) + * 2. Default credentials tab + */ + const navigateAfterUnlock = useCallback((): void => { + // Priority 1: Handle return URL (from reinitialize flow) + if (returnUrl?.path) { + const url = returnUrl; + setReturnUrl(null); + handleReturnUrl(url, router); + return; + } + + // Priority 2: Default navigation to credentials + router.replace('/(tabs)/credentials'); + }, [returnUrl, router]); + + /** + * Handle return URL navigation (from reinitialize flow). + */ + const handleReturnUrl = ( + returnUrl: { path: string; params?: Record }, + router: ReturnType + ): void => { + // Normalize the path using centralized function + const normalizedPath = normalizeDeepLinkPath(returnUrl.path); + const params = returnUrl.params || {}; + + // Check if this is a detail route (has a sub-page after the tab) + const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/'); + const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') && + !normalizedPath.endsWith('/(tabs)/settings'); + + if (isCredentialRoute) { + // Navigate to credentials tab first, then push detail page + router.replace('/(tabs)/credentials'); + setTimeout(() => { + const queryParams = new URLSearchParams(params as Record).toString(); + const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath; + router.push(targetUrl as Href); + }, 0); + } else if (isSettingsRoute) { + // Navigate to settings tab first, then push detail page + router.replace('/(tabs)/settings'); + setTimeout(() => { + const queryParams = new URLSearchParams(params as Record).toString(); + const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath; + router.push(targetUrl as Href); + }, 0); + } else { + // Direct navigation for root tab routes + // If there are query params, append them as query string + if (Object.keys(params).length > 0) { + const queryParams = new URLSearchParams(params as Record).toString(); + const targetUrl = `${normalizedPath}?${queryParams}`; + router.replace(targetUrl as Href); + } else { + router.replace(normalizedPath as Href); + } + } + }; + + /** + * Normalize a deep link or path to ensure it has the correct /(tabs)/ prefix. + * + * Supports: + * - Action-based URLs: aliasvault://open/mobile-unlock/[id] + * - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page] + */ + const normalizeDeepLinkPath = (urlOrPath: string): string => { + // Remove all URL schemes first + let path = urlOrPath + .replace('net.aliasvault.app://', '') + .replace('aliasvault://', '') + .replace('exp+aliasvault://', ''); + + // If it already has /(tabs)/ prefix, return as is + if (path.startsWith('/(tabs)/')) { + return path; + } + + // Handle credential paths + if (path.startsWith('credentials/') || path.includes('/credentials/')) { + if (!path.startsWith('/')) { + path = `/${path}`; + } + return `/(tabs)${path}`; + } + + // Handle settings paths + if (path.startsWith('settings/') || path.startsWith('/settings')) { + if (!path.startsWith('/')) { + path = `/${path}`; + } + return `/(tabs)${path}`; + } + + return path; + }; + + /** + * Check if the vault is unlocked. + */ + const isVaultUnlocked = useCallback(async (): Promise => { + try { + return await NativeVaultManager.isVaultUnlocked(); + } catch (error) { + console.error('Failed to check vault status:', error); + return false; + } + }, []); + + /** + * Handle app state changes - detect when vault is locked and save return URL. + */ + useEffect(() => { + const appstateSubscription = AppState.addEventListener('change', async (nextAppState) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + /** + * App coming to foreground + * Skip vault re-initialization checks during unlock, login, initialize, and reinitialize flows to prevent race conditions + * where the AppState listener fires during app initialization, especially on iOS release builds. + * Also skip during mobile-unlock flow as it has its own authentication. + */ + if (!pathname?.startsWith('/unlock') && + !pathname?.startsWith('/login') && + !pathname?.startsWith('/initialize') && + !pathname?.startsWith('/reinitialize') && + !pathname?.includes('/mobile-unlock/')) { + try { + // Check if vault is unlocked. + const isUnlocked = await isVaultUnlocked(); + if (!isUnlocked) { + // Get current full URL including query params + const currentRoute = lastRouteRef.current; + if (currentRoute?.path) { + setReturnUrl({ + path: currentRoute.path, + params: currentRoute.params as Record + }); + } + + // Database connection failed, navigate to reinitialize flow + console.log('database connection failed, navigating to reinitialize'); + router.replace('/reinitialize'); + } + } catch { + // Database query failed, navigate to reinitialize flow + console.log('database query failed, navigating to reinitialize'); + router.replace('/reinitialize'); + } + } + } + appState.current = nextAppState; + }); + + return (): void => { + appstateSubscription.remove(); + }; + }, [isVaultUnlocked, pathname, router]); + + const contextValue = useMemo(() => ({ + returnUrl, + setReturnUrl, + navigateAfterUnlock, + }), [returnUrl, navigateAfterUnlock]); + + return ( + + {children} + + ); +}; + +/** + * Hook to use the NavigationContext. + */ +export const useNavigation = (): NavigationContextType => { + const context = useContext(NavigationContext); + if (context === undefined) { + throw new Error('useNavigation must be used within a NavigationProvider'); + } + return context; +}; diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index ce58baca9..ebb95d8fe 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -8,7 +8,7 @@ "no": "No", "ok": "OK", "continue": "Continue", - "loading": "Loading...", + "loading": "Loading", "error": "Error", "success": "Success", "never": "Never", @@ -22,7 +22,8 @@ "disabled": "Disabled", "errors": { "unknownError": "An unknown error occurred. Please try again.", - "unknownErrorTryAgain": "An unknown error occurred. Please try again." + "unknownErrorTryAgain": "An unknown error occurred. Please try again.", + "serverVersionTooOld": "The AliasVault server needs to be updated to a newer version in order to use this feature. Please contact the server admin if you need help." } }, "auth": { @@ -48,6 +49,7 @@ "enableBiometric": "Enable {{biometric}}?", "biometricPrompt": "Would you like to use {{biometric}} to unlock your vault?", "tryBiometricAgain": "Try {{biometric}} Again", + "tryPinAgain": "Try PIN Again", "authCodeNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.", "errors": { "credentialsRequired": "Username and password are required", @@ -395,6 +397,23 @@ "failedToDelete": "Failed to delete account. Please try again.", "usernameNotFound": "Username not found. Please login again." } + }, + "qrScanner": { + "title": "QR Code Scanner", + "scanningMessage": "Scan AliasVault QR code", + "invalidQrCode": "Invalid QR Code", + "notAliasVaultQr": "This is not a valid AliasVault QR code. Please scan a QR code generated by AliasVault.", + "cameraPermissionTitle": "Camera Permission Required", + "cameraPermissionMessage": "Please allow camera access to scan QR codes.", + "mobileLogin": { + "confirmTitle": "Confirm Login Request", + "confirmSubtitle": "Re-authenticate to approve login on another device.", + "confirmMessage": "You are about to log in on a remote device with your account. This other device will have full access to your vault. Only proceed if you trust this device.", + "successDescription": "The remote device has been successfully logged in.", + "requestExpired": "This login request has expired. Please generate a new QR code.", + "authenticationFailed": "Authentication failed. Please try again.", + "noAuthMethodEnabled": "Biometric or PIN unlock needs to be enabled to unlock with mobile" + } } }, "navigation": { diff --git a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj index 870cf1989..fa62d62bd 100644 --- a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj +++ b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -212,7 +212,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -222,13 +222,84 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - CE59C7602E4F47FD0024A246 /* VaultUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUITests; sourceTree = ""; }; - CE77825E2EA1822400A75E6F /* VaultUtils */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUtils; sourceTree = ""; }; - CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = ""; }; - CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = ""; }; - CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = ""; }; - CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = ""; }; - CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = ""; }; + CE59C7602E4F47FD0024A246 /* VaultUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultUITests; + sourceTree = ""; + }; + CE77825E2EA1822400A75E6F /* VaultUtils */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultUtils; + sourceTree = ""; + }; + CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultStoreKit; + sourceTree = ""; + }; + CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultStoreKitTests; + sourceTree = ""; + }; + CEE4816B2DBE8AC800F4A367 /* VaultUI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultUI; + sourceTree = ""; + }; + CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultModels; + sourceTree = ""; + }; + CEE909812DA548C7008D568F /* Autofill */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Autofill; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1347,7 +1418,10 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -1401,7 +1475,10 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/apps/mobile-app/ios/AliasVault/Info.plist b/apps/mobile-app/ios/AliasVault/Info.plist index ba9c0957a..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 @@ -72,6 +79,8 @@ NSAllowsLocalNetworking + NSCameraUsageDescription + AliasVault supports scanning QR codes for logging into your vault on web or desktop without re-entering your master password. NSFaceIDUsageDescription AliasVault uses Face ID to securely store your encryption key, allowing you to access your encrypted data with facial authentication. NSUserActivityTypes diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index 7e4122cc4..5abc17116 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -231,6 +231,12 @@ [vaultManager clearUsername:resolve rejecter:reject]; } +// MARK: - Server Version Management + +- (void)isServerVersionGreaterThanOrEqualTo:(NSString *)targetVersion resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager isServerVersionGreaterThanOrEqualTo:targetVersion resolver:resolve rejecter:reject]; +} + // MARK: - Offline Mode Management - (void)setOfflineMode:(BOOL)isOffline resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { @@ -273,4 +279,16 @@ [vaultManager showPinSetup:resolve rejecter:reject]; } +// MARK: - Mobile Login + +- (void)encryptDecryptionKeyForMobileLogin:(NSString *)publicKeyJWK resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager encryptDecryptionKeyForMobileLogin:publicKeyJWK resolver:resolve rejecter:reject]; +} + +// MARK: - Re-authentication + +- (void)authenticateUser:(NSString *)title subtitle:(NSString *)subtitle resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager authenticateUser:title subtitle:subtitle 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..1b4e6699f 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -649,6 +649,16 @@ public class VaultManager: NSObject { resolve(nil) } + // MARK: - Server Version Management + + @objc + func isServerVersionGreaterThanOrEqualTo(_ targetVersion: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + let isGreaterOrEqual = vaultStore.isServerVersionGreaterThanOrEqualTo(targetVersion) + resolve(isGreaterOrEqual) + } + // MARK: - Offline Mode Management @objc @@ -887,6 +897,89 @@ public class VaultManager: NSObject { } } + @objc + func encryptDecryptionKeyForMobileLogin(_ 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.encryptDecryptionKeyForMobileLogin(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(_ title: String?, + subtitle: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + // Check if PIN is enabled first + let pinEnabled = vaultStore.isPinEnabled() + + if pinEnabled { + // PIN is enabled, show PIN unlock UI + DispatchQueue.main.async { [weak self] in + guard let self = self else { + reject("INTERNAL_ERROR", "VaultManager instance deallocated", nil) + return + } + + // Get the root view controller from React Native + guard let rootVC = RCTPresentedViewController() else { + reject("NO_VIEW_CONTROLLER", "No view controller available", nil) + return + } + + // Create PIN unlock view with ViewModel + // Use custom title/subtitle if provided, otherwise use defaults + let customTitle = (title?.isEmpty == false) ? title : nil + let customSubtitle = (subtitle?.isEmpty == false) ? subtitle : nil + let viewModel = PinUnlockViewModel( + pinLength: self.vaultStore.getPinLength(), + customTitle: customTitle, + customSubtitle: customSubtitle, + unlockHandler: { [weak self] pin in + guard let self = self else { + throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "VaultManager instance deallocated"]) + } + + // Unlock vault with PIN (just validates, doesn't store in memory) + _ = try self.vaultStore.unlockWithPin(pin) + + // Success - dismiss and resolve + await MainActor.run { + rootVC.dismiss(animated: true) { + resolve(true) + } + } + }, + cancelHandler: { + // User cancelled - dismiss and resolve with false + rootVC.dismiss(animated: true) { + resolve(false) + } + } + ) + + let pinView = PinUnlockView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: pinView) + + // Present modally as full screen + hostingController.modalPresentationStyle = .fullScreen + rootVC.present(hostingController, animated: true) + } + } else { + // Use biometric authentication + let authenticated = vaultStore.issueBiometricAuthentication(title: title) + resolve(authenticated) + } + } + @objc func requiresMainQueueSetup() -> Bool { return false diff --git a/apps/mobile-app/ios/Podfile.lock b/apps/mobile-app/ios/Podfile.lock index 5923a0124..42b9df0ad 100644 --- a/apps/mobile-app/ios/Podfile.lock +++ b/apps/mobile-app/ios/Podfile.lock @@ -272,6 +272,10 @@ PODS: - ExpoModulesCore - ExpoBlur (14.1.5): - ExpoModulesCore + - ExpoCamera (16.1.11): + - ExpoModulesCore + - ZXingObjC/OneD + - ZXingObjC/PDF417 - ExpoClipboard (7.1.5): - ExpoModulesCore - ExpoDocumentPicker (13.1.6): @@ -2445,6 +2449,11 @@ PODS: - SwiftLint (0.59.1) - SWXMLHash (7.0.2) - Yoga (0.0.0) + - ZXingObjC/Core (3.6.9) + - ZXingObjC/OneD (3.6.9): + - ZXingObjC/Core + - ZXingObjC/PDF417 (3.6.9): + - ZXingObjC/Core DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) @@ -2459,6 +2468,7 @@ DEPENDENCIES: - expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/ios`) - ExpoAsset (from `../node_modules/expo-asset/ios`) - ExpoBlur (from `../node_modules/expo-blur/ios`) + - ExpoCamera (from `../node_modules/expo-camera/ios`) - ExpoClipboard (from `../node_modules/expo-clipboard/ios`) - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) @@ -2572,6 +2582,7 @@ SPEC REPOS: - SQLite.swift - SwiftLint - SWXMLHash + - ZXingObjC EXTERNAL SOURCES: boost: @@ -2598,6 +2609,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-asset/ios" ExpoBlur: :path: "../node_modules/expo-blur/ios" + ExpoCamera: + :path: "../node_modules/expo-camera/ios" ExpoClipboard: :path: "../node_modules/expo-clipboard/ios" ExpoDocumentPicker: @@ -2807,6 +2820,7 @@ SPEC CHECKSUMS: expo-dev-menu-interface: 609c35ae8b97479cdd4c9e23c8cf6adc44beea0e ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6 ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9 + ExpoCamera: e1879906d41184e84b57d7643119f8509414e318 ExpoClipboard: 436f6de6971f14eb75ae160e076d9cb3b19eb795 ExpoDocumentPicker: b263a279685b6640b8c8bc70d71c83067aeaae55 ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63 @@ -2911,6 +2925,7 @@ SPEC CHECKSUMS: SwiftLint: 3d48e2fb2a3468fdaccf049e5e755df22fb40c2c SWXMLHash: dd733a457e9c4fe93b1538654057aefae4acb382 Yoga: dc7c21200195acacb62fa920c588e7c2106de45e + ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 PODFILE CHECKSUM: ac288e273086bafdd610cafff08ccca0d164f7c3 diff --git a/apps/mobile-app/ios/VaultModels/VaultMetadata.swift b/apps/mobile-app/ios/VaultModels/VaultMetadata.swift index 270b73224..51b8930f4 100644 --- a/apps/mobile-app/ios/VaultModels/VaultMetadata.swift +++ b/apps/mobile-app/ios/VaultModels/VaultMetadata.swift @@ -4,7 +4,7 @@ public struct VaultMetadata: Codable { public var publicEmailDomains: [String]? public var privateEmailDomains: [String]? public var vaultRevisionNumber: Int - + public init(publicEmailDomains: [String]? = nil, privateEmailDomains: [String]? = nil, vaultRevisionNumber: Int) { self.publicEmailDomains = publicEmailDomains self.privateEmailDomains = privateEmailDomains diff --git a/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift b/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift index 1f8f1ff9f..febfc9082 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Constants/VaultConstants.swift @@ -16,6 +16,7 @@ public struct VaultConstants { static let usernameKey = "aliasvault_username" static let offlineModeKey = "aliasvault_offline_mode" static let pinEnabledKey = "aliasvault_pin_enabled" + static let serverVersionKey = "aliasvault_server_version" static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds } diff --git a/apps/mobile-app/ios/VaultStoreKit/Utils/VersionComparison.swift b/apps/mobile-app/ios/VaultStoreKit/Utils/VersionComparison.swift index cba7df6b4..51df422e0 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Utils/VersionComparison.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Utils/VersionComparison.swift @@ -2,9 +2,10 @@ import Foundation /// Utility for comparing semantic version strings public enum VersionComparison { - /// Checks if version1 is greater than or equal to version2, following SemVer rules. + /// Checks if version1 is greater than or equal to version2, ignoring pre-release suffixes. /// - /// Pre-release versions (e.g., -alpha, -beta, -dev) are considered lower than release versions. + /// Pre-release suffixes (e.g., -alpha, -beta, -dev) are stripped from version1 before comparison. + /// This allows server versions like "0.25.0-alpha-dev" to be treated as "0.25.0". /// /// - Parameters: /// - version1: First version string (e.g., "1.2.3" or "1.2.3-beta") @@ -14,25 +15,22 @@ public enum VersionComparison { /// - Example: /// ```swift /// VersionComparison.isGreaterThanOrEqualTo("1.2.3", "1.2.0") // true - /// VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // false - /// VersionComparison.isGreaterThanOrEqualTo("1.2.0", "1.2.0-alpha") // true + /// VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // true (ignores -alpha) + /// VersionComparison.isGreaterThanOrEqualTo("1.2.0-dev", "1.2.1") // false (0.25.0 < 0.25.1) /// ``` public static func isGreaterThanOrEqualTo(_ version1: String, _ version2: String) -> Bool { - // Split versions into core and pre-release parts + // Strip pre-release suffix from version1 (server version) let components1 = version1.split(separator: "-", maxSplits: 1) let components2 = version2.split(separator: "-", maxSplits: 1) let core1 = String(components1[0]) let core2 = String(components2[0]) - let preRelease1 = components1.count > 1 ? String(components1[1]) : nil - let preRelease2 = components2.count > 1 ? String(components2[1]) : nil - // Parse core version numbers let parts1 = core1.split(separator: ".").compactMap { Int($0) } let parts2 = core2.split(separator: ".").compactMap { Int($0) } - // Compare core versions first + // Compare core versions only let maxLength = max(parts1.count, parts2.count) for iVal in 0..= preRelease2! + // Core versions are equal + return true } /// Checks if a given server version meets the minimum requirement diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift index e6f800a95..775fb397b 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift @@ -41,4 +41,46 @@ extension VaultStore { public func getAuthMethods() -> AuthMethods { return self.enabledAuthMethods } + + /// Authenticate the user using biometric authentication only + /// Note: This method only handles biometric authentication. + /// Returns true if authentication succeeded, false otherwise + /// - Parameter title: The title for authentication. Optional, defaults to "Unlock Vault" context. + public func issueBiometricAuthentication(title: String?) -> Bool { + // Use title if provided, otherwise default + let authReason = (title?.isEmpty == false) ? title! : "Unlock Vault" + + // Check if biometric authentication is enabled + guard self.enabledAuthMethods.contains(.faceID) else { + print("No authentication method enabled") + return false + } + + 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")") + return false + } + + // Perform biometric authentication synchronously + var authenticated = false + let semaphore = DispatchSemaphore(value: 0) + + context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: authReason + ) { 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+Metadata.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift index 42fcb05dd..552555158 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift @@ -85,4 +85,33 @@ extension VaultStore { public func getOfflineMode() -> Bool { return userDefaults.bool(forKey: VaultConstants.offlineModeKey) } + + // MARK: - Server Version Storage + + /// Set the server API version + public func setServerVersion(_ version: String) { + userDefaults.set(version, forKey: VaultConstants.serverVersionKey) + userDefaults.synchronize() + } + + /// Get the server API version + public func getServerVersion() -> String? { + return userDefaults.string(forKey: VaultConstants.serverVersionKey) + } + + /// Clear the server version + public func clearServerVersion() { + userDefaults.removeObject(forKey: VaultConstants.serverVersionKey) + userDefaults.synchronize() + } + + /// Check if the stored server version is greater than or equal to the specified version + /// - Parameter targetVersion: The version to compare against (e.g., "0.25.0") + /// - Returns: true if stored server version >= targetVersion, false if server version not available or less than target + public func isServerVersionGreaterThanOrEqualTo(_ targetVersion: String) -> Bool { + guard let serverVersion = getServerVersion() else { + return false // No server version stored yet + } + return VersionComparison.isGreaterThanOrEqualTo(serverVersion, targetVersion) + } } 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..04823cbda --- /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 login + /// 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 encryptDecryptionKeyForMobileLogin(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/ios/VaultStoreKit/VaultStore+Sync.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift index 31c0b3492..aefac0ab0 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift @@ -130,6 +130,9 @@ extension VaultStore { throw VaultSyncError.serverVersionNotSupported } + // Store server version in metadata + setServerVersion(status.serverVersion) + try validateSrpSalt(status.srpSalt) return status } diff --git a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift index 9cfcf1426..30be4ae30 100644 --- a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift +++ b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift @@ -46,13 +46,13 @@ public struct PinUnlockView: View { .padding(.bottom, 12) // Title - Text(String(localized: "unlock_vault", bundle: locBundle)) + Text(viewModel.customTitle ?? String(localized: "unlock_vault", bundle: locBundle)) .font(.system(size: 22, weight: .semibold)) .foregroundColor(colors.text) .padding(.bottom, 6) // Subtitle - Text(String(format: String(localized: "enter_pin_to_unlock_vault", bundle: locBundle))) + Text(viewModel.customSubtitle ?? String(format: String(localized: "enter_pin_to_unlock_vault", bundle: locBundle))) .font(.system(size: 15)) .foregroundColor(colors.text.opacity(0.7)) .multilineTextAlignment(.center) @@ -219,15 +219,21 @@ public class PinUnlockViewModel: ObservableObject { @Published public var isUnlocking: Bool = false public let pinLength: Int? + public let customTitle: String? + public let customSubtitle: String? private let unlockHandler: (String) async throws -> Void private let cancelHandler: () -> Void public init( pinLength: Int?, + customTitle: String? = nil, + customSubtitle: String? = nil, unlockHandler: @escaping (String) async throws -> Void, cancelHandler: @escaping () -> Void ) { self.pinLength = pinLength + self.customTitle = customTitle + self.customSubtitle = customSubtitle self.unlockHandler = unlockHandler self.cancelHandler = cancelHandler } diff --git a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift index 80b966302..9bc7460fe 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift @@ -103,7 +103,7 @@ public struct CredentialCard: View { } Button(action: { - if let url = URL(string: "net.aliasvault.app://credentials/\(credential.id.uuidString)") { + if let url = URL(string: "aliasvault://credentials/\(credential.id.uuidString)") { UIApplication.shared.open(url) } }, label: { @@ -111,7 +111,7 @@ public struct CredentialCard: View { }) Button(action: { - if let url = URL(string: "net.aliasvault.app://credentials/add-edit-page?id=\(credential.id.uuidString)") { + if let url = URL(string: "aliasvault://credentials/add-edit-page?id=\(credential.id.uuidString)") { UIApplication.shared.open(url) } }, label: { diff --git a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift index 196b1828e..99e52cc0f 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift @@ -60,7 +60,7 @@ public struct CredentialProviderView: View { if !viewModel.isChoosingTextToInsert { VStack(spacing: 12) { Button(action: { - var urlString = "net.aliasvault.app://credentials/add-edit-page" + var urlString = "aliasvault://credentials/add-edit-page" if let serviceUrl = viewModel.serviceUrl { let encodedUrl = serviceUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" urlString += "?serviceUrl=\(encodedUrl)" @@ -121,7 +121,7 @@ public struct CredentialProviderView: View { ToolbarItem(placement: .navigationBarTrailing) { HStack { Button(action: { - var urlString = "net.aliasvault.app://credentials/add-edit-page" + var urlString = "aliasvault://credentials/add-edit-page" if let serviceUrl = viewModel.serviceUrl { let encodedUrl = serviceUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" urlString += "?serviceUrl=\(encodedUrl)" diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index 462c9a741..f176b95f4 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -17,6 +17,7 @@ "@types/jsrsasign": "^10.5.15", "expo": "^53.0.22", "expo-blur": "~14.1.5", + "expo-camera": "^16.1.11", "expo-clipboard": "~7.1.5", "expo-constants": "~17.1.7", "expo-dev-client": "~5.1.8", @@ -8771,6 +8772,26 @@ "react-native": "*" } }, + "node_modules/expo-camera": { + "version": "16.1.11", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-16.1.11.tgz", + "integrity": "sha512-etA5ZKoC6nPBnWWqiTmlX//zoFZ6cWQCCIdmpUHTGHAKd4qZNCkhPvBWbi8o32pDe57lix1V4+TPFgEcvPwsaA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-clipboard": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.1.5.tgz", diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index 1f34f12bb..9e3990b5f 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -38,6 +38,7 @@ "@types/jsrsasign": "^10.5.15", "expo": "^53.0.22", "expo-blur": "~14.1.5", + "expo-camera": "^16.1.11", "expo-clipboard": "~7.1.5", "expo-constants": "~17.1.7", "expo-dev-client": "~5.1.8", diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts index 4ae8fe30f..bec75b1ee 100644 --- a/apps/mobile-app/specs/NativeVaultManager.ts +++ b/apps/mobile-app/specs/NativeVaultManager.ts @@ -85,6 +85,9 @@ export interface Spec extends TurboModule { setOfflineMode(isOffline: boolean): Promise; getOfflineMode(): Promise; + // Server version management + isServerVersionGreaterThanOrEqualTo(targetVersion: string): Promise; + // Vault sync and mutate isNewVaultVersionAvailable(): Promise<{ isNewVersionAvailable: boolean; newRevision: number | null }>; downloadVault(newRevision: number): Promise; @@ -95,6 +98,13 @@ export interface Spec extends TurboModule { removeAndDisablePin(): Promise; showPinUnlock(): Promise; showPinSetup(): Promise; + + // Mobile login methods + encryptDecryptionKeyForMobileLogin(publicKeyJWK: string): Promise; + + // Re-authentication methods + // Authenticate user with biometric or PIN. If title/subtitle are null/empty, defaults to "Unlock Vault" context. + authenticateUser(title: string | null, subtitle: string | null): Promise; } export default TurboModuleRegistry.getEnforcing('NativeVaultManager'); diff --git a/apps/mobile-app/utils/PostUnlockNavigation.ts b/apps/mobile-app/utils/PostUnlockNavigation.ts new file mode 100644 index 000000000..388178f2e --- /dev/null +++ b/apps/mobile-app/utils/PostUnlockNavigation.ts @@ -0,0 +1,137 @@ +import { Href } from 'expo-router'; +import { Linking } from 'react-native'; + +/** + * Post-unlock navigation options. + */ +export type PostUnlockNavigationOptions = { + /** + * Return URL from app context (for reinitialize flow). + */ + returnUrl?: { path: string; params?: Record } | null; + + /** + * Router instance for navigation. + */ + router: { + replace: (href: Href) => void; + push: (href: Href) => void; + }; + + /** + * Clear the return URL from app context. + */ + clearReturnUrl?: () => void; +} + +/** + * Centralized post-unlock navigation logic. + * Handles pending deep links, return URLs, and default navigation. + * This ensures consistent navigation behavior across all unlock flows: + * - initialize.tsx (cold boot with biometric unlock) + * - unlock.tsx (manual password/PIN unlock) + * - reinitialize.tsx (vault lock due to timeout, requiring unlock again) + */ +export class PostUnlockNavigation { + /** + * Navigate to the appropriate destination after successful vault unlock. + * Priority order: + * 1. Return URL (from reinitialize flow or _layout.tsx) + * 2. Default credentials tab + */ + static navigate(options: PostUnlockNavigationOptions): void { + const { returnUrl, router, clearReturnUrl } = options; + + // Priority 1: Handle return URL (from reinitialize flow) + if (returnUrl?.path) { + this.handleReturnUrl(returnUrl, router); + if (clearReturnUrl) { + clearReturnUrl(); + } + return; + } + + // Priority 2: Default navigation to credentials + router.replace('/(tabs)/credentials'); + } + + /** + * Handle return URL navigation (from reinitialize flow). + */ + private static handleReturnUrl( + returnUrl: { path: string; params?: Record | undefined }, + router: PostUnlockNavigationOptions['router'] + ): void { + // Normalize the path using centralized function + const normalizedPath = this.normalizeDeepLinkPath(returnUrl.path); + const params = returnUrl.params || {}; + + // Check if this is a detail route (has a sub-page after the tab) + const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/'); + const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') && + !normalizedPath.endsWith('/(tabs)/settings'); + + if (isCredentialRoute) { + // Navigate to credentials tab first, then push detail page + router.replace('/(tabs)/credentials'); + setTimeout(() => { + const queryParams = new URLSearchParams(params as Record).toString(); + const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath; + router.push(targetUrl as Href); + }, 0); + } else if (isSettingsRoute) { + // Navigate to settings tab first, then push detail page + router.replace('/(tabs)/settings'); + setTimeout(() => { + const queryParams = new URLSearchParams(params as Record).toString(); + const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath; + router.push(targetUrl as Href); + }, 0); + } else { + // Direct navigation for root tab routes + router.replace({ + pathname: normalizedPath as '/', + params: params as Record + }); + } + } + + /** + * Normalize a deep link or path to ensure it has the correct /(tabs)/ prefix. + * Exported for use in _layout.tsx and other navigation logic. + * + * Supports: + * - Action-based URLs: aliasvault://open/mobile-unlock/[id] + * - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page] + */ + private static normalizeDeepLinkPath(urlOrPath: string): string { + // Remove all URL schemes first + let path = urlOrPath + .replace('net.aliasvault.app://', '') + .replace('aliasvault://', '') + .replace('exp+aliasvault://', ''); + + // If it already has /(tabs)/ prefix, return as is + if (path.startsWith('/(tabs)/')) { + return path; + } + + // Handle credential paths + if (path.startsWith('credentials/') || path.includes('/credentials/')) { + if (!path.startsWith('/')) { + path = `/${path}`; + } + return `/(tabs)${path}`; + } + + // Handle settings paths + if (path.startsWith('settings/') || path.startsWith('/settings')) { + if (!path.startsWith('/')) { + path = `/${path}`; + } + return `/(tabs)${path}`; + } + + return path; + } +} diff --git a/apps/mobile-app/utils/VaultUnlockHelper.ts b/apps/mobile-app/utils/VaultUnlockHelper.ts new file mode 100644 index 000000000..46e38a56f --- /dev/null +++ b/apps/mobile-app/utils/VaultUnlockHelper.ts @@ -0,0 +1,126 @@ +import NativeVaultManager from '@/specs/NativeVaultManager'; + +export type AuthMethod = 'faceid' | 'password'; + +export type UnlockResult = { + success: boolean; + error?: string; + redirectToUnlock?: boolean; +}; + +/** + * Centralized vault unlock logic that handles both biometric and PIN unlock. + * This utility is used by initialize.tsx, reinitialize.tsx, and unlock.tsx to avoid code duplication. + */ +export class VaultUnlockHelper { + /** + * Attempt to unlock the vault using available authentication methods. + * Tries biometric first (if available), then PIN (if enabled), otherwise indicates manual unlock needed. + * + * @param params Configuration for unlock attempt + * @returns Promise indicating success/failure and any actions needed + */ + static async attemptAutomaticUnlock(params: { + enabledAuthMethods: AuthMethod[]; + unlockVault: () => Promise; // dbContext.unlockVault for biometric + }): Promise { + const { enabledAuthMethods, unlockVault } = params; + + // Check which authentication methods are available + const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + const isPinEnabled = await NativeVaultManager.isPinEnabled(); + + // Try biometric unlock first (Face ID / Touch ID) + if (isFaceIDEnabled) { + try { + const isUnlocked = await unlockVault(); + if (!isUnlocked) { + return { + success: false, + error: 'Biometric unlock failed', + redirectToUnlock: true, + }; + } + return { success: true }; + } catch (error) { + console.error('Biometric unlock error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Biometric unlock failed', + redirectToUnlock: true, + }; + } + } + + // Try PIN unlock if biometric is not available + if (isPinEnabled) { + try { + await NativeVaultManager.showPinUnlock(); + + // Verify vault is now unlocked + const isNowUnlocked = await NativeVaultManager.isVaultUnlocked(); + if (!isNowUnlocked) { + return { + success: false, + error: 'PIN unlock failed', + redirectToUnlock: true, + }; + } + return { success: true }; + } catch (error) { + // User cancelled or PIN unlock failed + // Only log non-cancellation errors to reduce noise + const errorMessage = error instanceof Error ? error.message : 'PIN unlock failed or cancelled'; + if (!errorMessage.includes('cancelled')) { + console.error('PIN unlock error:', error); + } + return { + success: false, + error: errorMessage, + redirectToUnlock: true, + }; + } + } + + // No automatic unlock method available - manual unlock required + return { + success: false, + error: 'No automatic unlock method available', + redirectToUnlock: true, + }; + } + + /** + * Authenticate user for a specific action (e.g., mobile unlock confirmation). + * Uses the native authenticateUser which automatically detects and uses the appropriate method. + * + * @param title Authentication prompt title + * @param subtitle Authentication prompt subtitle + * @returns Promise indicating if authentication succeeded + */ + static async authenticateForAction( + title: string, + subtitle: string + ): Promise { + try { + const authenticated = await NativeVaultManager.authenticateUser(title, subtitle); + return authenticated; + } catch (error) { + console.error('Authentication error:', error); + return false; + } + } + + /** + * Check if any automatic unlock method is available. + * @param enabledAuthMethods The enabled authentication methods + * @returns Promise indicating if automatic unlock is possible + */ + static async hasAutomaticUnlockMethod( + enabledAuthMethods: AuthMethod[] + ): Promise { + const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + const isPinEnabled = await NativeVaultManager.isPinEnabled(); + return isFaceIDEnabled || isPinEnabled; + } +} 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..af8a6cbfd 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; }; @@ -350,6 +354,10 @@ declare enum AuthEventType { * Represents a user logout event. */ Logout = 3, + /** + * Represents a mobile login attempt (login via QR code from mobile app). + */ + MobileLogin = 4, /** * Represents JWT access token refresh event issued by client to API. */ @@ -380,4 +388,35 @@ 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 login initiate request type. + */ +type MobileLoginInitiateRequest = { + clientPublicKey: string; +}; +/** + * Mobile login initiate response type. + */ +type MobileLoginInitiateResponse = { + requestId: string; +}; +/** + * Mobile login submit request type. + */ +type MobileLoginSubmitRequest = { + requestId: string; + encryptedDecryptionKey: string; +}; +/** + * Mobile login poll response type. + */ +type MobileLoginPollResponse = { + fulfilled: boolean; + encryptedSymmetricKey: string | null; + encryptedToken: string | null; + encryptedRefreshToken: string | null; + encryptedDecryptionKey: string | null; + encryptedUsername: 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 MobileLoginInitiateRequest, type MobileLoginInitiateResponse, type MobileLoginPollResponse, type MobileLoginSubmitRequest, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type TokenModel, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse }; diff --git a/apps/mobile-app/utils/dist/shared/models/webapi/index.js b/apps/mobile-app/utils/dist/shared/models/webapi/index.js index 501fdc30b..245e270a4 100644 --- a/apps/mobile-app/utils/dist/shared/models/webapi/index.js +++ b/apps/mobile-app/utils/dist/shared/models/webapi/index.js @@ -7,6 +7,7 @@ var AuthEventType = /* @__PURE__ */ ((AuthEventType2) => { AuthEventType2[AuthEventType2["Login"] = 1] = "Login"; AuthEventType2[AuthEventType2["TwoFactorAuthentication"] = 2] = "TwoFactorAuthentication"; AuthEventType2[AuthEventType2["Logout"] = 3] = "Logout"; + AuthEventType2[AuthEventType2["MobileLogin"] = 4] = "MobileLogin"; AuthEventType2[AuthEventType2["TokenRefresh"] = 10] = "TokenRefresh"; AuthEventType2[AuthEventType2["PasswordReset"] = 20] = "PasswordReset"; AuthEventType2[AuthEventType2["PasswordChange"] = 21] = "PasswordChange"; diff --git a/apps/server/AliasVault.Admin/Main/Models/MobileLoginRequestWithUsername.cs b/apps/server/AliasVault.Admin/Main/Models/MobileLoginRequestWithUsername.cs new file mode 100644 index 000000000..460237926 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/MobileLoginRequestWithUsername.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +using AliasServerDb; + +/// +/// View model for MobileLoginRequest joined with User to get username. +/// +public class MobileLoginRequestWithUsername +{ + /// + /// Gets or sets the mobile login request. + /// + public required MobileLoginRequest Request { get; set; } + + /// + /// Gets or sets the username from the User table via UserId FK. + /// + public string? Username { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs new file mode 100644 index 000000000..6bd8efc27 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageMobileLogins.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing IP addresses with mobile login request counts. +/// +public class RecentUsageMobileLogins +{ + /// + /// Gets or sets the anonymized IP address (last octet masked). + /// + public string IpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the original IP address for linking purposes. + /// + public string OriginalIpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the count of mobile login requests from this IP in the last 72 hours. + /// + public int MobileLoginCount72h { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs b/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs index 5e3b35893..4c461b131 100644 --- a/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs +++ b/apps/server/AliasVault.Admin/Main/Models/RecentUsageStatistics.cs @@ -26,4 +26,9 @@ public class RecentUsageStatistics /// Gets or sets the list of IP addresses with most registrations in the last 72 hours. /// public List TopIpsByRegistrations72h { get; set; } = new(); + + /// + /// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours. + /// + public List TopIpsByMobileLogins72h { get; set; } = new(); } diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor new file mode 100644 index 000000000..e77521759 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsage/Components/RecentUsageMobileLoginsTable.razor @@ -0,0 +1,72 @@ +@using AliasVault.Admin.Main.Models +@using AliasVault.RazorComponents.Tables + +
+
+
+

Top IP Addresses by Mobile Login Requests (Last 72h)

+

IP addresses with the most mobile login requests in the last 72 hours (last octet anonymized)

+
+
+ + @if (Data != null && Data.Any()) + { +
+ +
+
+ + @foreach (var ip in PagedData) + { + + + + @ip.IpAddress + + + @ip.MobileLoginCount72h.ToString("N0") + + } + +
+ } + else if (Data != null) + { +
+ + + +

No Recent Mobile Logins

+

No mobile login requests occurred in the last 72 hours.

+
+ } + else + { +
+ +
+ } +
+ +@code { + [Parameter] + public List? Data { get; set; } + + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 20; + + private IEnumerable PagedData => + Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty(); + + private readonly List _tableColumns = new() + { + new() { Title = "Client IP Address", PropertyName = "IpAddress", Sortable = false }, + new() { Title = "Mobile Logins (72h)", PropertyName = "MobileLoginCount72h", Sortable = false } + }; + + private void HandlePageChanged(int page) + { + CurrentPage = page; + StateHasChanged(); + } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor index 3b610210b..4c313e585 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/RecentUsageStats.razor @@ -27,6 +27,9 @@ + + +
@if (_loadingError) diff --git a/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor b/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor new file mode 100644 index 000000000..18044ea16 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor @@ -0,0 +1,382 @@ +@page "/mobile-login-history" +@using AliasVault.Admin.Main.Models +@using AliasVault.RazorComponents.Tables +@using Microsoft.AspNetCore.WebUtilities +@inject NavigationManager NavigationManager +@inherits MainBase + +Mobile Login History + + + + + + + +@if (IsInitialized) +{ +
+ +
+
+
+ + +
+
+
+ +
+
+
+} + +@if (IsLoading) +{ + +} +else +{ +
+ + @foreach (var request in RequestList) + { + + @request.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") + + @(request.ClientIpAddress ?? "N/A") + + + @(request.MobileIpAddress ?? "N/A") + + + @if (request.FulfilledAt.HasValue) + { + @request.FulfilledAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + } + else + { + - + } + + + @if (request.RetrievedAt.HasValue) + { + @request.RetrievedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + } + else + { + - + } + + + @if (!string.IsNullOrEmpty(request.Username)) + { + + @request.Username + + } + else + { + - + } + + + @if (request.RetrievedAt.HasValue) + { + + } + else if (request.FulfilledAt.HasValue) + { + + } + else + { + + } + + + } + +
+} + +@code { + private readonly List _tableColumns = [ + new TableColumn { Title = "Created At", PropertyName = "CreatedAt" }, + new TableColumn { Title = "Client IP", PropertyName = "ClientIpAddress" }, + new TableColumn { Title = "Mobile IP", PropertyName = "MobileIpAddress" }, + new TableColumn { Title = "Fulfilled At", PropertyName = "FulfilledAt" }, + new TableColumn { Title = "Retrieved At", PropertyName = "RetrievedAt" }, + new TableColumn { Title = "Username", PropertyName = "Username" }, + new TableColumn { Title = "Status", Sortable = false }, + ]; + + private List RequestList { get; set; } = []; + private bool IsInitialized { get; set; } = false; + private bool IsLoading { get; set; } = true; + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 50; + private int TotalRecords { get; set; } + + private string _searchTerm = string.Empty; + private CancellationTokenSource? _searchCancellationTokenSource; + private string _lastSearchTerm = string.Empty; + + private string SearchTerm + { + get => _searchTerm; + set + { + if (_searchTerm != value) + { + _searchTerm = value; + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); + } + } + } + + private string _selectedStatusFilter = string.Empty; + private string _lastSelectedStatusFilter = string.Empty; + private string SelectedStatusFilter + { + get => _selectedStatusFilter; + set + { + if (_selectedStatusFilter != value) + { + _selectedStatusFilter = value; + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); + } + } + } + + private string SortColumn { get; set; } = "CreatedAt"; + private SortDirection SortDirection { get; set; } = SortDirection.Descending; + + private async Task HandleSortChanged((string column, SortDirection direction) sort) + { + SortColumn = sort.column; + SortDirection = sort.direction; + await RefreshData(CancellationToken.None); + } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "users" }); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Mobile Login History" }); + + // Check for search query parameter + var uri = new Uri(NavigationManager.Uri); + var queryParams = QueryHelpers.ParseQuery(uri.Query); + if (queryParams.TryGetValue("search", out var search)) + { + _searchTerm = search.ToString(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await RefreshData(CancellationToken.None); + } + } + + private void HandlePageChanged(int newPage) + { + CurrentPage = newPage; + _ = RefreshData(CancellationToken.None); + } + + private async Task RefreshData(CancellationToken cancellationToken = default) + { + try + { + IsLoading = true; + StateHasChanged(); + + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + + // Join with AliasVaultUsers table to get username via UserId FK + var query = from request in dbContext.MobileLoginRequests + join user in dbContext.AliasVaultUsers on request.UserId equals user.Id into userJoin + from user in userJoin.DefaultIfEmpty() + select new MobileLoginRequestWithUsername + { + Request = request, + Username = user != null ? user.UserName : null + }; + + query = ApplySearchFilter(query); + query = ApplyStatusFilter(query); + query = ApplySort(query); + + TotalRecords = await query.CountAsync(cancellationToken); + var requests = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .Select(r => new + { + r.Request.CreatedAt, + r.Request.ClientIpAddress, + r.Request.MobileIpAddress, + r.Request.FulfilledAt, + r.Request.RetrievedAt, + r.Username, + r.Request.UserId + }) + .ToListAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + RequestList = requests.Select(r => new MobileLoginRequestModel + { + CreatedAt = r.CreatedAt, + ClientIpAddress = r.ClientIpAddress, + MobileIpAddress = r.MobileIpAddress, + FulfilledAt = r.FulfilledAt, + RetrievedAt = r.RetrievedAt, + Username = r.Username, + UserId = r.UserId + }).ToList(); + + IsLoading = false; + IsInitialized = true; + StateHasChanged(); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested, do nothing + } + } + + private IQueryable ApplySearchFilter(IQueryable query) + { + if (SearchTerm.Length > 0) + { + // Reset page number back to 1 if the search term has changed + if (SearchTerm != _lastSearchTerm && CurrentPage != 1) + { + CurrentPage = 1; + } + _lastSearchTerm = SearchTerm; + + var searchTerm = SearchTerm.Trim().ToLower(); + query = query.Where(r => + (r.Username != null && EF.Functions.Like(r.Username.ToLower(), "%" + searchTerm + "%")) || + (r.Request.ClientIpAddress != null && EF.Functions.Like(r.Request.ClientIpAddress.ToLower(), "%" + searchTerm + "%")) || + (r.Request.MobileIpAddress != null && EF.Functions.Like(r.Request.MobileIpAddress.ToLower(), "%" + searchTerm + "%")) + ); + } + + return query; + } + + private IQueryable ApplyStatusFilter(IQueryable query) + { + if (!string.IsNullOrEmpty(SelectedStatusFilter)) + { + // Reset page number back to 1 if the filter has changed + if (SelectedStatusFilter != _lastSelectedStatusFilter && CurrentPage != 1) + { + CurrentPage = 1; + } + _lastSelectedStatusFilter = SelectedStatusFilter; + + switch (SelectedStatusFilter) + { + case "retrieved": + query = query.Where(r => r.Request.RetrievedAt != null); + break; + case "fulfilled": + query = query.Where(r => r.Request.FulfilledAt != null && r.Request.RetrievedAt == null); + break; + case "pending": + query = query.Where(r => r.Request.FulfilledAt == null); + break; + } + } + + return query; + } + + private IQueryable ApplySort(IQueryable query) + { + switch (SortColumn) + { + case "CreatedAt": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Request.CreatedAt) + : query.OrderByDescending(x => x.Request.CreatedAt); + break; + case "ClientIpAddress": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Request.ClientIpAddress) + : query.OrderByDescending(x => x.Request.ClientIpAddress); + break; + case "MobileIpAddress": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Request.MobileIpAddress) + : query.OrderByDescending(x => x.Request.MobileIpAddress); + break; + case "FulfilledAt": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Request.FulfilledAt) + : query.OrderByDescending(x => x.Request.FulfilledAt); + break; + case "RetrievedAt": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Request.RetrievedAt) + : query.OrderByDescending(x => x.Request.RetrievedAt); + break; + case "Username": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Username) + : query.OrderByDescending(x => x.Username); + break; + default: + query = query.OrderByDescending(x => x.Request.CreatedAt); + break; + } + + return query; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource?.Dispose(); + } + base.Dispose(disposing); + } + + public class MobileLoginRequestModel + { + public DateTime CreatedAt { get; set; } + public string? ClientIpAddress { get; set; } + public string? MobileIpAddress { get; set; } + public DateTime? FulfilledAt { get; set; } + public DateTime? RetrievedAt { get; set; } + public string? Username { get; set; } + public string? UserId { get; set; } + } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor b/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor index 3e964af90..a19dee2e8 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor @@ -48,6 +48,12 @@

Number of days to keep auth logs before deletion. Set to 0 to disable automatic cleanup.

+
+ + +

+ Number of days to keep mobile login request logs before deletion. Set to 0 to disable automatic cleanup.

+
diff --git a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor index f46cf9110..13d1c5df0 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor @@ -11,6 +11,12 @@ Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")" Description="This page shows an overview of all registered users and the associated vaults."> + + + + + Mobile Login History + diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs index 94f033148..236e24acc 100644 --- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -115,6 +115,7 @@ public class StatisticsService GetTopUsersByAliases72hAsync().ContinueWith(t => stats.TopUsersByAliases72h = t.Result), GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result), GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result), + GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result), }; await Task.WhenAll(tasks); @@ -570,4 +571,36 @@ public class StatisticsService RegistrationCount72h = ip.RegistrationCount72h, }).ToList(); } + + /// + /// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours. + /// + /// List of top IP addresses by mobile login requests. + private async Task> GetTopIpsByMobileLogins72hAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + var cutoffDate = DateTime.UtcNow.AddHours(-72); + + // Get mobile login requests by client IP + var topIps = await context.MobileLoginRequests + .Where(mlr => mlr.CreatedAt >= cutoffDate && + mlr.ClientIpAddress != null && + mlr.ClientIpAddress != "xxx.xxx.xxx.xxx") + .GroupBy(mlr => mlr.ClientIpAddress) + .Select(g => new + { + IpAddress = g.Key, + MobileLoginCount72h = g.Count(), + }) + .OrderByDescending(ip => ip.MobileLoginCount72h) + .Take(20) + .ToListAsync(); + + return topIps.Select(ip => new RecentUsageMobileLogins + { + OriginalIpAddress = ip.IpAddress!, + IpAddress = AnonymizeIpAddress(ip.IpAddress!), + MobileLoginCount72h = ip.MobileLoginCount72h, + }).ToList(); + } } diff --git a/apps/server/AliasVault.Api/Controllers/AuthController.cs b/apps/server/AliasVault.Api/Controllers/AuthController.cs index 3be6e48f4..6c2f51a3d 100644 --- a/apps/server/AliasVault.Api/Controllers/AuthController.cs +++ b/apps/server/AliasVault.Api/Controllers/AuthController.cs @@ -15,6 +15,7 @@ using AliasServerDb; using AliasVault.Api.Helpers; using AliasVault.Auth; using AliasVault.Cryptography.Client; +using AliasVault.Cryptography.Server; using AliasVault.Shared.Core; using AliasVault.Shared.Models.Enums; using AliasVault.Shared.Models.WebApi; @@ -49,6 +50,23 @@ using SecureRemotePassword; [ApiVersion("1")] public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserManager userManager, SignInManager signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase { + /// + /// Timeout in minutes for mobile login requests. Clients use 2 minutes for countdown, we use 3 here to give a bit of extra buffer time. + /// Requests older than this will be automatically expired and removed. + /// + private const int MobileLoginTimeoutMinutes = 10; + + /// + /// Access token validity in minutes. + /// + /// + /// This is the time period for which the access token is valid. + /// It is used to authenticate the user for a limited time + /// and is short-lived by design. With the separate refresh token, the user can request a new access token + /// when this access token expires. + /// + private const int AccessTokenValiditySeconds = 600; + /// /// Semaphore to prevent concurrent access to the database when generating new tokens for a user. /// @@ -533,6 +551,204 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM latestVaultEncryptionSettings.EncryptionSettings)); } + /// + /// Initiates a mobile login request by creating a QR code challenge. + /// + /// The mobile login initiate request model. + /// IActionResult. + [HttpPost("mobile-login/initiate")] + [AllowAnonymous] + public async Task InitiateMobileLogin([FromBody] MobileLoginInitiateRequest model) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + // Generate a unique request ID + var requestId = Guid.NewGuid().ToString("N"); + + // Create the login request + var loginRequest = new MobileLoginRequest + { + Id = requestId, + ClientPublicKey = model.ClientPublicKey, + CreatedAt = timeProvider.UtcNow, + ClientIpAddress = IpAddressUtility.GetIpFromContext(HttpContext), + }; + + context.MobileLoginRequests.Add(loginRequest); + await context.SaveChangesAsync(); + + return Ok(new MobileLoginInitiateResponse + { + RequestId = requestId, + }); + } + + /// + /// Polls the status of a mobile login request. + /// + /// The unique identifier for the login request. + /// IActionResult. + [HttpGet("mobile-login/poll/{requestId}")] + [AllowAnonymous] + public async Task PollMobileLogin(string requestId) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == requestId); + + // Check if request exists and hasn't expired + if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow) + { + return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404)); + } + + // If not fulfilled, return pending status + if (loginRequest.FulfilledAt == null) + { + return Ok(new MobileLoginPollResponse + { + Fulfilled = false, + EncryptedSymmetricKey = null, + EncryptedToken = null, + EncryptedRefreshToken = null, + EncryptedDecryptionKey = null, + EncryptedUsername = null, + }); + } + + // Check if already retrieved (one-time use protection) + if (loginRequest.RetrievedAt != null) + { + return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404)); + } + + // Sanity check: check if user exists using UserId FK + var user = await userManager.FindByIdAsync(loginRequest.UserId!); + if (user == null) + { + await authLoggingService.LogAuthEventFailAsync("n/a", AuthEventType.MobileLogin, AuthFailureReason.InvalidUsername); + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400)); + } + + // Sanity check: check if the account is blocked. + if (user.Blocked) + { + await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.MobileLogin, AuthFailureReason.AccountBlocked); + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 400)); + } + + // Sanity check: check if the account is locked out. + if (await userManager.IsLockedOutAsync(user)) + { + await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.MobileLogin, AuthFailureReason.AccountLocked); + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_LOCKED, 400)); + } + + // Generate token for the user + var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: true); + + // Get encrypted decryption key from the login request and put it in memory + var encryptedDecryptionKey = loginRequest.EncryptedDecryptionKey!; + + // Generate a single symmetric key for encrypting all fields + var symmetricKey = Cryptography.Server.Encryption.GenerateRandomSymmetricKey(); + + // Encrypt each field with the symmetric key (returns base64) + var encryptedToken = Cryptography.Server.Encryption.SymmetricEncrypt(tokenModel.Token, symmetricKey); + var encryptedRefreshToken = Cryptography.Server.Encryption.SymmetricEncrypt(tokenModel.RefreshToken, symmetricKey); + var encryptedUsername = Cryptography.Server.Encryption.SymmetricEncrypt(user.UserName!, symmetricKey); + + // Encrypt the symmetric key with the client's RSA public key (returns base64) + var encryptedSymmetricKey = Cryptography.Server.Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, loginRequest.ClientPublicKey); + + // Log successful mobile login authentication + await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.MobileLogin); + + // Mark as retrieved and clear sensitive data from database + loginRequest.ClientPublicKey = string.Empty; + loginRequest.EncryptedDecryptionKey = null; + loginRequest.RetrievedAt = timeProvider.UtcNow; + await context.SaveChangesAsync(); + + // Return response with encrypted symmetric key and encrypted fields + // Client will decrypt username to call /login endpoint for salt and encryption settings + return Ok(new MobileLoginPollResponse + { + Fulfilled = true, + EncryptedSymmetricKey = encryptedSymmetricKey, + EncryptedToken = encryptedToken, + EncryptedRefreshToken = encryptedRefreshToken, + EncryptedDecryptionKey = encryptedDecryptionKey, + EncryptedUsername = encryptedUsername, + }); + } + + /// + /// Gets the public key for a mobile login request (for mobile app to encrypt). + /// + /// The unique identifier for the login request. + /// IActionResult. + [HttpGet("mobile-login/request/{requestId}")] + [Authorize] + public async Task GetMobileLoginRequest(string requestId) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == requestId); + + // Check if request exists and hasn't expired + if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow) + { + return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404)); + } + + // Return only the public key + return Ok(new { clientPublicKey = loginRequest.ClientPublicKey }); + } + + /// + /// Submits a mobile login response from the mobile app. + /// + /// The mobile login submit request model. + /// IActionResult. + [HttpPost("mobile-login/submit")] + [Authorize] + public async Task SubmitMobileLogin([FromBody] MobileLoginSubmitRequest model) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + // Get the authenticated user + var user = await userManager.GetUserAsync(User); + if (user == null) + { + return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 401)); + } + + var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == model.RequestId); + + // Check if request exists and hasn't expired + if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow) + { + return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404)); + } + + // Check if already fulfilled + if (loginRequest.FulfilledAt != null) + { + return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_ALREADY_FULFILLED, 400)); + } + + // Update the login request with the encrypted key and user ID + loginRequest.EncryptedDecryptionKey = model.EncryptedDecryptionKey; + loginRequest.UserId = user.Id; + loginRequest.FulfilledAt = timeProvider.UtcNow; + loginRequest.MobileIpAddress = IpAddressUtility.GetIpFromContext(HttpContext); + + await context.SaveChangesAsync(); + + return Ok(); + } + /// /// Confirms the account deletion process. /// @@ -764,7 +980,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM issuer: configuration["Jwt:Issuer"] ?? string.Empty, audience: configuration["Jwt:Issuer"] ?? string.Empty, claims: claims, - expires: timeProvider.UtcNow.AddMinutes(10), + expires: timeProvider.UtcNow.AddSeconds(AccessTokenValiditySeconds), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); diff --git a/apps/server/AliasVault.Api/Helpers/AuthHelper.cs b/apps/server/AliasVault.Api/Helpers/AuthHelper.cs index 3b90237f7..180e6da3b 100644 --- a/apps/server/AliasVault.Api/Helpers/AuthHelper.cs +++ b/apps/server/AliasVault.Api/Helpers/AuthHelper.cs @@ -77,9 +77,13 @@ public static class AuthHelper /// Generate a device identifier based on request headers. This is used to associate refresh tokens /// with a specific device for a specific user. /// + /// The identifier includes the client type (web app, browser extension, mobile app) to prevent + /// conflicts when a user is logged in on multiple clients from the same browser/device. + /// For example, logging out from the browser extension won't affect the web app session. + /// /// NOTE: current implementation means that only one refresh token can be valid for a /// specific user/device combo at a time. The identifier generation could be made more unique in the future - /// to prevent any unwanted conflicts. + /// to prevent any potential unwanted conflicts. /// /// The HttpRequest instance for the request that the client used. /// Unique device identifier as string. @@ -88,7 +92,11 @@ public static class AuthHelper var userAgent = request.Headers.UserAgent.ToString(); var acceptLanguage = request.Headers.AcceptLanguage.ToString(); - var rawIdentifier = $"{userAgent}|{acceptLanguage}"; + // Client header is usually formatted like "[client name]-[version]" e.g. "chrome-0.25.0", take only "chrome" + var clientHeader = request.Headers["X-AliasVault-Client"].ToString(); + var clientName = clientHeader?.Split('-')[0] ?? "unknown"; + + var rawIdentifier = $"{clientName}|{userAgent}|{acceptLanguage}"; return rawIdentifier; } } diff --git a/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor b/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor new file mode 100644 index 000000000..ced3ed2c0 --- /dev/null +++ b/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor @@ -0,0 +1,319 @@ +@using AliasVault.Client.Auth.Models +@using AliasVault.Client.Auth.Services +@using AliasVault.Client.Main.Components.Layout +@using AliasVault.Client.Utilities +@using Microsoft.Extensions.Localization +@using Microsoft.Extensions.DependencyInjection +@implements IDisposable + +@if (IsOpen) +{ + + +
+ + +
+ + + + +
+

+ @Title +

+

+ @Description +

+ + @if (!string.IsNullOrEmpty(_errorMessage)) + { +
+ @_errorMessage +
+ } + + @if (!string.IsNullOrEmpty(_qrCodeUrl)) + { +
+
+ +
+ @if (!_isLoading) + { +
+ @FormatTime(_timeRemaining) +
+ } +
+ } + + @if (_isLoading && string.IsNullOrEmpty(_errorMessage)) + { +
+
+
+ } + + +
+
+
+} + +@code { + private string? _qrCodeUrl; + private MobileLoginErrorCode? _errorCode; + private string? _errorMessage; + private int _timeRemaining = 120; // 2 minutes in seconds + private MobileLoginUtility? _mobileLoginUtility; + private bool _isLoading = true; + private System.Threading.Timer? _countdownTimer; + private string _qrElementId = $"mobile-unlock-qr-{Guid.NewGuid():N}"; + + [Inject] + private HttpClient Http { get; set; } = default!; + + [Inject] + private JsInteropService JsInteropService { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + [Inject] + private IStringLocalizerFactory LocalizerFactory { get; set; } = default!; + + [Inject] + private IServiceProvider ScopedServices { get; set; } = default!; + + private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client"); + + private IStringLocalizer MobileLoginLocalizer => LocalizerFactory.Create("MobileLogin", "AliasVault.Client"); + + /// + /// Whether the modal is open. + /// + [Parameter] + public bool IsOpen { get; set; } + + /// + /// Callback when the modal is closed. + /// + [Parameter] + public EventCallback OnClose { get; set; } + + /// + /// Callback when mobile login/unlock succeeds with the result. + /// + [Parameter] + public EventCallback OnSuccess { get; set; } + + /// + /// Mode - 'login' or 'unlock'. + /// + [Parameter] + public string Mode { get; set; } = "login"; + + /// + /// Modal title based on mode. + /// + private string Title => Mode == "unlock" + ? LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["UnlockTitle"] + : LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["PageTitle"]; + + /// + /// Modal description based on mode. + /// + private string Description => LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["ScanQrCodeDescription"]; + + /// + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + if (IsOpen && _mobileLoginUtility == null) + { + await InitiateMobileLoginAsync(); + } + else if (!IsOpen) + { + Cleanup(); + } + } + + /// + /// Initialize mobile login when modal opens. + /// + private async Task InitiateMobileLoginAsync() + { + try + { + _isLoading = true; + _errorCode = null; + _errorMessage = null; + _qrCodeUrl = null; + _timeRemaining = 120; + StateHasChanged(); + + // Initialize mobile login utility + var utilityLogger = ScopedServices.GetRequiredService>(); + _mobileLoginUtility = new MobileLoginUtility(Http, JsInteropService, utilityLogger); + + // Initiate mobile login and get QR code data + var requestId = await _mobileLoginUtility.InitiateAsync(); + + // Generate QR code with AliasVault prefix for mobile login + _qrCodeUrl = $"aliasvault://open/mobile-unlock/{requestId}"; + + // Render QR code while showing loading + StateHasChanged(); + await Task.Delay(100); // Give DOM time to render + await JsInteropService.GenerateQrCode(_qrElementId); + + // Wait for QR code to be fully rendered before hiding loading + await Task.Delay(300); + + _isLoading = false; + StateHasChanged(); + + // Start countdown timer + StartCountdownTimer(); + + // Start polling for response + await _mobileLoginUtility.StartPollingAsync( + HandleSuccessAsync, + HandleErrorAsync); + } + catch (MobileLoginException ex) + { + Logger.LogError(ex, "Error initiating mobile login"); + _isLoading = false; + _errorCode = ex.ErrorCode; + _errorMessage = GetErrorMessage(ex.ErrorCode); + StateHasChanged(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error initiating mobile login"); + _isLoading = false; + _errorCode = MobileLoginErrorCode.Generic; + _errorMessage = GetErrorMessage(MobileLoginErrorCode.Generic); + StateHasChanged(); + } + } + + /// + /// Handle successful mobile login/unlock. + /// + private async Task HandleSuccessAsync(MobileLoginResult result) + { + try + { + // Call parent success callback + await OnSuccess.InvokeAsync(result); + + // Close modal after successful processing + await HandleClose(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error handling mobile login success"); + _errorMessage = SharedLocalizer["ErrorUnknown"]; + StateHasChanged(); + } + } + + /// + /// Handle error. + /// + private void HandleErrorAsync(MobileLoginErrorCode errorCode) + { + _isLoading = false; + _qrCodeUrl = null; // Hide QR code when error occurs + _errorCode = errorCode; + _errorMessage = GetErrorMessage(errorCode); + StateHasChanged(); + } + + /// + /// Get translated error message for error code. + /// + private string GetErrorMessage(MobileLoginErrorCode errorCode) + { + return errorCode switch + { + MobileLoginErrorCode.Timeout => MobileLoginLocalizer["ErrorTimeout"], + MobileLoginErrorCode.Generic => SharedLocalizer["ErrorUnknown"], + _ => SharedLocalizer["ErrorUnknown"] + }; + } + + /// + /// Handle modal close. + /// + private async Task HandleClose() + { + Cleanup(); + await OnClose.InvokeAsync(); + } + + /// + /// Cleanup resources. + /// + private void Cleanup() + { + _mobileLoginUtility?.Cleanup(); + _mobileLoginUtility?.Dispose(); + _mobileLoginUtility = null; + _countdownTimer?.Dispose(); + _countdownTimer = null; + _qrCodeUrl = null; + _errorCode = null; + _errorMessage = null; + _timeRemaining = 120; + _isLoading = true; + } + + /// + /// Format time remaining as MM:SS. + /// + private string FormatTime(int seconds) + { + var mins = seconds / 60; + var secs = seconds % 60; + return $"{mins}:{secs:D2}"; + } + + /// + /// Start countdown timer. + /// + private void StartCountdownTimer() + { + _countdownTimer = new System.Threading.Timer(_ => + { + if (_timeRemaining > 0) + { + _timeRemaining--; + InvokeAsync(StateHasChanged); + } + else + { + _countdownTimer?.Dispose(); + _mobileLoginUtility?.StopPolling(); + } + }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + } + + /// + public void Dispose() + { + Cleanup(); + } +} diff --git a/apps/server/AliasVault.Client/Auth/Models/MobileLoginResult.cs b/apps/server/AliasVault.Client/Auth/Models/MobileLoginResult.cs new file mode 100644 index 000000000..bd11e42ea --- /dev/null +++ b/apps/server/AliasVault.Client/Auth/Models/MobileLoginResult.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Auth.Models; + +/// +/// Result of a successful mobile login containing decrypted authentication data. +/// +public sealed class MobileLoginResult +{ + /// + /// Gets or sets the username. + /// + public required string Username { get; set; } + + /// + /// Gets or sets the JWT access token. + /// + public required string Token { get; set; } + + /// + /// Gets or sets the refresh token. + /// + public required string RefreshToken { get; set; } + + /// + /// Gets or sets the vault decryption key (base64 encoded). + /// + public required string DecryptionKey { get; set; } +} diff --git a/apps/server/AliasVault.Client/Auth/Pages/Login.razor b/apps/server/AliasVault.Client/Auth/Pages/Login.razor index 52efae0ca..f7ca8fcb8 100644 --- a/apps/server/AliasVault.Client/Auth/Pages/Login.razor +++ b/apps/server/AliasVault.Client/Auth/Pages/Login.razor @@ -112,10 +112,22 @@ else @Localizer["LostPasswordLink"] - +
+ + +
+ + @if (Config.PublicRegistrationEnabled) { -
+
@Localizer["NoAccountYetText"] @Localizer["CreateNewVaultLink"]
} @@ -124,6 +136,11 @@ else + + @code { private readonly LoginFormModel _loginModel = new(); private readonly LoginModel2Fa _loginModel2Fa = new(); @@ -132,9 +149,11 @@ else private ServerValidationErrors _serverValidationErrors = new(); private bool _showTwoFactorAuthStep; private bool _showLoginWithRecoveryCodeStep; + private bool _showMobileLoginModal; private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Auth.Login", "AliasVault.Client"); private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client"); + private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client"); private SrpEphemeral _clientEphemeral = new(); private SrpSession _clientSession = new(); @@ -455,4 +474,78 @@ else } } + /// + /// Handle successful mobile login. + /// + private async Task HandleMobileLoginSuccess(MobileLoginResult result) + { + _loadingIndicator.Show(Localizer["LoggingInMessage"]); + _serverValidationErrors.Clear(); + + try + { + // Clear global messages + GlobalNotificationService.ClearMessages(); + + // Call /login endpoint to retrieve salt and encryption settings + var loginInitiateRequest = new { username = result.Username }; + var loginResponse = await Http.PostAsJsonAsync("v1/Auth/login", loginInitiateRequest); + + if (!loginResponse.IsSuccessStatusCode) + { + _serverValidationErrors.AddError(Localizer["LoginErrorMessage"]); + return; + } + + var loginData = await loginResponse.Content.ReadFromJsonAsync(); + if (loginData == null) + { + _serverValidationErrors.AddError(Localizer["LoginErrorMessage"]); + return; + } + + // Store the tokens in local storage + await AuthService.StoreAccessTokenAsync(result.Token); + await AuthService.StoreRefreshTokenAsync(result.RefreshToken); + + // Convert decryption key from base64 string to byte array + var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey); + + // Store the encryption key in memory + await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes); + + await AuthStateProvider.GetAuthenticationStateAsync(); + GlobalNotificationService.ClearMessages(); + + // Redirect to the page the user was trying to access before if set + var localStorageReturnUrl = await LocalStorage.GetItemAsync(ReturnUrlKey); + if (!string.IsNullOrEmpty(localStorageReturnUrl)) + { + await LocalStorage.RemoveItemAsync(ReturnUrlKey); + NavigationManager.NavigateTo(localStorageReturnUrl); + } + else + { + NavigationManager.NavigateTo("/"); + } + } +#if DEBUG + catch (Exception ex) + { + // If in debug mode show the actual exception. + _serverValidationErrors.AddError(ex.ToString()); + } +#else + catch + { + // If in release mode show a generic error. + _serverValidationErrors.AddError(Localizer["LoginErrorMessage"]); + } +#endif + finally + { + _loadingIndicator.Hide(); + } + } + } diff --git a/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor b/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor index 6933c27f5..9f5d38d50 100644 --- a/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor +++ b/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor @@ -46,11 +46,11 @@ else
- -
@@ -72,20 +72,31 @@ else
- + + } -
+
@Localizer["SwitchAccountsText"] @Localizer["LogOutLink"]
} + + @code { /// /// Skip automatic WebAuthn unlock during page load if set to true. @@ -98,12 +109,14 @@ else private bool IsWebAuthnLoading { get; set; } private bool ShowWebAuthnButton { get; set; } private bool IsPasswordFocused { get; set; } + private bool _showMobileUnlockModal; private readonly UnlockModel _unlockModel = new(); private FullScreenLoadingIndicator _loadingIndicator = new(); private ServerValidationErrors _serverValidationErrors = new(); private PasswordInputField? passwordField; private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Auth.Unlock", "AliasVault.Client"); private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client"); + private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client"); /// protected override async Task OnAfterRenderAsync(bool firstRender) @@ -353,4 +366,62 @@ else // Do nothing } } + + /// + /// Handle successful mobile unlock. + /// + private async Task HandleMobileUnlockSuccess(MobileLoginResult result) + { + _loadingIndicator.Show(Localizer["UnlockingVaultMessage"]); + _serverValidationErrors.Clear(); + + try + { + await StatusCheck(); + + // Revoke existing tokens + await AuthService.RemoveTokensAsync(); + + // Store the new tokens + await AuthService.StoreAccessTokenAsync(result.Token); + await AuthService.StoreRefreshTokenAsync(result.RefreshToken); + + // Convert decryption key from base64 string to byte array + var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey); + + // Store the encryption key in memory + await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes); + + await AuthStateProvider.GetAuthenticationStateAsync(); + + // Redirect to the page the user was trying to access before if set + var localStorageReturnUrl = await LocalStorage.GetItemAsync(ReturnUrlKey); + if (!string.IsNullOrEmpty(localStorageReturnUrl)) + { + await LocalStorage.RemoveItemAsync(ReturnUrlKey); + NavigationManager.NavigateTo(localStorageReturnUrl); + } + else + { + NavigationManager.NavigateTo("/"); + } + } +#if DEBUG + catch (Exception ex) + { + // If in debug mode show the actual exception. + _serverValidationErrors.AddError(ex.ToString()); + } +#else + catch + { + // If in release mode show a generic error. + _serverValidationErrors.AddError(Localizer["GenericUnlockError"]); + } +#endif + finally + { + _loadingIndicator.Hide(); + } + } } diff --git a/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs b/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs new file mode 100644 index 000000000..87134c0ce --- /dev/null +++ b/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs @@ -0,0 +1,220 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Auth.Services; + +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AliasVault.Client.Auth.Models; +using AliasVault.Client.Services.JsInterop; +using AliasVault.Client.Utilities; +using AliasVault.Shared.Models.WebApi.Auth; +using Microsoft.Extensions.Logging; + +/// +/// Utility class for logging in with mobile app functionality. +/// +public sealed class MobileLoginUtility : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly JsInteropService _jsInteropService; + private readonly ILogger _logger; + + private Timer? _pollingTimer; + private string? _requestId; + private string? _privateKey; + private CancellationTokenSource? _cancellationTokenSource; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client. + /// The JS interop service. + /// The logger. + public MobileLoginUtility(HttpClient httpClient, JsInteropService jsInteropService, ILogger logger) + { + _httpClient = httpClient; + _jsInteropService = jsInteropService; + _logger = logger; + } + + /// + /// Initiates a mobile login request and returns the request ID for QR code generation. + /// + /// The request ID. + /// Thrown when the request fails. + public async Task InitiateAsync() + { + try + { + // Generate RSA key pair + var keyPair = await _jsInteropService.GenerateRsaKeyPair(); + _privateKey = keyPair.PrivateKey; + + // Send public key to server + var request = new MobileLoginInitiateRequest + { + ClientPublicKey = keyPair.PublicKey, + }; + var response = await _httpClient.PostAsJsonAsync("v1/Auth/mobile-login/initiate", request); + + if (!response.IsSuccessStatusCode) + { + throw new MobileLoginException(MobileLoginErrorCode.Generic); + } + + var result = await response.Content.ReadFromJsonAsync(); + if (result == null) + { + throw new MobileLoginException(MobileLoginErrorCode.Generic); + } + + _requestId = result.RequestId; + return _requestId; + } + catch (MobileLoginException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initiate mobile login"); + throw new MobileLoginException(MobileLoginErrorCode.Generic); + } + } + + /// + /// Starts polling the server for mobile login response. + /// + /// Callback for successful authentication with decrypted login result. + /// Callback for errors with error code. + /// Task. + public Task StartPollingAsync(Func onSuccess, Action onError) + { + if (string.IsNullOrEmpty(_requestId) || string.IsNullOrEmpty(_privateKey)) + { + throw new InvalidOperationException("Must call InitiateAsync() before starting polling"); + } + + _cancellationTokenSource = new CancellationTokenSource(); + + // Start polling timer (every 3 seconds) + _pollingTimer = new Timer(async _ => await PollServerAsync(onSuccess, onError), null, TimeSpan.Zero, TimeSpan.FromSeconds(3)); + + // Auto-stop after 3.5 minutes (adds 1 minute buffer to default 2 minute timer for edge cases) + Task.Delay(TimeSpan.FromSeconds(210), _cancellationTokenSource.Token) + .ContinueWith( + _ => + { + if (!_cancellationTokenSource.IsCancellationRequested) + { + StopPolling(); + onError(MobileLoginErrorCode.Timeout); + } + }, + TaskScheduler.Default); + + return Task.CompletedTask; + } + + /// + /// Stops polling the server. + /// + public void StopPolling() + { + _pollingTimer?.Dispose(); + _pollingTimer = null; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + + /// + /// Cleans up resources. + /// + public void Cleanup() + { + StopPolling(); + _privateKey = null; + _requestId = null; + } + + /// + public void Dispose() + { + Cleanup(); + } + + private async Task PollServerAsync(Func onSuccess, Action onError) + { + if (string.IsNullOrEmpty(_requestId) || _cancellationTokenSource?.IsCancellationRequested == true) + { + return; + } + + try + { + var response = await _httpClient.GetAsync($"v1/Auth/mobile-login/poll/{_requestId}"); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + StopPolling(); + _privateKey = null; + _requestId = null; + onError(MobileLoginErrorCode.Timeout); + return; + } + + throw new InvalidOperationException($"Polling failed: {response.StatusCode}"); + } + + var result = await response.Content.ReadFromJsonAsync(); + + if (result?.Fulfilled == true && !string.IsNullOrEmpty(result.EncryptedSymmetricKey)) + { + // Stop polling + StopPolling(); + + // Decrypt the vault decryption key directly with RSA private key + var decryptionKey = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedDecryptionKey!, _privateKey!); + + // Decrypt the symmetric key with RSA private key + var symmetricKeyBase64 = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedSymmetricKey, _privateKey!); + + // Decrypt all remaining fields using the symmetric key + var token = await _jsInteropService.SymmetricDecrypt(result.EncryptedToken!, symmetricKeyBase64); + var refreshToken = await _jsInteropService.SymmetricDecrypt(result.EncryptedRefreshToken!, symmetricKeyBase64); + var username = await _jsInteropService.SymmetricDecrypt(result.EncryptedUsername!, symmetricKeyBase64); + + // Clear sensitive data + _privateKey = null; + _requestId = null; + + // Call success callback with decrypted data + await onSuccess(new MobileLoginResult + { + Username = username, + Token = token, + RefreshToken = refreshToken, + DecryptionKey = decryptionKey, + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during mobile login polling"); + StopPolling(); + _privateKey = null; + _requestId = null; + onError(MobileLoginErrorCode.Generic); + } + } +} diff --git a/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx b/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx index f8c7a22b6..849057e31 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx @@ -104,10 +104,6 @@ Log in Login button text - - Login to your account - Extended login button text - @@ -126,7 +122,10 @@ Log in with an authenticator code instead. Link text for logging in with authenticator - + + Log in using Mobile App + Link text for mobile device login + Your login is protected with an authenticator app. Enter your authenticator code below. diff --git a/apps/server/AliasVault.Client/Resources/MobileLogin.en.resx b/apps/server/AliasVault.Client/Resources/MobileLogin.en.resx new file mode 100644 index 000000000..04ef1983e --- /dev/null +++ b/apps/server/AliasVault.Client/Resources/MobileLogin.en.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Mobile login request timed out. Please reload the page and try again. + + diff --git a/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileLogin.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileLogin.en.resx new file mode 100644 index 000000000..5ec771ddf --- /dev/null +++ b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileLogin.en.resx @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Log in using Mobile App + Page title for mobile unlock feature + + + Unlock using Mobile App + Modal title for mobile unlock feature + + + Scan this QR code with your AliasVault mobile app to login. + Description instructing user to scan QR code + + + Scan this QR code with your AliasVault mobile app to unlock your vault. + Description instructing user to scan QR code to unlock + + diff --git a/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileUnlockModal.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileUnlockModal.en.resx new file mode 100644 index 000000000..16166b115 --- /dev/null +++ b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileUnlockModal.en.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Log in using Mobile App + Page title for mobile unlock feature + + + Unlock using Mobile App + Modal title for mobile unlock feature + + + Scan this QR code with your AliasVault mobile app to login and unlock your vault. + Description instructing user to scan QR code + + diff --git a/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx index ee01f41a5..da067364b 100644 --- a/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx +++ b/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx @@ -122,4 +122,8 @@ An error occurred while processing the login request. Try again (later). Generic error message for unlock failures + + Unlock with Mobile App + Button text for unlocking with mobile app + \ No newline at end of file diff --git a/apps/server/AliasVault.Client/Resources/SharedResources.en.resx b/apps/server/AliasVault.Client/Resources/SharedResources.en.resx index 29c48d9f1..d66cc2386 100644 --- a/apps/server/AliasVault.Client/Resources/SharedResources.en.resx +++ b/apps/server/AliasVault.Client/Resources/SharedResources.en.resx @@ -215,6 +215,10 @@ An error occurred. Please try again. Generic error message + + An unknown error occurred. Please try again. + Generic unknown error message + Please correct the errors below. Validation error message @@ -291,6 +295,10 @@ + + or + Divider text between options + Lock vault Tooltip text for lock vault button diff --git a/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs b/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs index b9687f052..b7dcca87c 100644 --- a/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs +++ b/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs @@ -235,7 +235,7 @@ public sealed class JsInteropService(IJSRuntime jsRuntime) try { // Invoke the JavaScript function and get the result as a byte array - await jsRuntime.InvokeVoidAsync("generateQrCode", "authenticator-uri"); + await jsRuntime.InvokeVoidAsync("generateQrCode", elementId); } catch (JSException ex) { diff --git a/apps/server/AliasVault.Client/Utilities/MobileLoginErrorCode.cs b/apps/server/AliasVault.Client/Utilities/MobileLoginErrorCode.cs new file mode 100644 index 000000000..3bf0cb50e --- /dev/null +++ b/apps/server/AliasVault.Client/Utilities/MobileLoginErrorCode.cs @@ -0,0 +1,25 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Utilities; + +/// +/// Error codes for mobile login operations. +/// These codes are used to provide translatable error messages to users. +/// +public enum MobileLoginErrorCode +{ + /// + /// The mobile login request has timed out after 2 minutes. + /// + Timeout, + + /// + /// A generic error occurred during mobile login. + /// + Generic, +} diff --git a/apps/server/AliasVault.Client/Utilities/MobileLoginException.cs b/apps/server/AliasVault.Client/Utilities/MobileLoginException.cs new file mode 100644 index 000000000..51e75a6aa --- /dev/null +++ b/apps/server/AliasVault.Client/Utilities/MobileLoginException.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Utilities; + +using System; + +/// +/// Exception thrown during mobile login operations. +/// Contains a for translation. +/// +public class MobileLoginException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error code. + public MobileLoginException(MobileLoginErrorCode errorCode) + : base($"Mobile login failed with error code: {errorCode}") + { + ErrorCode = errorCode; + } + + /// + /// Gets the error code. + /// + public MobileLoginErrorCode ErrorCode { get; } +} diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css index ac4ed1892..fadb8ed09 100644 --- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css @@ -1515,6 +1515,10 @@ video { border-width: 2px; } +.border-4 { + border-width: 4px; +} + .border-b { border-bottom-width: 1px; } @@ -1627,6 +1631,11 @@ video { border-color: rgb(254 240 138 / var(--tw-border-opacity)); } +.border-red-400 { + --tw-border-opacity: 1; + border-color: rgb(248 113 113 / var(--tw-border-opacity)); +} + .bg-amber-100 { --tw-bg-opacity: 1; background-color: rgb(254 243 199 / var(--tw-bg-opacity)); @@ -1811,6 +1820,11 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } +.bg-yellow-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 249 195 / var(--tw-bg-opacity)); +} + .bg-yellow-50 { --tw-bg-opacity: 1; background-color: rgb(254 252 232 / var(--tw-bg-opacity)); @@ -1821,6 +1835,11 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1829,6 +1848,10 @@ video { --tw-bg-opacity: 0.75; } +.bg-opacity-80 { + --tw-bg-opacity: 0.8; +} + .bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)); } @@ -1958,6 +1981,11 @@ video { padding-bottom: 1.5rem; } +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + .pb-28 { padding-bottom: 7rem; } @@ -2018,6 +2046,10 @@ video { padding-top: 2rem; } +.pt-5 { + padding-top: 1.25rem; +} + .text-left { text-align: left; } @@ -2368,11 +2400,20 @@ video { box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.ring-inset { + --tw-ring-inset: inset; +} + .ring-black { --tw-ring-opacity: 1; --tw-ring-color: rgb(0 0 0 / var(--tw-ring-opacity)); } +.ring-gray-300 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + .ring-opacity-5 { --tw-ring-opacity: 0.05; } @@ -2715,6 +2756,11 @@ video { --tw-ring-color: rgb(243 244 246 / var(--tw-ring-opacity)); } +.focus\:ring-gray-200:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity)); +} + .focus\:ring-gray-300:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); @@ -2876,6 +2922,11 @@ video { border-color: rgb(133 77 14 / var(--tw-border-opacity)); } +.dark\:border-red-700:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(185 28 28 / var(--tw-border-opacity)); +} + .dark\:bg-amber-800\/30:is(.dark *) { background-color: rgb(146 64 14 / 0.3); } @@ -3018,6 +3069,10 @@ video { background-color: rgb(113 63 18 / 0.2); } +.dark\:bg-red-900\/30:is(.dark *) { + background-color: rgb(127 29 29 / 0.3); +} + .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -3177,6 +3232,11 @@ video { color: rgb(156 163 175 / var(--tw-placeholder-opacity)); } +.dark\:ring-gray-600:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity)); +} + .dark\:ring-offset-gray-800:is(.dark *) { --tw-ring-offset-color: #1f2937; } @@ -3190,6 +3250,11 @@ video { color: rgb(248 185 99 / var(--tw-text-opacity)); } +.dark\:hover\:border-gray-600:hover:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(75 85 99 / var(--tw-border-opacity)); +} + .dark\:hover\:bg-blue-500:hover:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(59 130 246 / var(--tw-bg-opacity)); @@ -3514,6 +3579,10 @@ video { display: inline; } + .md\:flex { + display: flex; + } + .md\:hidden { display: none; } diff --git a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs index 41e7f74e3..b2475ba9a 100644 --- a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs @@ -137,6 +137,11 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon /// public DbSet TaskRunnerJobs { get; set; } + /// + /// Gets or sets the MobileLoginRequests DbSet. + /// + public DbSet MobileLoginRequests { get; set; } + /// /// Sets up the connection string if it is not already configured. /// diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.Designer.cs b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.Designer.cs new file mode 100644 index 000000000..bc65a99fe --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.Designer.cs @@ -0,0 +1,981 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20251117215634_AddMobileLoginRequest")] + partial class AddMobileLoginRequest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastPasswordChanged") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Blocked") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxEmailAgeDays") + .HasColumnType("integer"); + + b.Property("MaxEmails") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PreviousTokenValue") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.AuthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalInfo") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Client") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsSuccess") + .HasColumnType("boolean"); + + b.Property("IsSuspiciousActivity") + .HasColumnType("boolean"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestPath") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EventType" }, "IX_EventType"); + + b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress"); + + b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp"); + + b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp") + .IsDescending(false, false, true); + + b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp") + .IsDescending(false, true); + + b.ToTable("AuthLogs"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DateSystem") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("From") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageHtml") + .HasColumnType("text"); + + b.Property("MessagePlain") + .HasColumnType("text"); + + b.Property("MessagePreview") + .HasColumnType("text"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushNotificationSent") + .HasColumnType("boolean"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("To") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("UserEncryptionKeyId"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("integer"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text"); + + b.Property("Filesize") + .HasColumnType("integer"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("text") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("text"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceContext") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TimeStamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClearedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClientIpAddress") + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDecryptionKey") + .HasColumnType("text"); + + b.Property("FulfilledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MobileIpAddress") + .HasColumnType("text"); + + b.Property("RetrievedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress"); + + b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt"); + + b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress"); + + b.HasIndex(new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" }, "IX_RetrievedAt_ClearedAt_FulfilledAt"); + + b.HasIndex(new[] { "UserId" }, "IX_UserId"); + + b.ToTable("MobileLoginRequests"); + }); + + modelBuilder.Entity("AliasServerDb.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IsOnDemand") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("TaskRunnerJobs"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("Address") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Client") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialsCount") + .HasColumnType("integer"); + + b.Property("EmailClaimsCount") + .HasColumnType("integer"); + + b.Property("EncryptionSettings") + .IsRequired() + .HasColumnType("text"); + + b.Property("EncryptionType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("integer"); + + b.Property("RevisionNumber") + .HasColumnType("bigint"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("text"); + + b.Property("Verifier") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey") + .WithMany("Emails") + .HasForeignKey("UserEncryptionKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EncryptionKey"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EmailClaims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EncryptionKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("Vaults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Navigation("EmailClaims"); + + b.Navigation("EncryptionKeys"); + + b.Navigation("Vaults"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Navigation("Emails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.cs b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.cs new file mode 100644 index 000000000..ca1388dd1 --- /dev/null +++ b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddMobileLoginRequest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MobileLoginRequests", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ClientPublicKey = table.Column(type: "text", nullable: false), + EncryptedDecryptionKey = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + FulfilledAt = table.Column(type: "timestamp with time zone", nullable: true), + RetrievedAt = table.Column(type: "timestamp with time zone", nullable: true), + ClearedAt = table.Column(type: "timestamp with time zone", nullable: true), + ClientIpAddress = table.Column(type: "text", nullable: true), + MobileIpAddress = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MobileLoginRequests", x => x.Id); + table.ForeignKey( + name: "FK_MobileLoginRequests_AliasVaultUsers_UserId", + column: x => x.UserId, + principalTable: "AliasVaultUsers", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClientIpAddress", + table: "MobileLoginRequests", + column: "ClientIpAddress"); + + migrationBuilder.CreateIndex( + name: "IX_CreatedAt", + table: "MobileLoginRequests", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_MobileIpAddress", + table: "MobileLoginRequests", + column: "MobileIpAddress"); + + migrationBuilder.CreateIndex( + name: "IX_RetrievedAt_ClearedAt_FulfilledAt", + table: "MobileLoginRequests", + columns: new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UserId", + table: "MobileLoginRequests", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MobileLoginRequests"); + } + } +} diff --git a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 23cf10910..4d5957ab1 100644 --- a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -488,6 +488,54 @@ namespace AliasServerDb.Migrations b.ToTable("Logs", (string)null); }); + modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClearedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClientIpAddress") + .HasColumnType("text"); + + b.Property("ClientPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDecryptionKey") + .HasColumnType("text"); + + b.Property("FulfilledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MobileIpAddress") + .HasColumnType("text"); + + b.Property("RetrievedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress"); + + b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt"); + + b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress"); + + b.HasIndex(new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" }, "IX_RetrievedAt_ClearedAt_FulfilledAt"); + + b.HasIndex(new[] { "UserId" }, "IX_UserId"); + + b.ToTable("MobileLoginRequests"); + }); + modelBuilder.Entity("AliasServerDb.ServerSetting", b => { b.Property("Key") @@ -865,6 +913,15 @@ namespace AliasServerDb.Migrations b.Navigation("Email"); }); + modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => { b.HasOne("AliasServerDb.AliasVaultUser", "User") diff --git a/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs b/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs new file mode 100644 index 000000000..dfe34dbac --- /dev/null +++ b/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs @@ -0,0 +1,80 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using Microsoft.EntityFrameworkCore; + +/// +/// Mobile login request entity for storing temporary login requests. +/// +[Index(nameof(RetrievedAt), nameof(ClearedAt), nameof(FulfilledAt), Name = "IX_RetrievedAt_ClearedAt_FulfilledAt")] +[Index(nameof(ClientIpAddress), Name = "IX_ClientIpAddress")] +[Index(nameof(MobileIpAddress), Name = "IX_MobileIpAddress")] +[Index(nameof(CreatedAt), Name = "IX_CreatedAt")] +[Index(nameof(UserId), Name = "IX_UserId")] +public class MobileLoginRequest +{ + /// + /// Gets or sets the unique identifier for this login request. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the public key from the client (base64 encoded). + /// + public string ClientPublicKey { get; set; } = string.Empty; + + /// + /// Gets or sets the encrypted decryption key from the mobile app (base64 encoded). + /// Will be null until mobile app responds. + /// + public string? EncryptedDecryptionKey { get; set; } + + /// + /// Gets or sets the created timestamp. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets the fulfilled timestamp (when mobile app submitted the response). + /// + public DateTime? FulfilledAt { get; set; } + + /// + /// Gets or sets the retrieved timestamp (when client successfully retrieved and decrypted). + /// + public DateTime? RetrievedAt { get; set; } + + /// + /// Gets or sets the timestamp when sensitive data was cleared from this record. + /// Sensitive data (ClientPublicKey, EncryptedDecryptionKey, Salt, etc.) is cleared + /// after a timeout period to minimize risk if server is compromised. + /// + public DateTime? ClearedAt { get; set; } + + /// + /// Gets or sets the IP address of the client that initiated the request. + /// + public string? ClientIpAddress { get; set; } + + /// + /// Gets or sets the IP address of the mobile device that fulfilled the request. + /// + public string? MobileIpAddress { get; set; } + + /// + /// Gets or sets the user ID (foreign key to AliasVaultUser). + /// Null when record is created, populated when mobile app fulfills the request. + /// + public string? UserId { get; set; } + + /// + /// Gets or sets the navigation property to the user. + /// + public virtual AliasVaultUser? User { get; set; } +} diff --git a/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs b/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs index 48f17a9b0..8f58140bc 100644 --- a/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs +++ b/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs @@ -69,5 +69,41 @@ public class LogCleanupTask : IMaintenanceTask .ExecuteDeleteAsync(cancellationToken); _logger.LogInformation("Deleted {Count} auth log entries older than {Days} days", deletedCount, settings.AuthLogRetentionDays); } + + if (settings.MobileLoginLogRetentionDays > 0) + { + var cutoffDate = DateTime.UtcNow.AddDays(-settings.MobileLoginLogRetentionDays); + var deletedCount = await dbContext.MobileLoginRequests + .Where(x => x.CreatedAt < cutoffDate) + .ExecuteDeleteAsync(cancellationToken); + _logger.LogInformation("Deleted {Count} mobile login request entries older than {Days} days", deletedCount, settings.MobileLoginLogRetentionDays); + } + + // Clear sensitive data from stale mobile login requests (fulfilled > 10 minutes ago but not retrieved) + const int sensitiveDataTimeoutMinutes = 10; + var cutoffTime = DateTime.UtcNow.AddMinutes(-sensitiveDataTimeoutMinutes); + + var staleRequests = await dbContext.MobileLoginRequests + .Where(r => + r.FulfilledAt != null && + r.FulfilledAt < cutoffTime && + r.RetrievedAt == null && + r.ClearedAt == null) + .ToListAsync(cancellationToken); + + if (staleRequests.Count > 0) + { + var now = DateTime.UtcNow; + foreach (var request in staleRequests) + { + // Clear all sensitive data + request.ClientPublicKey = string.Empty; + request.EncryptedDecryptionKey = null; + request.ClearedAt = now; + } + + await dbContext.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Cleared encrypted data from {Count} fulfilled but not retrieved mobile login requests", staleRequests.Count); + } } } diff --git a/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor b/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor index 31a82f827..e7f953f48 100644 --- a/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor +++ b/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor @@ -23,9 +23,32 @@ [Parameter] public string TextFalse { get; set; } = "Disabled"; - private string PillClass => Enabled - ? "bg-green-100 text-green-800" - : "bg-red-100 text-red-800"; + /// + /// Optional color override: "green", "yellow", "red". If not specified, uses Enabled parameter. + /// + [Parameter] + public string? Color { get; set; } + + private string PillClass + { + get + { + if (!string.IsNullOrEmpty(Color)) + { + return Color.ToLower() switch + { + "green" => "bg-green-100 text-green-800", + "yellow" => "bg-yellow-100 text-yellow-800", + "red" => "bg-red-100 text-red-800", + _ => "bg-gray-100 text-gray-800" + }; + } + + return Enabled + ? "bg-green-100 text-green-800" + : "bg-red-100 text-red-800"; + } + } private string StatusText => Enabled ? TextTrue : TextFalse; } diff --git a/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs b/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs index 3eab0434e..6cbe217f2 100644 --- a/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs +++ b/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs @@ -22,6 +22,12 @@ public class ServerSettingsModel /// public int AuthLogRetentionDays { get; set; } = 30; + /// + /// Gets or sets the mobile login log retention days. Defaults to 30. + /// Mobile login requests older than this will be permanently deleted. + /// + public int MobileLoginLogRetentionDays { get; set; } = 30; + /// /// Gets or sets the email retention days. Defaults to 0 (unlimited). /// diff --git a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs index 2b92b1e40..137b0c5a4 100644 --- a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs +++ b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs @@ -133,4 +133,14 @@ public enum ApiErrorCode /// Vault is not up-to-date and requires synchronization. /// VAULT_NOT_UP_TO_DATE, + + /// + /// Mobile login request not found or expired. + /// + MOBILE_LOGIN_REQUEST_NOT_FOUND, + + /// + /// Mobile login request already fulfilled. + /// + MOBILE_LOGIN_REQUEST_ALREADY_FULFILLED, } diff --git a/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs b/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs index ca8b9829f..c84886146 100644 --- a/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs +++ b/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs @@ -27,6 +27,11 @@ public enum AuthEventType /// Logout = 3, + /// + /// Represents a mobile login attempt (login via QR code from mobile app). + /// + MobileLogin = 4, + /// /// Represents JWT access token refresh event issued by client to API. /// diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateRequest.cs new file mode 100644 index 000000000..f08cedb6f --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateRequest.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Request model for initiating a mobile login request. +/// +public class MobileLoginInitiateRequest +{ + /// + /// Gets or sets the public key from the client (base64 encoded). + /// + public required string ClientPublicKey { get; set; } +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateResponse.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateResponse.cs new file mode 100644 index 000000000..59a02e534 --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateResponse.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Response model for mobile login initiate request. +/// +public class MobileLoginInitiateResponse +{ + /// + /// Gets or sets the unique identifier for this login request. + /// + public required string RequestId { get; set; } +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginPollResponse.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginPollResponse.cs new file mode 100644 index 000000000..ec09c1e52 --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginPollResponse.cs @@ -0,0 +1,48 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Response model for polling mobile login status. +/// All sensitive data is encrypted with AES-256, and the AES key is encrypted with client's RSA public key. +/// Client decrypts username to call /login endpoint for salt and encryption settings. +/// +public class MobileLoginPollResponse +{ + /// + /// Gets or sets a value indicating whether the request has been fulfilled by the mobile app. + /// + public required bool Fulfilled { get; set; } + + /// + /// Gets or sets the AES symmetric key encrypted with client's RSA public key (base64 encoded). + /// Used to decrypt all encrypted fields. Null if not fulfilled. + /// + public string? EncryptedSymmetricKey { get; set; } + + /// + /// Gets or sets the JWT token encrypted with AES symmetric key (base64 encoded). Null if not fulfilled. + /// + public string? EncryptedToken { get; set; } + + /// + /// Gets or sets the refresh token encrypted with AES symmetric key (base64 encoded). Null if not fulfilled. + /// + public string? EncryptedRefreshToken { get; set; } + + /// + /// Gets or sets the vault decryption key encrypted with client's RSA public key (base64 encoded). Null if not fulfilled. + /// + public string? EncryptedDecryptionKey { get; set; } + + /// + /// Gets or sets the username encrypted with AES symmetric key (base64 encoded). + /// Retrieved from User via UserId FK. Null if not fulfilled. + /// + public string? EncryptedUsername { get; set; } +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginSubmitRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginSubmitRequest.cs new file mode 100644 index 000000000..da5c87b8b --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginSubmitRequest.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// Request model for submitting mobile login response from mobile app. +/// +public class MobileLoginSubmitRequest +{ + /// + /// Gets or sets the unique identifier for this login request. + /// + public required string RequestId { get; set; } + + /// + /// Gets or sets the encrypted decryption key (base64 encoded). + /// + public required string EncryptedDecryptionKey { get; set; } +} diff --git a/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs b/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs index 404a0242e..2a1cad266 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs @@ -103,7 +103,7 @@ public class BrowserExtensionPlaywrightTest : ClientPlaywrightTest // Test vault loading with username and password await extensionPopup.FillAsync("input[type='text']", TestUserUsername); await extensionPopup.FillAsync("input[type='password']", TestUserPassword); - await extensionPopup.ClickAsync("button:has-text('Login')"); + await extensionPopup.ClickAsync("button:has-text('Log in')"); // Wait for login to complete by waiting for expected text. if (waitForLogin) diff --git a/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs b/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs index 16c2bf7a8..673b40f8c 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs @@ -173,7 +173,7 @@ public class ClientPlaywrightTest : PlaywrightTest { "password", TestUserPassword }, }); - var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" }); + var submitButton = Page.Locator("#unlock-button"); await submitButton.ClickAsync(); // Wait for the sync page to show diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs index 7ea6ff501..39fe5c27c 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs @@ -32,7 +32,7 @@ public class PasswordLockoutTests : AdminPlaywrightTest { await Page.Locator("input[id='username']").FillAsync(TestUserUsername); await Page.Locator("input[id='password']").FillAsync("wrongpassword"); - var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Login to your account" }); + var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Login" }); await submitButton.ClickAsync(); // Wait for the text "Error: Invalid login attempt." to appear if we expect not to be locked out yet.. diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs index 4b57dfa67..cf2bb68ae 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs @@ -66,7 +66,7 @@ public class JwtTokenTests : ClientPlaywrightTest { "password", TestUserPassword }, }); - var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" }); + var submitButton = Page.Locator("#unlock-button"); await submitButton.ClickAsync(); // Check if we get redirected back to the page we were trying to access. @@ -96,9 +96,9 @@ public class JwtTokenTests : ClientPlaywrightTest // Not all pages do a webapi call on load everytime so we need to navigate to a page that does. await NavigateUsingBlazorRouter("test/2"); - await WaitForUrlAsync("user/login", "Login"); + await WaitForUrlAsync("user/login", "Log in"); var pageContent = await Page.TextContentAsync("body"); - Assert.That(pageContent, Does.Contain("Login"), "No redirect to login while refresh token should be expired."); + Assert.That(pageContent, Does.Contain("Log in"), "No redirect to login while refresh token should be expired."); } } diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs index 655491b15..44ee7bea0 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs @@ -65,7 +65,7 @@ public class UnlockTests : ClientPlaywrightTest { "password", TestUserPassword }, }); - var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" }); + var submitButton = Page.Locator("#unlock-button"); await submitButton.ClickAsync(); // Check if we get redirected back to the page we were trying to access. diff --git a/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs b/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs index 46ac8c83d..dbb3980f3 100644 --- a/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs +++ b/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs @@ -473,6 +473,168 @@ public class TaskRunnerTests Assert.That(storedSetting.Value, Is.EqualTo("0"), "MarkUserInactiveAfterDays should be 0 (disabled)"); } + /// + /// Tests the Mobile Login Log Cleanup task. + /// + /// Task. + [Test] + public async Task MobileLoginLogCleanup() + { + // Arrange + await using var dbContext = await _testHostBuilder.GetDbContextAsync(); + + // Set mobile login retention to 30 days + var setting = new ServerSetting + { + Key = "MobileLoginLogRetentionDays", + Value = "30", + }; + dbContext.ServerSettings.Add(setting); + await dbContext.SaveChangesAsync(); + + // Create old mobile login requests (should be deleted) + for (int i = 0; i < 20; i++) + { + var oldRequest = new MobileLoginRequest + { + Id = Guid.NewGuid().ToString(), + ClientPublicKey = "old-test-key", + CreatedAt = DateTime.UtcNow.AddDays(-40 - i), // 40+ days old + ClientIpAddress = "192.168.1.1", + }; + dbContext.MobileLoginRequests.Add(oldRequest); + } + + // Create recent mobile login requests (should be kept) + for (int i = 0; i < 30; i++) + { + var recentRequest = new MobileLoginRequest + { + Id = Guid.NewGuid().ToString(), + ClientPublicKey = "recent-test-key", + CreatedAt = DateTime.UtcNow.AddDays(-i), // 0-29 days old + ClientIpAddress = "192.168.1.2", + }; + dbContext.MobileLoginRequests.Add(recentRequest); + } + + await dbContext.SaveChangesAsync(); + + // Act + await _testHost.StartAsync(); + await WaitForMaintenanceJobCompletion(); + + // Assert + var remainingRequests = await dbContext.MobileLoginRequests.ToListAsync(); + Assert.That(remainingRequests, Has.Count.EqualTo(30), "Only recent mobile login requests (last 30 days) should remain"); + } + + /// + /// Tests the Mobile Login Sensitive Data Cleanup task (runs during nightly maintenance). + /// Sensitive data is automatically cleared after 10 minutes (hardcoded). + /// + /// Task. + [Test] + public async Task MobileLoginSensitiveDataCleanup() + { + // Arrange + await using var dbContext = await _testHostBuilder.GetDbContextAsync(); + + // Create test user + var user = new AliasVaultUser + { + UserName = "testuser", + Email = "testuser@example.tld", + }; + dbContext.AliasVaultUsers.Add(user); + await dbContext.SaveChangesAsync(); + + // Create fulfilled-but-not-retrieved request that's old enough to be cleared (> 10 minutes) + var staleRequest = new MobileLoginRequest + { + Id = Guid.NewGuid().ToString(), + ClientPublicKey = "stale-public-key", + EncryptedDecryptionKey = "encrypted-key-data", + UserId = user.Id, + CreatedAt = DateTime.UtcNow.AddMinutes(-15), + FulfilledAt = DateTime.UtcNow.AddMinutes(-12), // Fulfilled 12 minutes ago (exceeds 10 min timeout) + RetrievedAt = null, // Not yet retrieved + ClearedAt = null, // Not yet cleared + ClientIpAddress = "192.168.1.1", + MobileIpAddress = "10.0.0.1", + }; + dbContext.MobileLoginRequests.Add(staleRequest); + + // Create fulfilled-but-not-retrieved request that's too recent to clear (< 10 minutes) + var recentRequest = new MobileLoginRequest + { + Id = Guid.NewGuid().ToString(), + ClientPublicKey = "recent-public-key", + EncryptedDecryptionKey = "encrypted-key-data", + UserId = user.Id, + CreatedAt = DateTime.UtcNow.AddMinutes(-6), + FulfilledAt = DateTime.UtcNow.AddMinutes(-5), // Fulfilled 5 minutes ago (under 10 min timeout) + RetrievedAt = null, + ClearedAt = null, + ClientIpAddress = "192.168.1.2", + MobileIpAddress = "10.0.0.2", + }; + dbContext.MobileLoginRequests.Add(recentRequest); + + // Create completed request (already retrieved) + var completedRequest = new MobileLoginRequest + { + Id = Guid.NewGuid().ToString(), + ClientPublicKey = "completed-public-key", + EncryptedDecryptionKey = "encrypted-key-data", + UserId = user.Id, + CreatedAt = DateTime.UtcNow.AddMinutes(-15), + FulfilledAt = DateTime.UtcNow.AddMinutes(-12), + RetrievedAt = DateTime.UtcNow.AddMinutes(-11), // Already retrieved + ClearedAt = DateTime.UtcNow.AddMinutes(-11), // Already cleared when retrieved + ClientIpAddress = "192.168.1.3", + MobileIpAddress = "10.0.0.3", + }; + dbContext.MobileLoginRequests.Add(completedRequest); + + await dbContext.SaveChangesAsync(); + + // Act - Run the nightly maintenance cleanup + await _testHost.StartAsync(); + await WaitForMaintenanceJobCompletion(); + + // Assert - Reload entities from database to get updated values + await dbContext.Entry(staleRequest).ReloadAsync(); + await dbContext.Entry(recentRequest).ReloadAsync(); + await dbContext.Entry(completedRequest).ReloadAsync(); + + var staleAfterCleanup = staleRequest; + Assert.Multiple(() => + { + // Stale request should have sensitive data cleared + Assert.That(staleAfterCleanup.ClientPublicKey, Is.Empty, "Stale request ClientPublicKey should be cleared"); + Assert.That(staleAfterCleanup.EncryptedDecryptionKey, Is.Null, "Stale request EncryptedDecryptionKey should be cleared"); + Assert.That(staleAfterCleanup.ClearedAt, Is.Not.Null, "Stale request ClearedAt should be set"); + + // Metadata should be preserved for abuse tracking + Assert.That(staleAfterCleanup.ClientIpAddress, Is.EqualTo("192.168.1.1"), "Client IP should be preserved"); + Assert.That(staleAfterCleanup.MobileIpAddress, Is.EqualTo("10.0.0.1"), "Mobile IP should be preserved"); + Assert.That(staleAfterCleanup.UserId, Is.EqualTo(user.Id), "UserId should be preserved"); + }); + + var recentAfterCleanup = recentRequest; + Assert.Multiple(() => + { + // Recent request should still have sensitive data (not old enough) + Assert.That(recentAfterCleanup.ClientPublicKey, Is.EqualTo("recent-public-key"), "Recent request should retain sensitive data"); + Assert.That(recentAfterCleanup.EncryptedDecryptionKey, Is.Not.Null, "Recent request should retain encrypted key"); + Assert.That(recentAfterCleanup.ClearedAt, Is.Null, "Recent request should not be cleared yet"); + }); + + var completedAfterCleanup = completedRequest; + Assert.That(completedAfterCleanup.ClearedAt, Is.Not.Null, "Completed request should remain cleared"); + } + /// /// Creates a base email with static required fields. /// diff --git a/docs/architecture/index.md b/docs/architecture/index.md index a5d802e31..6182c501e 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -6,6 +6,18 @@ nav_order: 5 --- # Architecture +{: .no_toc } + +
+ + Table of contents + + {: .text-delta } +- TOC +{:toc} +
+ +--- AliasVault implements zero-knowledge encryption where sensitive user data never leaves the client device in unencrypted form. Below is a detailed explanation of how the system secures user data and communications. @@ -13,8 +25,6 @@ AliasVault implements zero-knowledge encryption where sensitive user data never - **Vault Data** (usernames, passwords, notes, passkeys etc.) is fully encrypted client-side before being sent to the server. The server cannot decrypt any vault contents. - **Email Contents**: When emails are received by the server, their contents are immediately encrypted with your public key before being saved. Only you can decrypt and read them with your private key. -*Note: email aliases are stored on the server as "claims" which are linked to internal user IDs for routing purposes.* - ## Diagram The security architecture diagram below illustrates all encryption and authentication processes used in AliasVault to secure user data and communications. @@ -76,7 +86,7 @@ You can also view the diagram in a browser-friendly HTML format: [AliasVault Sec > Note: The use of a symmetric key for email content encryption and asymmetric encryption for the symmetric key (hybrid encryption) is implemented due to RSA's limitations on encryption string length and for better performance. -### 5. Passkey Authentication System +### 5. Passkey Authentication AliasVault includes a virtual passkey authenticator that implements the WebAuthn Level 2 specification, allowing users to securely store and use passkeys for passwordless authentication across websites and services. @@ -134,18 +144,55 @@ AliasVault includes a virtual passkey authenticator that implements the WebAuthn - PRF is supported via browser extension and iOS (0.24.0+) - Android support is pending due to limited Android API support -## Security Benefits -- Zero-knowledge encryption: entire vault is encrypted client-side before transmission -- Email contents are encrypted with your public key immediately upon receipt by the server -- Master password never leaves the client device -- All sensitive operations (key derivation, encryption/decryption) happen locally -- Server stores only encrypted vault data and encrypted email contents -- Multi-layer hybrid encryption for emails provides secure communication -- Optional 2FA adds an additional security layer -- Use of established cryptographic standards (Argon2id, AES-256-GCM, RSA/OAEP) -- Passkey private keys remain encrypted in vault at all times -- Cross-platform passkey sync without compromising security -- WebAuthn compliance eliminates phishing risks through domain binding -- No personally identifiable information required for registration +### 6. Login with Mobile -This security architecture ensures that even if the server is compromised, vault contents and email messages remain secure and unreadable as all sensitive operations and keys remain strictly on the client side. Email aliases (stored on the server as "claims" for routing) are linked to internal user IDs, not real-world identities. +AliasVault includes a secure "Login with Mobile" feature that allows users to unlock their vault on web browsers or browser extensions by scanning a QR code with their mobile app. This system provides a convenient authentication method while maintaining zero-knowledge security through end-to-end encryption. + +#### Security Architecture + +The mobile login system uses a hybrid encryption approach combining RSA asymmetric encryption and AES-256-GCM symmetric encryption to ensure that: +- The server never has access to the user's decryption key in plaintext +- Only the authorized client that initiated the request can decrypt the transmitted data +- No sensitive data persists on the server after retrieval + +#### Authentication Flow + +1. **Initiation (Browser/Extension Client)** + - Client generates an RSA-2048 key pair locally + - Public key is sent to the server + - Server creates a unique request ID and stores the public key + - Client generates a QR code containing the request ID + - Private key is kept only in memory (never persisted to disk) + +2. **QR Code Scanning (Mobile App)** + - User scans the QR code with their authenticated mobile app + - Mobile app retrieves the public key from server + - Mobile app encrypts the user's vault decryption key using the RSA public key + - Encrypted decryption key is sent to server + - Server stores the encrypted decryption key and marks the request as fulfilled + +3. **Polling and Retrieval (Browser/Extension Client)** + - Client polls the server every few seconds + - Polling continues for up to 2 minutes (3-minute server-side timeout for buffer) + - When fulfilled, server: + - Generates a fresh JWT access token and refresh token for the user + - Creates a random 256-bit AES symmetric key + - Encrypts the JWT tokens and username using this symmetric key + - Encrypts the symmetric key itself using the client's RSA public key + - Returns all encrypted data in the response + - Immediately marks the request as retrieved and clears sensitive data from database + +4. **Decryption (Browser/Extension Client)** + - Client uses its RSA private key to decrypt the symmetric key + - Client uses the symmetric key to decrypt the JWT tokens and username + - Client uses the RSA private key to decrypt the vault decryption key + - Client can now unlock the vault using the decryption key and stores it in memory + - RSA private key is immediately purged from memory + +#### Security Properties + +- **Zero-Knowledge**: The server never has access to the vault decryption key in plaintext. It only temporarily stores the RSA-encrypted version. +- **One-Time Use**: Once a mobile login request is retrieved by the client, it cannot be accessed again. The encrypted data is immediately cleared from the database. +- **Automatic Expiration**: Fulfilled but unretrieved requests are automatically deleted by the server within 24 hours to prevent stale data accumulation. +- **Man-in-the-Middle Protection**: The encryption scheme ensures that any eavesdroppers cannot intercept the decryption key. Only the local client that started the mobile login request has the private key for decryption. +- **Short-Lived Requests**: The 3-minute timeout window limits the attack surface for QR code interception. diff --git a/docs/mobile-apps/android/build-from-source.md b/docs/mobile-apps/android/build-from-source.md index 7fb014c62..3bd7b36f2 100644 --- a/docs/mobile-apps/android/build-from-source.md +++ b/docs/mobile-apps/android/build-from-source.md @@ -32,27 +32,14 @@ cd aliasvault/apps/mobile-app npm install ``` -4. Install and build Android dependencies: +4. Deploy release build to your device via React Native automatically: + ```bash -cd android -./gradlew assembleRelease +npx react-native run-android --mode release ``` -5. Deploy release build to your device: -For MacOS, install adb to deploy the app to a phone or simulator via command line: -```bash -# Install adb -brew install android-platform-tools - -# List devices (physical devices connected via USB and any running simulators) -adb devices - -# Deploy to chosen device -adb -s [device-id] install android/app/build/outputs/apk/release/app-release.apk -``` - -6. For publishing to Google Play: +5. For publishing to Google Play: Create a local gradle file in your user directory where the keystore credentials will be placed ```bash nano ~/.gradle/gradle.properties diff --git a/shared/models/src/webapi/AuthEventType.ts b/shared/models/src/webapi/AuthEventType.ts index bd200c404..9991ea0c4 100644 --- a/shared/models/src/webapi/AuthEventType.ts +++ b/shared/models/src/webapi/AuthEventType.ts @@ -17,6 +17,11 @@ export enum AuthEventType { */ Logout = 3, + /** + * Represents a mobile login attempt (login via QR code from mobile app). + */ + MobileLogin = 4, + /** * Represents JWT access token refresh event issued by client to API. */ diff --git a/shared/models/src/webapi/MobileLogin.ts b/shared/models/src/webapi/MobileLogin.ts new file mode 100644 index 000000000..a0c0c0b11 --- /dev/null +++ b/shared/models/src/webapi/MobileLogin.ts @@ -0,0 +1,33 @@ +/** + * Mobile login initiate request type. + */ +export type MobileLoginInitiateRequest = { + clientPublicKey: string; +} + +/** + * Mobile login initiate response type. + */ +export type MobileLoginInitiateResponse = { + requestId: string; +} + +/** + * Mobile login submit request type. + */ +export type MobileLoginSubmitRequest = { + requestId: string; + encryptedDecryptionKey: string; +} + +/** + * Mobile login poll response type. + */ +export type MobileLoginPollResponse = { + fulfilled: boolean; + encryptedSymmetricKey: string | null; + encryptedToken: string | null; + encryptedRefreshToken: string | null; + encryptedDecryptionKey: string | null; + encryptedUsername: string | null; +} diff --git a/shared/models/src/webapi/ValidateLogin.ts b/shared/models/src/webapi/ValidateLogin.ts index e64eaf216..81360c9a2 100644 --- a/shared/models/src/webapi/ValidateLogin.ts +++ b/shared/models/src/webapi/ValidateLogin.ts @@ -19,14 +19,19 @@ export type ValidateLoginRequest2Fa = { clientSessionProof: string; } +/** + * Token model type. + */ +export type TokenModel = { + token: string; + refreshToken: string; +} + /** * Validate login response type. */ export type ValidateLoginResponse = { requiresTwoFactor: boolean; - token?: { - token: string; - refreshToken: string; - }; + token?: TokenModel; serverSessionProof: string; } \ No newline at end of file diff --git a/shared/models/src/webapi/index.ts b/shared/models/src/webapi/index.ts index 75c1c8832..43ea8d481 100644 --- a/shared/models/src/webapi/index.ts +++ b/shared/models/src/webapi/index.ts @@ -21,3 +21,4 @@ export * from './PasswordChangeInitiateResponse'; export * from './VaultPasswordChangeRequest'; export * from './BadRequestResponse'; export * from './AuthEventType'; +export * from './MobileLogin';