diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca4e7767f..42b2bdcbb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,33 @@ on: type: boolean jobs: + # Guard job to prevent releases from main branch + valid-release: + if: github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check tag target + run: | + BRANCHES=$(git branch -r --contains $GITHUB_SHA) + + echo "Tag is contained in:" + echo "$BRANCHES" + + if ! echo "$BRANCHES" | grep -q "origin/release/"; then + echo "❌ Releases must come from a release/* branch, please recreate the release from a release branch" + exit 1 + fi + + echo "✅ Tag is on a release branch" + upload-install-script: + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') runs-on: ubuntu-latest permissions: contents: write @@ -43,7 +69,8 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} build-chrome-extension: - if: github.event_name == 'release' || inputs.build_browser_extensions + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_browser_extensions) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -58,7 +85,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-firefox-extension: - if: github.event_name == 'release' || inputs.build_browser_extensions + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_browser_extensions) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -73,7 +101,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-edge-extension: - if: github.event_name == 'release' || inputs.build_browser_extensions + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_browser_extensions) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -88,7 +117,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-android-release: - if: github.event_name == 'release' || inputs.build_mobile_apps + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_mobile_apps) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -107,7 +137,8 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} build-and-push-docker-multi-container: - if: github.event_name == 'release' || inputs.build_multi_container + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_multi_container) runs-on: ubuntu-latest permissions: contents: read @@ -372,7 +403,8 @@ jobs: annotations: ${{ steps.installcli-meta.outputs.annotations }} build-and-push-docker-all-in-one: - if: github.event_name == 'release' || inputs.build_all_in_one + needs: [valid-release] + if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_all_in_one) runs-on: ubuntu-latest permissions: contents: read diff --git a/apps/mobile-app/android/app/src/main/AndroidManifest.xml b/apps/mobile-app/android/app/src/main/AndroidManifest.xml index 0dd908452..9bfd1b083 100644 --- a/apps/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile-app/android/app/src/main/AndroidManifest.xml @@ -40,13 +40,13 @@ diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/OriginVerifier.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/OriginVerifier.kt new file mode 100644 index 000000000..ef0174575 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/OriginVerifier.kt @@ -0,0 +1,1081 @@ +package net.aliasvault.app.credentialprovider + +import android.os.Build +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.provider.CallingAppInfo +import org.json.JSONArray +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** + * Validates caller origin for WebAuthn credential requests. + * + * For privileged apps (browsers): Uses CallingAppInfo.getOrigin() with an allowlist. + * For native apps: Computes facetId from the app's signing certificate SHA-256 hash. + * + * Reference: https://developer.android.com/identity/sign-in/credential-provider + */ +class OriginVerifier { + + companion object { + private const val TAG = "OriginVerifier" + + /** + * Privileged app allowlist for CallingAppInfo.getOrigin(). + * Includes major browsers trusted to make requests on behalf of web origins. + * ------ + * 2025-12-30 cached version based on Google's full list: https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json + */ + private val PRIVILEGED_ALLOWLIST_JSON = """ + { + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.android.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.chromium.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.apps.chrome", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_webauthndebug", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_aurora", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BC:04:88:83:8D:06:F4:CA:6B:F3:23:86:DA:AB:0D:D8:EB:CF:3E:77:30:78:74:59:F6:2F:B3:CD:14:A1:BA:AA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.rocket", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "86:3A:46:F0:97:39:32:B7:D0:19:9B:54:91:12:74:1C:2D:27:31:AC:72:EA:11:B7:52:3A:A9:0A:11:BF:56:91" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fenix", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "50:04:77:90:88:E7:F9:88:D5:BC:5C:C5:F8:79:8F:EB:F4:F8:CD:08:4A:1B:2A:46:EF:D4:C8:EE:4A:EA:F2:11" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fenix.debug", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus.nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.klar", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.reference.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "B0:09:90:E3:0F:9D:81:5D:2E:BC:7B:9B:B2:21:CE:47:E5:C9:D5:17:AA:C7:0E:7F:D5:95:B1:E5:3E:9A:4B:14" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.rolling", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.local", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "app.vanadium.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.snapshot", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.sopranos", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.citrix.Receiver", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "3D:D1:12:67:10:69:AB:36:4E:F9:BE:73:9A:B7:B5:EE:15:E1:CD:E9:D8:75:7B:1B:F0:64:F5:0C:55:68:9A:49" + }, + { + "build": "release", + "cert_fingerprint_sha256": "CE:B2:23:D7:77:09:F2:B6:BC:0B:3A:78:36:F5:A5:AF:4C:E1:D3:55:F4:A7:28:86:F7:9D:F8:0D:C9:D6:12:2E" + }, + { + "build": "release", + "cert_fingerprint_sha256": "AA:D0:D4:57:E6:33:C3:78:25:77:30:5B:C1:B2:D9:E3:81:41:C7:21:DF:0D:AA:6E:29:07:2F:C4:1D:34:F0:AB" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.android.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C9:00:9D:01:EB:F9:F5:D0:30:2B:C7:1B:2F:E9:AA:9A:47:A4:32:BB:A1:73:08:A3:11:1B:75:D7:B2:14:90:25" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.gms", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "7C:E8:3C:1B:71:F3:D5:72:FE:D0:4C:8D:40:C5:CB:10:FF:75:E6:D8:7D:9D:F6:FB:D5:3F:04:68:C2:90:50:53" + }, + { + "build": "release", + "cert_fingerprint_sha256": "D2:2C:C5:00:29:9F:B2:28:73:A0:1A:01:0D:E1:C8:2F:BE:4D:06:11:19:B9:48:14:DD:30:1D:AB:50:CB:76:78" + }, + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.alpha", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.corp", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.broteam", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.talonsec.talon", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A3:66:03:44:A6:F6:AF:CA:81:8C:BF:43:96:A2:3C:CF:D5:ED:7A:78:1B:B4:A3:D1:85:03:01:E2:F4:6D:23:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "E2:A5:64:74:EA:23:7B:06:67:B6:F5:2C:DC:E9:04:5E:24:88:3B:AE:D0:82:59:9A:A2:DF:0B:60:3A:CF:6A:3B" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.talonsec.talon_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F5:86:62:7A:32:C8:9F:E6:7E:00:6D:B1:8C:34:31:9E:01:7F:B3:B2:BE:D6:9D:01:01:B7:F9:43:E7:7C:48:AE" + }, + { + "build": "release", + "cert_fingerprint_sha256": "9A:A1:25:D5:E5:5E:3F:B0:DE:96:72:D9:A9:5D:04:65:3F:49:4A:1E:C3:EE:76:1E:94:C4:4E:5D:2F:65:8E:2F" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android.debug", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C4:F0:9E:2B:D7:25:AD:F5:AD:92:0B:A2:80:27:66:AC:16:4A:C1:53:B3:EA:9E:08:48:B0:57:98:37:F7:6A:29" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BB:7B:B3:1C:57:3C:46:A1:DA:7F:C5:C5:28:A6:AC:F4:32:10:84:56:FE:EC:50:81:0C:7F:33:69:4E:B3:D2:D4" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.naver.whale", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "0B:8B:85:23:BB:4A:EF:FA:34:6E:4B:DD:4F:BF:7D:19:34:50:56:9A:A1:4A:AA:D4:AD:FD:94:A3:F7:B2:27:BB" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.fido.fido2client", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "FC:98:DA:E6:3A:D3:96:26:C8:C6:7F:BE:83:F2:F0:6F:74:93:2A:9C:D1:46:B9:2C:EC:FC:6A:04:7A:90:43:86" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.heytap.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5" + }, + { + "build": "release", + "cert_fingerprint_sha256": "A8:FE:A4:CA:FB:93:32:DA:26:B8:E6:81:08:17:C1:DA:90:A5:03:0E:35:A6:0A:79:E0:6C:90:97:AA:C6:A4:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.Island", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "D9:C3:39:AC:9C:3A:EE:E1:75:1D:85:8C:35:D9:BA:C5:CC:87:B3:CE:76:30:93:F0:F5:10:64:F5:A2:F6:9B:04" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.IslandCanary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:17:13:23:45:6E:6F:39:CB:FD:CF:B2:56:BE:1D:CF:F3:BC:1C:59:8A:15:93:30:E4:97:73:D0:4C:B9:C9:05" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.IslandBeta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "35:31:83:1A:9E:2B:21:1D:E6:AA:C3:69:4B:45:83:6E:56:09:B9:D7:D0:04:C3:1B:21:87:40:FB:77:17:38:D1" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.IslandDev", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.intune", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C2:38:24:15:41:20:A0:8F:C3:95:42:AC:D8:2A:E9:24:94:78:80:1E:47:FD:6C:66:2B:18:1C:28:CA:7E:59:4E" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.canary.intune", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1E:16:74:BB:79:EA:09:FB:37:CF:9F:1B:07:1B:1D:51:8D:46:03:0E:D3:EE:F2:C1:4E:AD:93:9E:C6:EE:3A:4C" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.beta.intune", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "D2:5E:AD:F6:1C:E6:36:6C:A4:23:A4:7F:C4:DB:9B:8C:9C:8A:35:B4:B0:19:E8:D9:82:FB:D0:8A:D9:DB:49:5A" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.dev.intune", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "net.quetta.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BE:FE:E7:31:12:6A:A5:6E:7E:FD:AE:AF:5E:F3:FA:EA:44:1C:19:CC:E0:CA:EC:42:6B:65:BB:F8:2C:59:46:80" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "F1:38:00:4F:38:04:51:D4:8A:05:2B:B3:A3:EF:17:24:23:D4:B0:D0:C8:A3:AA:DD:FB:DB:66:30:31:48:EC:A4" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "cz.seznam.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DB:95:40:66:10:78:83:6E:4E:B1:66:F6:9E:F4:07:30:9E:8D:AE:33:34:68:5E:C8:F6:FA:2F:13:81:B9:AC:F6" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.opera.mini.native", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.opera.mini.native.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2" + } + ] + } + } + ] + } + """.trimIndent() + } + + /** Result of origin verification. */ + sealed class OriginResult { + /** + * Origin was successfully verified. + * @property origin The verified origin string. + * @property isPrivileged True if the caller is a privileged app (browser). + */ + data class Success(val origin: String, val isPrivileged: Boolean) : OriginResult() + + /** + * Origin verification failed. + * @property reason Human-readable reason for failure. + */ + data class Failure(val reason: String) : OriginResult() + } + + /** Result of asset links verification. */ + private sealed class AssetLinksResult { + data object Success : AssetLinksResult() + data class Failure(val reason: String) : AssetLinksResult() + } + + /** + * Verify and compute the origin for a credential request. + */ + @RequiresApi(Build.VERSION_CODES.P) + fun verifyOrigin( + callingAppInfo: CallingAppInfo?, + requestedRpId: String, + ): OriginResult { + if (callingAppInfo == null) { + Log.e(TAG, "CallingAppInfo is null - cannot verify origin") + return OriginResult.Failure("Cannot identify calling application") + } + + val packageName = callingAppInfo.packageName + Log.d(TAG, "Verifying origin for package: $packageName, rpId: $requestedRpId") + + // Try to get origin from privileged app (browser) + val privilegedOrigin = try { + callingAppInfo.getOrigin(PRIVILEGED_ALLOWLIST_JSON) + } catch (e: Exception) { + Log.d(TAG, "Not a privileged caller or getOrigin failed: ${e.message}") + null + } + + if (privilegedOrigin != null) { + Log.d(TAG, "Privileged caller detected, origin: $privilegedOrigin") + + if (!isOriginValidForRpId(privilegedOrigin, requestedRpId)) { + Log.w(TAG, "Privileged origin $privilegedOrigin does not match rpId $requestedRpId") + return OriginResult.Failure("Origin does not match RP ID") + } + + return OriginResult.Success(privilegedOrigin, isPrivileged = true) + } + + // Not a privileged caller - compute facetId from signing certificate + val facetId = computeFacetId(callingAppInfo) + if (facetId == null) { + Log.e(TAG, "Failed to compute facetId for $packageName") + return OriginResult.Failure("Cannot verify calling application signature") + } + + Log.d(TAG, "Native app detected, facetId: $facetId") + + // Verify the native app is authorized via Asset Links + val certFingerprint = getCertificateFingerprint(callingAppInfo) + if (certFingerprint == null) { + Log.e(TAG, "Failed to get certificate fingerprint for $packageName") + return OriginResult.Failure("Cannot verify calling application signature") + } + + val assetLinksResult = verifyAssetLinks(requestedRpId, packageName, certFingerprint) + if (assetLinksResult is AssetLinksResult.Failure) { + Log.e(TAG, "Asset links verification failed for $packageName on $requestedRpId: ${assetLinksResult.reason}") + return OriginResult.Failure(assetLinksResult.reason) + } + + Log.d(TAG, "Asset links verification passed for $packageName on $requestedRpId") + return OriginResult.Success(facetId, isPrivileged = false) + } + + /** + * Compute the facetId (origin) for a native Android app. + * Format: android:apk-key-hash: + */ + @RequiresApi(Build.VERSION_CODES.P) + private fun computeFacetId(callingAppInfo: CallingAppInfo): String? { + return try { + val signingInfo = callingAppInfo.signingInfo + val signers = signingInfo.apkContentsSigners + if (signers.isEmpty()) { + Log.e(TAG, "No signing certificates found for ${callingAppInfo.packageName}") + return null + } + + val cert = signers[0].toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val certHash = md.digest(cert) + + // Encode as base64url (no padding, URL-safe characters) + val base64Hash = Base64.encodeToString( + certHash, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING, + ) + + "android:apk-key-hash:$base64Hash" + } catch (e: Exception) { + Log.e(TAG, "Error computing facetId", e) + null + } + } + + /** + * Verify that an origin is valid for the given RP ID. + * The origin must be https:// and the host must equal or be a subdomain of the RP ID. + */ + private fun isOriginValidForRpId(origin: String, rpId: String): Boolean { + val originHost = try { + val url = URL(origin) + if (url.protocol != "https") { + Log.w(TAG, "Origin is not HTTPS: $origin") + return false + } + url.host.lowercase() + } catch (e: Exception) { + Log.w(TAG, "Invalid origin URL: $origin", e) + return false + } + + val rpIdLower = rpId.lowercase() + return originHost == rpIdLower || originHost.endsWith(".$rpIdLower") + } + + /** + * Verify that a native app is authorized for the given RP ID via Asset Links. + * Fetches /.well-known/assetlinks.json and checks for get_login_creds permission. + */ + private fun verifyAssetLinks(rpId: String, packageName: String, certHash: String): AssetLinksResult { + return try { + val assetLinksUrl = URL("https://$rpId/.well-known/assetlinks.json") + val connection = assetLinksUrl.openConnection() as HttpURLConnection + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.requestMethod = "GET" + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "Asset links not found for $rpId: ${connection.responseCode}") + return AssetLinksResult.Failure( + "App is not authorized to access passkeys for $rpId", + ) + } + + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val response = reader.readText() + reader.close() + + val assetLinks = JSONArray(response) + val normalizedCertHash = certHash.replace(":", "").lowercase() + + val found = (0 until assetLinks.length()).any { i -> + val link = assetLinks.getJSONObject(i) + isMatchingAssetLink(link, packageName, normalizedCertHash) + } + + if (found) { + Log.d(TAG, "Asset links verification passed for $packageName on $rpId") + AssetLinksResult.Success + } else { + Log.w(TAG, "App $packageName not found in asset links for $rpId") + AssetLinksResult.Failure( + "App is not authorized to access passkeys for $rpId", + ) + } + } catch (e: Exception) { + Log.w(TAG, "Asset links verification failed for $rpId", e) + AssetLinksResult.Failure( + "App is not authorized to access passkeys for $rpId", + ) + } + } + + /** Check if an asset link entry matches the given package and certificate. */ + private fun isMatchingAssetLink( + link: org.json.JSONObject, + packageName: String, + normalizedCertHash: String, + ): Boolean { + val relation = link.optJSONArray("relation") ?: return false + val target = link.optJSONObject("target") ?: return false + + if (target.optString("namespace") != "android_app") return false + if (target.optString("package_name") != packageName) return false + + val hasGetLoginCreds = (0 until relation.length()).any { j -> + relation.getString(j) == "delegate_permission/common.get_login_creds" + } + if (!hasGetLoginCreds) return false + + val fingerprints = target.optJSONArray("sha256_cert_fingerprints") ?: return false + return (0 until fingerprints.length()).any { j -> + fingerprints.getString(j).replace(":", "").lowercase() == normalizedCertHash + } + } + + /** Get a human-readable certificate fingerprint. */ + @RequiresApi(Build.VERSION_CODES.P) + fun getCertificateFingerprint(callingAppInfo: CallingAppInfo): String? { + return try { + val signers = callingAppInfo.signingInfo.apkContentsSigners + if (signers.isEmpty()) return null + + val cert = signers[0].toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val certHash = md.digest(cert) + + certHash.joinToString(":") { "%02X".format(it) } + } catch (e: Exception) { + Log.e(TAG, "Error getting certificate fingerprint", e) + null + } + } +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt index b4a214477..4b45eb5e4 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyAuthenticationActivity.kt @@ -1,6 +1,7 @@ package net.aliasvault.app.credentialprovider import android.content.Intent +import android.os.Build import android.os.Bundle import android.util.Log import android.view.View @@ -8,6 +9,10 @@ import android.widget.TextView import androidx.credentials.provider.PendingIntentHandler import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.aliasvault.app.R import net.aliasvault.app.utils.Helpers import net.aliasvault.app.vaultstore.VaultStore @@ -109,6 +114,19 @@ class PasskeyAuthenticationActivity : FragmentActivity() { } } + /** + * Update the loading message displayed to the user. + */ + private fun updateLoadingMessage(messageResId: Int) { + runOnUiThread { + try { + findViewById(R.id.loadingMessage)?.text = getString(messageResId) + } catch (e: Exception) { + Log.w(TAG, "Could not update loading message", e) + } + } + } + /** * Process the passkey authentication request and generate assertion. * Called after authentication (biometric or PIN) succeeds and vault is unlocked. @@ -120,107 +138,149 @@ class PasskeyAuthenticationActivity : FragmentActivity() { finish() return } - try { - // Extract passkey ID from intent - val passkeyIdString = intent.getStringExtra( - AliasVaultCredentialProviderService.EXTRA_PASSKEY_ID, - ) - if (passkeyIdString == null) { - Log.e(TAG, "No passkey ID in intent") - setResult(RESULT_CANCELED) - finish() - return - } - val passkeyId = UUID.fromString(passkeyIdString.uppercase()) - - // Get database connection from vault (should be unlocked at this point) - val db = vaultStore.database - if (db == null) { - Log.e(TAG, "Database not available - vault may not be unlocked") - setResult(RESULT_CANCELED) - finish() - return - } - - val passkey = vaultStore.getPasskeyById(passkeyId, db) - if (passkey == null) { - Log.e(TAG, "Passkey not found: $passkeyId") - setResult(RESULT_CANCELED) - finish() - return - } - - val requestJson = intent.getStringExtra( - AliasVaultCredentialProviderService.EXTRA_REQUEST_JSON, - ) ?: "" - val requestObj = JSONObject(requestJson) - - // Extract clientDataHash from the calling app's request - // Browsers (Chrome, Firefox, Edge, etc.) provide this, native apps typically don't - val providedClientDataHash: ByteArray? = providerRequest.credentialOptions - .filterIsInstance() - .firstOrNull()?.clientDataHash - - // Determine clientDataHash and clientDataJson based on what caller provided - val clientDataHash: ByteArray - val clientDataJson: String? - if (providedClientDataHash != null) { - // Browser provided clientDataHash - use it directly - // The browser has its own clientDataJSON with the web origin - clientDataHash = providedClientDataHash - clientDataJson = null - } else { - // Native app scenario - build clientDataJSON ourselves and hash it - val challenge = requestObj.optString("challenge", "") - val origin = requestObj.optString("origin", "https://${passkey.rpId}") - val json = buildClientDataJson(challenge, origin) - clientDataHash = sha256(json.toByteArray(Charsets.UTF_8)) - clientDataJson = json - } - - // Use PasskeyAuthenticator.getAssertion for signing - val credentialId = PasskeyHelper.guidToBytes(passkey.id.toString()) - val prfInputs = extractPrfInputs(requestObj) - val assertion = PasskeyAuthenticator.getAssertion( - credentialId = credentialId, - clientDataHash = clientDataHash, - rpId = passkey.rpId, - privateKeyJWK = passkey.privateKey, - userId = passkey.userHandle, - uvPerformed = true, - prfInputs = prfInputs, - prfSecret = passkey.prfKey, - ) - - // Build response JSON - val response = buildPublicKeyCredentialResponse( - assertion = assertion, - clientDataJson = clientDataJson, - ) - - val resultIntent = Intent() + lifecycleScope.launch { try { - PendingIntentHandler.setGetCredentialResponse(resultIntent, response) - setResult(RESULT_OK, resultIntent) - } catch (e: Exception) { - Log.e(TAG, "Error setting credential response", e) - try { - PendingIntentHandler.setGetCredentialException( - resultIntent, - androidx.credentials.exceptions.GetCredentialUnknownException("Failed to generate assertion: ${e.message}"), - ) - setResult(RESULT_OK, resultIntent) - } catch (e2: Exception) { - Log.e(TAG, "Error setting exception", e2) + // Show retrieving status to user + updateLoadingMessage(R.string.passkey_retrieving) + + // Extract passkey ID from intent + val passkeyIdString = intent.getStringExtra( + AliasVaultCredentialProviderService.EXTRA_PASSKEY_ID, + ) + if (passkeyIdString == null) { + Log.e(TAG, "No passkey ID in intent") setResult(RESULT_CANCELED) + finish() + return@launch } + + val passkeyId = UUID.fromString(passkeyIdString.uppercase()) + + // Get database connection from vault + val db = vaultStore.database + if (db == null) { + Log.e(TAG, "Database not available - vault may not be unlocked") + setResult(RESULT_CANCELED) + finish() + return@launch + } + + val passkey = vaultStore.getPasskeyById(passkeyId, db) + if (passkey == null) { + Log.e(TAG, "Passkey not found: $passkeyId") + setResult(RESULT_CANCELED) + finish() + return@launch + } + + val requestJson = intent.getStringExtra( + AliasVaultCredentialProviderService.EXTRA_REQUEST_JSON, + ) ?: "" + val requestObj = JSONObject(requestJson) + + // Extract clientDataHash from the calling app's request + // Browsers (Chrome, Firefox, Edge, etc.) provide this, native apps typically don't + val providedClientDataHash: ByteArray? = providerRequest.credentialOptions + .filterIsInstance() + .firstOrNull()?.clientDataHash + + // Show verifying status to user + updateLoadingMessage(R.string.passkey_verifying) + + // Verify origin of the calling app + val originVerifier = OriginVerifier() + val callingAppInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + providerRequest.callingAppInfo + } else { + null + } + + // Run origin verification on IO thread + val originResult = withContext(Dispatchers.IO) { + originVerifier.verifyOrigin( + callingAppInfo = callingAppInfo, + requestedRpId = passkey.rpId, + ) + } + + val verifiedOrigin: String + val isPrivilegedCaller: Boolean + + when (originResult) { + is OriginVerifier.OriginResult.Success -> { + verifiedOrigin = originResult.origin + isPrivilegedCaller = originResult.isPrivileged + Log.d(TAG, "Origin verified: $verifiedOrigin (privileged: $isPrivilegedCaller)") + } + is OriginVerifier.OriginResult.Failure -> { + Log.e(TAG, "Origin verification failed: ${originResult.reason}") + showError("Security error: ${originResult.reason}") + return@launch + } + } + + // Show authenticating status to user + updateLoadingMessage(R.string.passkey_authenticating) + + // Determine clientDataHash and clientDataJson based on what caller provided + val clientDataHash: ByteArray + val clientDataJson: String? + if (providedClientDataHash != null && isPrivilegedCaller) { + // Browser provided clientDataHash - use it directly + clientDataHash = providedClientDataHash + clientDataJson = null + } else { + // Native app scenario - build clientDataJSON ourselves + val challenge = requestObj.optString("challenge", "") + val json = buildClientDataJson(challenge, verifiedOrigin) + clientDataHash = sha256(json.toByteArray(Charsets.UTF_8)) + clientDataJson = json + } + + // Use PasskeyAuthenticator.getAssertion for signing + val credentialId = PasskeyHelper.guidToBytes(passkey.id.toString()) + val prfInputs = extractPrfInputs(requestObj) + val assertion = PasskeyAuthenticator.getAssertion( + credentialId = credentialId, + clientDataHash = clientDataHash, + rpId = passkey.rpId, + privateKeyJWK = passkey.privateKey, + userId = passkey.userHandle, + uvPerformed = true, + prfInputs = prfInputs, + prfSecret = passkey.prfKey, + ) + + // Build response JSON + val response = buildPublicKeyCredentialResponse( + assertion = assertion, + clientDataJson = clientDataJson, + ) + + val resultIntent = Intent() + try { + PendingIntentHandler.setGetCredentialResponse(resultIntent, response) + setResult(RESULT_OK, resultIntent) + } catch (e: Exception) { + Log.e(TAG, "Error setting credential response", e) + try { + PendingIntentHandler.setGetCredentialException( + resultIntent, + androidx.credentials.exceptions.GetCredentialUnknownException("Failed to generate assertion: ${e.message}"), + ) + setResult(RESULT_OK, resultIntent) + } catch (e2: Exception) { + Log.e(TAG, "Error setting exception", e2) + setResult(RESULT_CANCELED) + } + } + finish() + } catch (e: Exception) { + Log.e(TAG, "Error processing authentication request", e) + setResult(RESULT_CANCELED) + finish() } - finish() - } catch (e: Exception) { - Log.e(TAG, "Error processing authentication request", e) - setResult(RESULT_CANCELED) - finish() } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt index d32b7c3ef..64c0a16dc 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyFormFragment.kt @@ -246,8 +246,11 @@ class PasskeyFormFragment : Fragment() { val requestObj = JSONObject(viewModel.requestJson) val challenge = requestObj.optString("challenge", "") - // Use origin from the request, or fallback to RP id - val requestOrigin = viewModel.origin ?: ("https://" + viewModel.rpId) + // Use the origin set by PasskeyRegistrationActivity + val requestOrigin = viewModel.origin + ?: throw net.aliasvault.app.exceptions.PasskeyOperationException( + "Origin not available", + ) // Extract PRF inputs if present val prfInputs = extractPrfInputs(requestObj) @@ -438,8 +441,9 @@ class PasskeyFormFragment : Fragment() { val requestObj = JSONObject(viewModel.requestJson) val challenge = requestObj.optString("challenge", "") - // Use origin from the request - val requestOrigin = viewModel.origin ?: throw PasskeyOperationException("Origin not available") + // Use the origin set by PasskeyRegistrationActivity + val requestOrigin = viewModel.origin + ?: throw PasskeyOperationException("Origin not available") // Extract PRF inputs if present val prfInputs = extractPrfInputs(requestObj) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt index b9927b917..f351a43e7 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt @@ -1,14 +1,20 @@ package net.aliasvault.app.credentialprovider import android.content.Intent +import android.os.Build import android.os.Bundle import android.util.Log import android.view.View import android.widget.TextView import androidx.activity.viewModels import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.aliasvault.app.R import net.aliasvault.app.credentialprovider.models.PasskeyRegistrationViewModel import net.aliasvault.app.utils.Helpers @@ -65,10 +71,9 @@ class PasskeyRegistrationActivity : FragmentActivity() { return } - // Get requestJson, clientDataHash, and origin from the request + // Get requestJson, clientDataHash from the request viewModel.requestJson = createRequest.requestJson viewModel.clientDataHash = createRequest.clientDataHash - viewModel.origin = createRequest.origin // Parse request JSON to extract RP ID and user info val requestObj = JSONObject(viewModel.requestJson) @@ -102,29 +107,18 @@ class PasskeyRegistrationActivity : FragmentActivity() { null } - // Show loading screen while unlock is in progress + // Show loading screen while verification and unlock are in progress setContentView(R.layout.activity_loading) - // Initialize unlock coordinator - unlockCoordinator = UnlockCoordinator( - activity = this, - vaultStore = vaultStore, - onUnlocked = { - // Vault unlocked successfully - proceed with passkey registration - proceedWithPasskeyRegistration(savedInstanceState) - }, - onCancelled = { - // User cancelled unlock - finish() - }, - onError = { errorMessage -> - // Error during unlock - showError(errorMessage) - }, - ) + // Get calling app info for origin verification + val callingAppInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + providerRequest.callingAppInfo + } else { + null + } - // Start the unlock flow - unlockCoordinator.startUnlockFlow() + // Verify origin and start unlock flow + verifyOriginAndStartUnlock(callingAppInfo, savedInstanceState) } catch (e: Exception) { Log.e(TAG, "Error in onCreate", e) finish() @@ -140,6 +134,76 @@ class PasskeyRegistrationActivity : FragmentActivity() { } } + /** + * Update the loading message displayed to the user. + */ + private fun updateLoadingMessage(messageResId: Int) { + runOnUiThread { + try { + findViewById(R.id.loadingMessage)?.text = getString(messageResId) + } catch (e: Exception) { + Log.w(TAG, "Could not update loading message", e) + } + } + } + + /** + * Verify origin on background thread and start unlock flow if successful. + */ + private fun verifyOriginAndStartUnlock(callingAppInfo: CallingAppInfo?, savedInstanceState: Bundle?) { + lifecycleScope.launch { + try { + // Show verifying status to user (network call may happen) + updateLoadingMessage(R.string.passkey_verifying) + + // Run origin verification on IO thread (asset links fetch requires network) + val originVerifier = OriginVerifier() + val originResult = withContext(Dispatchers.IO) { + originVerifier.verifyOrigin( + callingAppInfo = callingAppInfo, + requestedRpId = viewModel.rpId, + ) + } + + when (originResult) { + is OriginVerifier.OriginResult.Success -> { + viewModel.origin = originResult.origin + viewModel.isPrivilegedCaller = originResult.isPrivileged + Log.d(TAG, "Origin verified: ${originResult.origin} (privileged: ${originResult.isPrivileged})") + + // Initialize unlock coordinator + unlockCoordinator = UnlockCoordinator( + activity = this@PasskeyRegistrationActivity, + vaultStore = vaultStore, + onUnlocked = { + // Vault unlocked successfully - proceed with passkey registration + proceedWithPasskeyRegistration(savedInstanceState) + }, + onCancelled = { + // User cancelled unlock + finish() + }, + onError = { errorMessage -> + // Error during unlock + showError(errorMessage) + }, + ) + + // Start the unlock flow + unlockCoordinator.startUnlockFlow() + } + is OriginVerifier.OriginResult.Failure -> { + Log.e(TAG, "Origin verification failed: ${originResult.reason}") + showError("Security error: ${originResult.reason}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error verifying origin", e) + showError("Error verifying application: ${e.message}") + } + } + } + /** * Proceed with passkey registration after authentication (biometric or PIN). */ diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt index 4710b7061..175da5452 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/models/PasskeyRegistrationViewModel.kt @@ -17,6 +17,9 @@ class PasskeyRegistrationViewModel : ViewModel() { /** The origin URL of the passkey request. */ var origin: String? = null + /** Whether the caller is a privileged app (browser). */ + var isPrivilegedCaller: Boolean = false + /** The relying party identifier. */ var rpId: String = "" diff --git a/apps/mobile-app/android/app/src/main/res/values-ca/strings.xml b/apps/mobile-app/android/app/src/main/res/values-ca/strings.xml index e3a7e181f..f74684fc8 100644 --- a/apps/mobile-app/android/app/src/main/res/values-ca/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-ca/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… Checking connection… + Retrieving passkey… + Verifying… + Authenticating… Connection Error No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values-de/strings.xml b/apps/mobile-app/android/app/src/main/res/values-de/strings.xml index c2b947c20..f365b1031 100644 --- a/apps/mobile-app/android/app/src/main/res/values-de/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-de/strings.xml @@ -43,6 +43,9 @@ Dies wird den bestehenden Passkey durch einen neuen ersetzen. Bitte beachte, dass Dein alter Passkey überschrieben wird und nicht mehr zugänglich ist. Wenn Du stattdessen einen separaten Passkey erstellen möchtest, gehe zurück zum vorherigen Schritt. Passkey ersetzen… Verbindung wird überprüft… + Retrieving passkey… + Verifying… + Authenticating… Verbindungsfehler Es kann keine Verbindung zum Server hergestellt werden. Bitte überprüfe Deine Internetverbindung und versuche das Erstellen des Passkeys erneut. diff --git a/apps/mobile-app/android/app/src/main/res/values-es/strings.xml b/apps/mobile-app/android/app/src/main/res/values-es/strings.xml index 0ce5efa63..b44ad00de 100644 --- a/apps/mobile-app/android/app/src/main/res/values-es/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-es/strings.xml @@ -43,6 +43,9 @@ Esto reemplazará la llave de acceso existente con una nueva. Tenga en cuenta que su llave de acceso antigua será sobrescrita y ya no será accesible. Si desea crear una llave de acceso separada en su lugar, vuelva a la pantalla anterior. Reemplazando llave de acceso… Comprobando la conexión… + Recuperando llave… + Verificando… + Autenticando… Error de conexión No se puede establecer conexión con el servidor. Por favor, compruebe su conexión a Internet e intente crear la llave de acceso de nuevo. diff --git a/apps/mobile-app/android/app/src/main/res/values-fi/strings.xml b/apps/mobile-app/android/app/src/main/res/values-fi/strings.xml index cb0831b9e..05b76e81d 100644 --- a/apps/mobile-app/android/app/src/main/res/values-fi/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-fi/strings.xml @@ -43,6 +43,9 @@ Tämä korvaa olemassa olevan todennusavaimen uudella todennusavaimella. Ole hyvä ja ota huomioon, että vanha todennusavaimesi on korvattu eikä enää käytettävissä. Jos haluat luoda erillisen todennusavaimen sen sijaan, mene takaisin edelliseen ruutuun. Korvataan todennusavainta... Tarkistetaan yhteyttä + Noudetaan todennusavainta... + Tarkistetaan… + Todennetaan… Yhteysvirhe Yhteyttä palvelimeen ei voida luoda. Tarkista internet-yhteytesi ja yritä luoda todennusavain uudelleen. diff --git a/apps/mobile-app/android/app/src/main/res/values-fr/strings.xml b/apps/mobile-app/android/app/src/main/res/values-fr/strings.xml index e82d69d97..364d1fa8d 100644 --- a/apps/mobile-app/android/app/src/main/res/values-fr/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-fr/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… Checking connection… + Retrieving passkey… + Verifying… + Authenticating… Connection Error No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values-he/strings.xml b/apps/mobile-app/android/app/src/main/res/values-he/strings.xml index 06df533e6..4c38644ac 100644 --- a/apps/mobile-app/android/app/src/main/res/values-he/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-he/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… החיבור נבדק… + Retrieving passkey… + Verifying… + Authenticating… שגיאת חיבור No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values-it/strings.xml b/apps/mobile-app/android/app/src/main/res/values-it/strings.xml index 1b7f84642..e6332873e 100644 --- a/apps/mobile-app/android/app/src/main/res/values-it/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-it/strings.xml @@ -43,6 +43,9 @@ Questo sostituirà la passkey esistente con una nuova. Si prega di notare che la vecchia passkey sarà sovrascritta e non sarà più accessibile. Se si desidera invece creare una passkey separata, tornare alla schermata precedente. Sostituzione passkey… Controllo connessione… + Recupero passkey… + Verifica… + Autenticazione… Errore Di Connessione Non è possibile effettuare alcuna connessione al server. Controlla la tua connessione internet e prova a creare nuovamente la passkey. diff --git a/apps/mobile-app/android/app/src/main/res/values-nl/strings.xml b/apps/mobile-app/android/app/src/main/res/values-nl/strings.xml index 68265bf78..0680023c9 100644 --- a/apps/mobile-app/android/app/src/main/res/values-nl/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-nl/strings.xml @@ -43,6 +43,9 @@ Dit zal de bestaande passkey vervangen door een nieuwe. Houd er rekening mee dat je oude passkey wordt overschreven en niet langer toegankelijk is. Als je in plaats hiervan een aparte passkey wilt maken, ga dan terug naar het vorige scherm. Passkey vervangen… Verbinding controleren… + Passkey ophalen… + Verifiëren… + Authenticeren… Verbindingsfout Er kan geen verbinding met de server worden gemaakt. Controleer je internetverbinding en probeer het opnieuw. diff --git a/apps/mobile-app/android/app/src/main/res/values-pl/strings.xml b/apps/mobile-app/android/app/src/main/res/values-pl/strings.xml index b16ef62f1..d3578103c 100644 --- a/apps/mobile-app/android/app/src/main/res/values-pl/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-pl/strings.xml @@ -43,6 +43,9 @@ Spowoduje to zastąpienie dotychczasowego klucza dostępu nowym. Należy pamiętać, że stare klucz zostanie nadpisany i nie będzie już dostępny. Jeśli chcesz utworzyć nowy klucz dostępu, wróć do poprzedniego ekranu. Zastępowanie klucza dostępu… Sprawdzanie połączenia… + Retrieving passkey… + Verifying… + Authenticating… Błąd połączenia Nie można nawiązać połączenia z serwerem. Sprawdź połączenie internetowe i spróbuj ponownie utworzyć klucz dostępu. diff --git a/apps/mobile-app/android/app/src/main/res/values-pt/strings.xml b/apps/mobile-app/android/app/src/main/res/values-pt/strings.xml index 1b285e605..dc8618ec7 100644 --- a/apps/mobile-app/android/app/src/main/res/values-pt/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-pt/strings.xml @@ -43,6 +43,9 @@ Isto irá substituir a passkey existente com uma nova. Por favor, saiba que sua passkey anterior será sobrescrita e não será mais acessível. Se você deseja criar uma passkey separadamente, volte à tela anterior. Substituindo passkey… Verificando conexão… + Retrieving passkey… + Verifying… + Authenticating… Erro de Conexão A conexão com o servidor não foi feita. Por favor, confira sua conexão com a internet e tente criar a passkey novamente. diff --git a/apps/mobile-app/android/app/src/main/res/values-ru/strings.xml b/apps/mobile-app/android/app/src/main/res/values-ru/strings.xml index 6526598db..801e0895f 100644 --- a/apps/mobile-app/android/app/src/main/res/values-ru/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-ru/strings.xml @@ -43,6 +43,9 @@ Существующий ключ доступа будет заменен на новый. Обратите внимание, что старый ключ будет перезаписан и станет недоступен. Если вы хотите создать отдельный ключ доступа, вернитесь на предыдущий экран. Замена ключа доступа… Проверка соединения… + Retrieving passkey… + Verifying… + Authenticating… Ошибка подключения Не удалось подключиться к серверу. Проверьте интернет-соединение и попробуйте создать ключ доступа снова. diff --git a/apps/mobile-app/android/app/src/main/res/values-sv/strings.xml b/apps/mobile-app/android/app/src/main/res/values-sv/strings.xml index e3a7e181f..f74684fc8 100644 --- a/apps/mobile-app/android/app/src/main/res/values-sv/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-sv/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… Checking connection… + Retrieving passkey… + Verifying… + Authenticating… Connection Error No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values-tr/strings.xml b/apps/mobile-app/android/app/src/main/res/values-tr/strings.xml index e3a7e181f..f74684fc8 100644 --- a/apps/mobile-app/android/app/src/main/res/values-tr/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-tr/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… Checking connection… + Retrieving passkey… + Verifying… + Authenticating… Connection Error No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values-uk/strings.xml b/apps/mobile-app/android/app/src/main/res/values-uk/strings.xml index af22a9a8e..afa4f7b0f 100644 --- a/apps/mobile-app/android/app/src/main/res/values-uk/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-uk/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… Checking connection… + Retrieving passkey… + Verifying… + Authenticating… Connection Error No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values-zh/strings.xml b/apps/mobile-app/android/app/src/main/res/values-zh/strings.xml index a97da0920..d032ab71c 100644 --- a/apps/mobile-app/android/app/src/main/res/values-zh/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values-zh/strings.xml @@ -43,6 +43,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. 正在替换通行密钥… 检查连接中… + Retrieving passkey… + Verifying… + Authenticating… 连接错误 No connection to the server can be made. Please check your internet connection and try creating the passkey again. diff --git a/apps/mobile-app/android/app/src/main/res/values/strings.xml b/apps/mobile-app/android/app/src/main/res/values/strings.xml index 2b214b425..c4b42a274 100644 --- a/apps/mobile-app/android/app/src/main/res/values/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values/strings.xml @@ -49,6 +49,9 @@ This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen. Replacing passkey… Checking connection… + Retrieving passkey… + Verifying… + Authenticating… Connection Error diff --git a/apps/mobile-app/i18n/locales/fr.json b/apps/mobile-app/i18n/locales/fr.json index 0242ca581..c2770707f 100644 --- a/apps/mobile-app/i18n/locales/fr.json +++ b/apps/mobile-app/i18n/locales/fr.json @@ -1,7 +1,7 @@ { "common": { "cancel": "Annuler", - "close": "Close", + "close": "Fermer", "delete": "Supprimer", "save": "Sauvegarder", "yes": "Oui", @@ -24,78 +24,78 @@ "deleteItemConfirmTitle": "Supprimer l'élement", "deleteItemConfirmDescription": "Êtes-vous certain de vouloir supprimer cet élément?", "errors": { - "unknownError": "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." + "unknownError": "Une erreur inconnue s'est produite. Merci de réessayer.", + "unknownErrorTryAgain": "Une erreur inconnue s'est produite. Merci de réessayer.", + "serverVersionTooOld": "Le serveur AliasVault doit être mis à jour vers une version plus récente pour pouvoir utiliser cette fonctionnalité. Veuillez contacter l'administrateur du serveur si vous avez besoin d'aide." } }, "auth": { - "login": "Log in", - "logout": "Logout", - "username": "Username or email", - "password": "Password", - "authCode": "Authentication Code", - "unlock": "Unlock", - "unlocking": "Unlocking...", - "loggingIn": "Logging in", - "validatingCredentials": "Validating credentials", - "syncingVault": "Syncing vault", - "verifyingAuthCode": "Verifying authentication code", - "verify": "Verify", - "unlockVault": "Unlock Vault", - "unlockWithPin": "Unlock with PIN", - "enterPassword": "Enter your password to unlock your vault", - "enterPasswordPlaceholder": "Password", - "enterAuthCode": "Enter 6-digit code", + "login": "Se connecter", + "logout": "Se déconnecter", + "username": "Nom d'utilisateur ou email", + "password": "Mot de passe", + "authCode": "Code d'authentification", + "unlock": "Déverrouiller", + "unlocking": "Déverrouillage...", + "loggingIn": "Connexion en cours", + "validatingCredentials": "Validation des identifiants", + "syncingVault": "Synchronisation du coffre", + "verifyingAuthCode": "Vérification du code d'authentification", + "verify": "Vérifier", + "unlockVault": "Déverrouiller le coffre", + "unlockWithPin": "Déverrouiller avec un code PIN", + "enterPassword": "Entrez votre mot de passe principal pour déverrouiller votre coffre-fort", + "enterPasswordPlaceholder": "Mot de passe", + "enterAuthCode": "Saisissez le code à 6 chiffres", "usernamePlaceholder": "nom / nom@entreprise.com", - "passwordPlaceholder": "Enter your password", - "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.", + "passwordPlaceholder": "Saisissez votre mot de passe", + "enableBiometric": "Activer {{biometric}}?", + "biometricPrompt": "Voulez-vous utiliser {{biometric}} pour déverrouiller votre coffre ?", + "tryBiometricAgain": "Réessayez {{biometric}}", + "tryPinAgain": "Réessayez le PIN", + "authCodeNote": "Remarque : si vous n'avez pas accès à votre appareil d'authentification, vous pouvez réinitialiser votre authentification 2FA avec un code de récupération en vous connectant via le site web.", "errors": { - "credentialsRequired": "Username and password are required", - "invalidAuthCode": "Please enter a valid 6-digit authentication code", + "credentialsRequired": "Le nom d'utilisateur et le mot de passe sont requis", + "invalidAuthCode": "Veuillez entrer un code d'authentification à 6 chiffres valide", "incorrectPassword": "Mot de passe incorrect. Veuillez réessayer.", - "enterPassword": "Please enter your password", - "serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.", - "serverErrorSelfHosted": "Could not reach the API. For self-hosted instances, please verify the API endpoint is reachable by navigating to it in a browser: it should display 'OK'.", - "networkError": "Network request failed. Please check your internet connection and try again.", - "networkErrorSelfHosted": "Network request failed. Check your network connection and server availability. For self-hosted instances, please ensure you have a valid SSL certificate installed. Self-signed certificates are not supported on mobile devices for security reasons.", - "sessionExpired": "Your session has expired. Please login again.", - "httpError": "HTTP error: {{status}}" + "enterPassword": "Veuillez saisir votre mot de passe", + "serverError": "Impossible d'accéder au serveur AliasVault. Veuillez réessayer plus tard ou contacter le support si le problème persiste.", + "serverErrorSelfHosted": "Impossible d'atteindre l'API. Pour les instances auto-hébergées, veuillez vérifier que le point de terminaison de l'API est accessible en naviguant vers celui-ci dans un navigateur : il devrait afficher 'OK'.", + "networkError": "Erreur réseau. Vérifiez votre connexion et réessayez.", + "networkErrorSelfHosted": "La requête réseau a échoué. Vérifiez votre connexion réseau et la disponibilité du serveur. Pour les instances auto-hébergées, veuillez vous assurer que vous avez un certificat SSL valide. Les certificats auto-signés ne sont pas pris en charge pour des raisons de sécurité.", + "sessionExpired": "Votre session a expiré. Veuillez vous reconnecter.", + "httpError": "Erreur HTTP : {{status}}" }, - "confirmLogout": "Are you sure you want to logout? You need to login again with your master password to access your vault.", + "confirmLogout": "Êtes-vous sûr de vouloir vous déconnecter ? Vous devez vous reconnecter avec votre mot de passe maître pour accéder à votre coffre.", "noAccountYet": "Pas encore de compte ?", "createNewVault": "Créer un nouveau coffre-fort", "connectingTo": "Connexion à", "loggedInAs": "Connecté en tant que" }, "vault": { - "syncingVault": "Syncing vault", - "uploadingVaultToServer": "Uploading vault to server", - "savingChangesToVault": "Saving changes to vault", - "checkingForVaultUpdates": "Checking for vault updates", - "executingOperation": "Executing operation...", - "checkingVaultUpdates": "Checking vault updates", - "syncingUpdatedVault": "Syncing updated vault", + "syncingVault": "Synchronisation du coffre", + "uploadingVaultToServer": "Envoi du coffre sur le serveur", + "savingChangesToVault": "Enregistrement des modifications dans le coffre", + "checkingForVaultUpdates": "Vérification des mises à jour du coffre", + "executingOperation": "Exécution de l'opération...", + "checkingVaultUpdates": "Vérification des mises à jour du coffre", + "syncingUpdatedVault": "Synchronisation du coffre mis à jour", "errors": { - "failedToGetEncryptedDatabase": "Failed to get encrypted database", - "usernameNotFound": "Username not found", - "vaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.", - "failedToSyncVault": "Failed to sync vault", - "versionNotSupported": "This version of the AliasVault mobile app is not supported by the server anymore. Please update your app to the latest version.", - "serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this mobile app. Please contact support if you need help.", + "failedToGetEncryptedDatabase": "Impossible d'obtenir la base de données chiffrée", + "usernameNotFound": "Identifiant non trouvé", + "vaultOutdated": "Votre coffre est obsolète. Veuillez vous connecter sur le site AliasVault et suivre les étapes.", + "failedToSyncVault": "Échec de la synchronisation du coffre", + "versionNotSupported": "Cette version de l'application mobile AliasVault n'est plus prise en charge par le serveur. Veuillez mettre à jour votre application vers la dernière version.", + "serverVersionNotSupported": "Le serveur AliasVault doit être mis à jour vers une version plus récente afin d'utiliser cette application mobile. Veuillez contacter le support si vous avez besoin d'aide.", "appOutdated": "Cette application est obsolète et ne peut pas être utilisée pour accéder à cette (nouvelle) version du coffre. Veuillez mettre à jour l'application AliasVault pour continuer.", - "passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons." + "passwordChanged": "Votre mot de passe a changé depuis la dernière fois que vous vous êtes connecté. Veuillez vous reconnecter pour des raisons de sécurité." } }, "credentials": { - "title": "Credentials", - "addCredential": "Add Credential", - "editCredential": "Edit Credential", - "deleteCredential": "Delete Credential", + "title": "Identifiants", + "addCredential": "Ajouter un identifiant", + "editCredential": "Modifier l'identifiant", + "deleteCredential": "Supprimer l'identifiant", "deleteConfirm": "Êtes-vous sûr de vouloir supprimer ces identifiants ? Cette action est irréversible.", "service": "Service", "serviceName": "Nom du service", @@ -113,116 +113,116 @@ "birthDate": "Date de naissance", "birthDatePlaceholder": "AAAA-MM-JJ", "notes": "Notes", - "randomAlias": "Random Alias", - "manual": "Manual", - "generateRandomAlias": "Generate Random Alias", - "clearAliasFields": "Clear Alias Fields", - "enterFullEmail": "Enter full email address", - "enterEmailPrefix": "Enter email prefix", - "useDomainChooser": "Use domain chooser", - "enterCustomDomain": "Enter custom domain", - "selectEmailDomain": "Select Email Domain", - "privateEmailTitle": "Private Email", - "privateEmailAliasVaultServer": "AliasVault server", - "privateEmailDescription": "E2E encrypted, fully private.", - "publicEmailTitle": "Public Temp Email Providers", - "publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.", - "searchPlaceholder": "Search vault...", - "noMatchingCredentials": "No matching credentials found", - "noCredentialsFound": "No credentials found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.", - "noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.", - "noAttachmentsFound": "No credentials with attachments found", - "recentEmails": "Recent emails", - "loadingEmails": "Loading emails...", - "noEmailsYet": "No emails received yet.", - "offlineEmailsMessage": "You are offline. Please connect to the internet to load your emails.", - "emailLoadError": "An error occurred while loading emails. Please try again later.", - "emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later.", - "password": "Password", - "passwordLength": "Password Length", - "changePasswordComplexity": "Password Settings", - "includeLowercase": "Lowercase (a-z)", - "includeUppercase": "Uppercase (A-Z)", - "includeNumbers": "Numbers (0-9)", - "includeSpecialChars": "Special Characters (!@#)", - "avoidAmbiguousChars": "Avoid Ambiguous Characters", - "deletingCredential": "Deleting credential...", - "errorLoadingCredentials": "Error loading credentials", - "vaultSyncFailed": "Vault sync failed", - "vaultSyncedSuccessfully": "Vault synced successfully", - "vaultUpToDate": "Vault is up-to-date", - "offlineMessage": "You are offline. Please connect to the internet to sync your vault.", - "credentialCreated": "Credential Created!", - "credentialCreatedMessage": "Your new credential has been added to your vault and is ready to use.", - "credentialDetails": "Credential Details", - "emailPreview": "Email Preview", - "switchBackToBrowser": "Switch back to your browser to continue.", + "randomAlias": "Alias aléatoire", + "manual": "Manuel", + "generateRandomAlias": "Générer un alias aléatoire", + "clearAliasFields": "Effacer les champs d'alias", + "enterFullEmail": "Entrez l'adresse email complète", + "enterEmailPrefix": "Entrez le préfixe de l'email", + "useDomainChooser": "Utiliser le sélecteur de domaine", + "enterCustomDomain": "Entrez le domaine personnalisé", + "selectEmailDomain": "Sélectionner un domaine de messagerie", + "privateEmailTitle": "E-mail privé", + "privateEmailAliasVaultServer": "Serveur AliasVault", + "privateEmailDescription": "E2E chiffré, entièrement privé.", + "publicEmailTitle": "Fournisseurs d'email public temporaires", + "publicEmailDescription": "Anonyme mais confidentiel limitée. Le contenu des e-mails est lisible par toute personne qui connaît l'adresse.", + "searchPlaceholder": "Rechercher dans le coffre...", + "noMatchingCredentials": "Aucun identifiant correspondant trouvé", + "noCredentialsFound": "Aucun identifiant trouvé. Créez en un pour commencer. Astuce : vous pouvez également vous connecter à l'application web AliasVault pour importer les identifiants depuis d'autres gestionnaires de mots de passe.", + "noPasskeysFound": "Aucune clé d'accès n'a encore été créée. Les clés d'accès sont créés en visitant un site Web qui propose des clés d'accès comme méthode d'authentification.", + "noAttachmentsFound": "Aucun identifiant avec des pièces jointes trouvé", + "recentEmails": "E-mails récents", + "loadingEmails": "Chargement des e-mails...", + "noEmailsYet": "Pas encore d'e-mails reçus.", + "offlineEmailsMessage": "Vous êtes déconnecté. Veuillez vous connecter à internet pour charger vos e-mails.", + "emailLoadError": "Une erreur s'est produite lors du chargement des e-mails. Veuillez réessayer plus tard.", + "emailUnexpectedError": "Une erreur inattendue s'est produite lors du chargement des e-mails. Veuillez réessayer plus tard.", + "password": "Mot de passe", + "passwordLength": "Longueur du mot de passe", + "changePasswordComplexity": "Paramètres du mot de passe", + "includeLowercase": "Minuscules (a-z)", + "includeUppercase": "Majuscules (A-Z)", + "includeNumbers": "Nombres (0-9)", + "includeSpecialChars": "Caractères spéciaux (!@#)", + "avoidAmbiguousChars": "Éviter les caractères ambigus", + "deletingCredential": "Suppression de l'identifiant...", + "errorLoadingCredentials": "Erreur lors du chargement des identifiants", + "vaultSyncFailed": "Échec de la synchronisation du coffre", + "vaultSyncedSuccessfully": "Le coffre a été synchronisé avec succès", + "vaultUpToDate": "Le coffre est à jour", + "offlineMessage": "Vous êtes déconnecté. Veuillez vous connecter à internet pour synchroniser votre coffre.", + "credentialCreated": "Identifiant créé!", + "credentialCreatedMessage": "Votre nouvel identifiant a été ajouté à votre coffre et est prêt à être utilisé.", + "credentialDetails": "Détails de l'identifiant", + "emailPreview": "Aperçu de l'e-mail", + "switchBackToBrowser": "Passez à votre navigateur pour continuer.", "filters": { - "all": "(All) Credentials", - "passkeys": "Passkeys", - "aliases": "Aliases", - "userpass": "Passwords", - "attachments": "Attachments" + "all": "(Tous les) Identifiants de connexion", + "passkeys": "Clés d'accès", + "aliases": "Alias", + "userpass": "Mots de passe", + "attachments": "Pièces jointes" }, - "twoFactorAuth": "Two-factor authentication", - "totpCode": "TOTP Code", - "attachments": "Attachments", - "deleteAttachment": "Delete", - "fileSavedTo": "File saved to", - "previewNotSupported": "Preview not supported", - "downloadToView": "Download the file to view it", + "twoFactorAuth": "Authentification à deux facteurs", + "totpCode": "Code à usage unique", + "attachments": "Pièces jointes", + "deleteAttachment": "Supprimer", + "fileSavedTo": "Fichier enregistré sous", + "previewNotSupported": "Aperçu non supporté", + "downloadToView": "Télécharger le fichier pour le voir", "unsavedChanges": { - "title": "Discard Changes?", - "message": "You have unsaved changes. Are you sure you want to discard them?", - "discard": "Discard" + "title": "Annuler les modifications?", + "message": "Vos modifications n'ont pas été enregistrées. Voulez-vous vraiment les ignorer ?", + "discard": "Ignorer" }, "toasts": { - "credentialUpdated": "Credential updated successfully", - "credentialCreated": "Credential created successfully", - "credentialDeleted": "Credential deleted successfully", - "usernameCopied": "Username copied to clipboard", - "emailCopied": "Email copied to clipboard", - "passwordCopied": "Password copied to clipboard" + "credentialUpdated": "Identifiant mis à jour avec succès", + "credentialCreated": "Identifiant créé avec succès", + "credentialDeleted": "Identifiant supprimé avec succès", + "usernameCopied": "Nom d'utilisateur copié dans le presse-papiers", + "emailCopied": "E-mail copié dans le presse-papiers", + "passwordCopied": "Mot de passe copié dans le presse-papiers" }, - "createNewAliasFor": "Create new alias for", + "createNewAliasFor": "Créer un nouvel alias pour", "errors": { - "loadFailed": "Failed to load credential", - "saveFailed": "Failed to save credential" + "loadFailed": "Échec du chargement de l'identifiant", + "saveFailed": "Échec de l'enregistrement de l'identifiant" }, "contextMenu": { - "title": "Credential Options", - "edit": "Edit", - "delete": "Delete", - "copyUsername": "Copy Username", - "copyEmail": "Copy Email", - "copyPassword": "Copy Password" + "title": "Options de l'identifiant", + "edit": "Modifier", + "delete": "Supprimer", + "copyUsername": "Copier le nom d'utilisateur", + "copyEmail": "Copier l'e-mail", + "copyPassword": "Copier le mot de passe" } }, "passkeys": { - "passkey": "Passkey", + "passkey": "Clé d'identification", "site": "Site", - "displayName": "Display Name", - "helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential.", - "passkeyMarkedForDeletion": "Passkey marked for deletion", - "passkeyWillBeDeleted": "This passkey will be deleted when you save this credential." + "displayName": "Nom affiché", + "helpText": "Les clés d'accès sont créées sur le site Web lorsque vous y êtes invité. Elles ne peuvent pas être modifiées manuellement. Pour supprimer cette clé, vous pouvez la supprimer de cet identifiant.", + "passkeyMarkedForDeletion": "Clé d'accès marquée pour suppression", + "passkeyWillBeDeleted": "Cette clé d'accès sera supprimée lorsque vous enregistrerez cet identifiant." }, "totp": { - "addCode": "Add 2FA Code", - "nameOptional": "Name (optional)", - "secretKey": "Secret Key", - "instructions": "Enter the secret key shown by the website where you want to add two-factor authentication.", - "saveToViewCode": "Save to view code", + "addCode": "Ajouter un code 2FA", + "nameOptional": "Nom (facultatif)", + "secretKey": "Clé secrète", + "instructions": "Entrez la clé secrète affichée par le site Web où vous souhaitez ajouter l'authentification à deux facteurs.", + "saveToViewCode": "Enregistrer pour afficher le code", "errors": { - "invalidSecretKey": "Invalid secret key format." + "invalidSecretKey": "Format de clé secrète invalide." } }, "settings": { - "title": "Settings", - "autofill": "Autofill & Passkeys", + "title": "Réglages", + "autofill": "Remplissage automatique et clés d'accès", "iosAutofillSettings": { - "headerText": "You can configure AliasVault to provide native password and passkey autofill functionality in iOS. Follow the instructions below to enable it.", - "passkeyNotice": "Passkeys are created through iOS. To store them in AliasVault, ensure Autofill below is enabled.", - "howToEnable": "How to enable Autofill & Passkeys:", + "headerText": "Vous pouvez configurer AliasVault pour fournir des fonctionnalités natives de saisie automatique du mot de passe et de clés d'accès dans iOS. Suivez les instructions ci-dessous pour l'activer.", + "passkeyNotice": "Les clés d'accès sont créées via iOS. Pour les stocker dans AliasVault, assurez-vous que le remplissage automatique ci-dessous est activé.", + "howToEnable": "Comment activer le remplissage automatique et les clés d'accès :", "step1": "1. Ouvrir les paramètres d'iOS via le bouton ci-dessous", "step2": "2. Aller dans « Général »", "step3": "3. Appuyer sur « Remplissage automatique et mots de passe »", @@ -233,45 +233,45 @@ "warningText": "Note : Vous devrez vous authentifier avec Face ID/Touch ID ou votre code d'accès lorsque vous utilisez le remplissage automatique." }, "androidAutofillSettings": { - "warningTitle": "⚠️ Experimental Feature", - "warningDescription": "Autofill and passkey support for Android is currently in an experimental state.", - "warningLink": "Read more about it here", - "headerText": "You can configure AliasVault to provide native password and passkey autofill functionality in Android. Follow the instructions below to enable it.", - "passkeyNotice": "Passkeys are created through Android Credential Manager (Android 14+). To store them in AliasVault, ensure Autofill below is enabled.", - "howToEnable": "How to enable Autofill & Passkeys:", - "step1": "1. Open Android Settings via the button below, and change the \"autofill preferred service\" to \"AliasVault\"", - "openAutofillSettings": "Open Autofill Settings", - "buttonTip": "If the button above doesn't work it might be blocked because of security settings. You can manually go to Android Settings → General Management → Passwords and autofill.", - "step2": "2. Some apps, e.g. Google Chrome, may require manual configuration in their settings to allow third-party autofill apps. However, most apps should work with autofill by default.", - "alreadyConfigured": "I already configured it", - "advancedOptions": "Advanced Options", - "showSearchText": "Show search text", - "showSearchTextDescription": "Include the text AliasVault receives from Android that it uses to search for a matching credential" + "warningTitle": "⚠️ Fonctionnalité expérimentale", + "warningDescription": "Le remplissage automatique et la prise en charge de la clé d'accès pour Android sont actuellement dans un état expérimental.", + "warningLink": "En savoir plus à ce sujet ici", + "headerText": "Vous pouvez configurer AliasVault pour fournir le mot de passe natif et la fonctionnalité de saisie automatique du mot de passe dans Android. Suivez les instructions ci-dessous pour l'activer.", + "passkeyNotice": "Les mots de passe sont créés via le gestionnaire d'identifiants Android (Android 14+). Pour les stocker dans AliasVault, assurez-vous que le remplissage automatique ci-dessous est activé.", + "howToEnable": "Comment activer le remplissage automatique et les clés d'accès:", + "step1": "1. Ouvrez les paramètres Android via le bouton ci-dessous, et changez le \"service préféré de saisie automatique\" par \"AliasVault\"", + "openAutofillSettings": "Ouvrir les paramètres de remplissage automatique", + "buttonTip": "Si le bouton ci-dessus ne fonctionne pas, il peut être bloqué en raison des paramètres de sécurité. Vous pouvez manuellement aller dans Réglages Android → Gestion Générale → Mots de passe et saisie automatique.", + "step2": "2. Certaines applications, par exemple Google Chrome, peuvent nécessiter une configuration manuelle dans leurs paramètres pour permettre le remplissage automatique des applications tierces. Cependant, la plupart des applications devraient fonctionner avec le remplissage automatique par défaut.", + "alreadyConfigured": "Je l'ai déjà configuré", + "advancedOptions": "Options avancées", + "showSearchText": "Afficher le texte de recherche", + "showSearchTextDescription": "Inclure le texte que AliasVault reçoit d'Android qu'il utilise pour rechercher un identifiant correspondant" }, "vaultUnlock": "Méthode de déverrouillage du coffre-fort", "autoLock": "Délai de verrouillage automatique", - "clipboardClear": "Clear Clipboard", - "clipboardClearDescription": "Automatically clear copied passwords and sensitive information from your clipboard after a specified time period.", - "clipboardClearAndroidWarning": "Note: some Android devices have clipboard history enabled, which may keep track of previously copied items, even after AliasVault clears the clipboard. AliasVault can only overwrite the most recent item, but older entries may remain visible in history. For security reasons, we recommend disabling any clipboard history features in your device settings.", + "clipboardClear": "Effacer le presse-papiers", + "clipboardClearDescription": "Effacer automatiquement les mots de passe copiés et les informations sensibles du presse-papiers après une période donnée.", + "clipboardClearAndroidWarning": "Remarque : certains appareils Android ont l'historique du presse-papiers activé, qui peut garder une trace des éléments précédemment copiés, même après que AliasVault a effacé le presse-papiers. AliasVault ne peut que remplacer l'élément le plus récent, mais les entrées plus anciennes peuvent rester visibles dans l'historique. Pour des raisons de sécurité, nous vous recommandons de désactiver toutes les fonctionnalités d'historique du presse-papiers dans les paramètres de votre appareil.", "clipboardClearOptions": { - "never": "Never", - "5seconds": "5 seconds", - "10seconds": "10 seconds", - "15seconds": "15 seconds", - "30seconds": "30 seconds" + "never": "Jamais", + "5seconds": "5 secondes", + "10seconds": "10 secondes", + "15seconds": "15 secondes", + "30seconds": "30 secondes" }, - "batteryOptimizationHelpTitle": "Enable Background Clipboard Clearing", - "batteryOptimizationActive": "Battery optimization is blocking background tasks", - "batteryOptimizationDisabled": "Background clipboard clearing enabled", - "batteryOptimizationHelpDescription": "Android's battery optimization prevents reliable clipboard clearing when the app is in the background. Disabling battery optimization for AliasVault allows precise background clipboard clearing and automatically grants necessary alarm permissions.", - "disableBatteryOptimization": "Disable battery optimization", + "batteryOptimizationHelpTitle": "Activer le nettoyage du presse-papier en arrière-plan", + "batteryOptimizationActive": "L'optimisation de la batterie bloque les tâches en arrière-plan", + "batteryOptimizationDisabled": "Effacement du presse-papiers en arrière-plan activé", + "batteryOptimizationHelpDescription": "L'optimisation de la batterie d'Android empêche le nettoyage fiable du presse-papiers lorsque l'application est en arrière-plan. La désactivation de l'optimisation de la batterie pour AliasVault permet un nettoyage précis du presse-papiers et accorde automatiquement les autorisations d'alarme nécessaires.", + "disableBatteryOptimization": "Désactiver l'optimisation de la batterie", "identityGenerator": "Générateur d'identité", - "passwordGenerator": "Password Generator", - "importExport": "Import / Export", - "importSectionTitle": "Import", - "importSectionDescription": "Import your passwords from other password managers or from a previous AliasVault export.", - "importWebNote": "To import credentials from existing password managers, please login to the web app. The import feature is currently only available on the web version.", - "exportSectionTitle": "Export", + "passwordGenerator": "Générateur de mot de passe", + "importExport": "Importer / Exporter", + "importSectionTitle": "Importer", + "importSectionDescription": "Importez vos mots de passe depuis d'autres gestionnaires de mots de passe ou depuis un précédent export AliasVault.", + "importWebNote": "Pour importer des informations d’identification à partir des gestionnaires de mots de passe existants, veuillez vous connecter à l’application Web. La fonction d’importation n’est actuellement disponible que sur la version web.", + "exportSectionTitle": "Exporter", "exportSectionDescription": "Export your vault data to a CSV file. This file can be used as a back-up and can also be imported into other password managers.", "exportCsvButton": "Export vault to CSV file", "exporting": "Exporting...", @@ -415,7 +415,7 @@ "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.", + "cameraPermissionMessage": "Veuillez autoriser l'accès à l'appareil photo pour scanner les QR codes.", "mobileLogin": { "confirmTitle": "Confirm Login Request", "confirmSubtitle": "Re-authenticate to approve login on another device.", @@ -493,7 +493,7 @@ "status": { "unlockingVault": "Unlocking vault", "decryptingVault": "Decrypting vault", - "openingVaultReadOnly": "Opening vault in read-only mode", + "openingVaultReadOnly": "Ouvrir le coffre en mode lecture seule", "retryingConnection": "Retrying connection..." }, "offline": { diff --git a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj index a33439843..35b0ea342 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 */ @@ -1349,7 +1420,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"; @@ -1403,7 +1477,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/Podfile.lock b/apps/mobile-app/ios/Podfile.lock index 5923a0124..b12c0faf6 100644 --- a/apps/mobile-app/ios/Podfile.lock +++ b/apps/mobile-app/ios/Podfile.lock @@ -333,9 +333,9 @@ PODS: - FBLazyVector (0.79.6) - fmt (11.0.2) - glog (0.3.5) - - hermes-engine (0.79.5): - - hermes-engine/Pre-built (= 0.79.5) - - hermes-engine/Pre-built (0.79.5) + - hermes-engine (0.79.6): + - hermes-engine/Pre-built (= 0.79.6) + - hermes-engine/Pre-built (0.79.6) - Macaw (0.9.10): - SWXMLHash - OpenSSL-Universal (3.3.3001) @@ -2442,7 +2442,7 @@ PODS: - SQLite.swift (0.14.1): - SQLite.swift/standard (= 0.14.1) - SQLite.swift/standard (0.14.1) - - SwiftLint (0.59.1) + - SwiftLint (0.62.2) - SWXMLHash (7.0.2) - Yoga (0.0.0) @@ -2795,8 +2795,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8 EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd EXManifests: 691a779b04e4f2c96da46fb9bef4f86174fefcb5 @@ -2826,9 +2826,9 @@ SPEC CHECKSUMS: EXUpdatesInterface: 7ff005b7af94ee63fa452ea7bb95d7a8ff40277a fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 07309209b7b914451b8f822544a18e2a0a85afff - fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a - hermes-engine: f03b0e06d3882d71e67e45b073bb827da1a21aae + fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 + hermes-engine: 44bb6fe76a6eb400d3a992e2d0b21946ae999fa9 Macaw: 7af8ea57aa2cab35b4a52a45e6f85eea753ea9ae OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 @@ -2908,7 +2908,7 @@ SPEC CHECKSUMS: SignalArgon2: 1c24183835ca861e6af06631c18b1671cdf35571 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SQLite.swift: 2992550ebf3c5b268bf4352603e3df87d2a4ed72 - SwiftLint: 3d48e2fb2a3468fdaccf049e5e755df22fb40c2c + SwiftLint: f84fc7d844e9cde0dc4f5013af608a269e317aba SWXMLHash: dd733a457e9c4fe93b1538654057aefae4acb382 Yoga: dc7c21200195acacb62fa920c588e7c2106de45e diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index c249aa33d..2ec6d5315 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -6416,24 +6416,24 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -6450,6 +6450,27 @@ "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -6457,6 +6478,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -14354,13 +14385,13 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -14444,21 +14475,52 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/apps/server/AliasVault.Client/wwwroot/locales/nl.json b/apps/server/AliasVault.Client/wwwroot/locales/nl.json index 5f2deda0c..d3b77ba32 100644 --- a/apps/server/AliasVault.Client/wwwroot/locales/nl.json +++ b/apps/server/AliasVault.Client/wwwroot/locales/nl.json @@ -1,7 +1,7 @@ { "loading": { "title": "AliasVault wordt geladen", - "message": "De beveiligde omgeving wordt opgestart. AliasVault draait volledig in je browser, waardoor het laden de eerste keer iets langer kan duren.", + "message": "De beveiligde omgeving wordt gestart. AliasVault draait volledig in je browser, waardoor het laden de eerste keer iets langer kan duren.", "refreshText": "Als het laden vastloopt, kunt je op de onderstaande knop klikken om de pagina te vernieuwen.", "refreshButtonText": "Vernieuwen" }, diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index ff3303f39..4470398df 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -275,7 +275,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (1.8.0) - uri (1.0.3) + uri (1.0.4) webrick (1.9.0) PLATFORMS diff --git a/docs/misc/dev/release/git-versioning-strategy.md b/docs/misc/dev/release/git-versioning-strategy.md new file mode 100644 index 000000000..a1783000f --- /dev/null +++ b/docs/misc/dev/release/git-versioning-strategy.md @@ -0,0 +1,61 @@ +--- +layout: default +title: Git versioning strategy +parent: Release +grand_parent: Development +nav_order: 3 +--- + +# Git versioning strategy + +This document describes the **official release workflow** for AliasVault. + +## Branch Semantics + +### `main` +- Represents the **next version line** +- Contains **only pre-release versions** +- Example versions: + - `0.26.0-alpha` + - `0.26.0-beta` +- Never tagged for stable releases + +--- + +### `XXXX-*` (GitHub issue branches) +- Branch from: + - `main` for next-version development, or + - a release tag for hotfixes +- Contains **only code fixes** +- No version changes +- No release notes +- May contain many commits + +Landing rules: +- If branched from `main`: merge or rebase back into `main` +- If branched from a tag: **cherry-pick fixes into `main`** +- May be merged into a `release/*` branch for packaging + +--- + +### `release/*` +- Used only to **package a stable release** +- Contains: + - fixes (cherry-picked back into main) + - release notes (cherry-picked back into main) + - version bump +- Never merged into `main` +- Deleted after tagging + +--- + +## Versioning Rules + +### Development versions +- Live only on `main` +- Always pre-release (`-alpha`, `-beta`, etc.) + +### Stable versions +- Live only on `release/*` branches +- Always tagged +- Never merged back into `main` diff --git a/docs/misc/dev/release/release-checklist.md b/docs/misc/dev/release/release-checklist.md new file mode 100644 index 000000000..ecafbb3b2 --- /dev/null +++ b/docs/misc/dev/release/release-checklist.md @@ -0,0 +1,65 @@ +--- +layout: default +title: Release Checklist +parent: Release +grand_parent: Development +nav_order: 4 +--- + +# Release Checklist + +Step-by-step guide for creating a new AliasVault release. + +## 1. Create release branch + +- **Feature release**: Branch from `main` + +```bash +# Feature release +git checkout main && git checkout -b release/X.Y.Z +``` + +- **Bug/hotfix release**: Branch from previous tag (e.g., `0.25.2`) + +```bash +# Hotfix release +git checkout 0.25.2 && git checkout -b release/0.25.3 +``` + +## 2. Bump version and write release notes + +Run the version bump script which automatically bumps all versions and creates Fastlane changelog files: + +```bash +./scripts/bump-version.sh +``` + +- Commit the release notes in its own commit first +- **Cherry-pick the release notes commit to `main`** +- Commit the version bump changes in a separate commit +- The version bump commit stays only in the release branch + - ***Not cherry-picked***, as the `main` branch is always targeting the next feature (pre)release + +## 3. Additional changes (optional) + +- If additional fixes are needed after testing, add them to the release branch +- **Cherry-pick all fix commits back to `main`** + +## 4. Publish release + +- Create the release from GitHub Releases based on the release branch +- Tag is created automatically + +## 5. Verify cherry-picks + +After release, verify all relevant changes were cherry-picked to `main`: + +```bash +git range-diff ..release/ ..main +# Example: +git range-diff 0.25.2..release/0.25.3 0.25.2..main +``` + +**Expected output:** +- Only the version bump commit should show as `<` (only in release branch) +- All other commits should show as `=` (in both branches) diff --git a/fastlane/metadata/android/en-US/changelogs/2503901.txt b/fastlane/metadata/android/en-US/changelogs/2503901.txt new file mode 100644 index 000000000..a43b258a0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2503901.txt @@ -0,0 +1,2 @@ +- Android Passkey compatibility improvements +- Add French and Spanish language options diff --git a/fastlane/metadata/android/nl-NL/changelogs/2503901.txt b/fastlane/metadata/android/nl-NL/changelogs/2503901.txt new file mode 100644 index 000000000..09d32dd29 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/2503901.txt @@ -0,0 +1,2 @@ +- Android Passkey compatibiliteit verbeterd +- Frans en Spaans toegevoegd als taalopties diff --git a/fastlane/metadata/browser-extension/en-US/changelogs/0.25.3.txt b/fastlane/metadata/browser-extension/en-US/changelogs/0.25.3.txt new file mode 100644 index 000000000..e60e7594e --- /dev/null +++ b/fastlane/metadata/browser-extension/en-US/changelogs/0.25.3.txt @@ -0,0 +1 @@ +- Add French and Spanish language options diff --git a/fastlane/metadata/ios/en-US/changelogs/2503900.txt b/fastlane/metadata/ios/en-US/changelogs/2503900.txt new file mode 100644 index 000000000..e60e7594e --- /dev/null +++ b/fastlane/metadata/ios/en-US/changelogs/2503900.txt @@ -0,0 +1 @@ +- Add French and Spanish language options diff --git a/fastlane/metadata/ios/nl-NL/changelogs/2503900.txt b/fastlane/metadata/ios/nl-NL/changelogs/2503900.txt new file mode 100644 index 000000000..26056ef06 --- /dev/null +++ b/fastlane/metadata/ios/nl-NL/changelogs/2503900.txt @@ -0,0 +1 @@ +- Frans en Spaans toegevoegd als taalopties diff --git a/scripts/print-latest-changelogs.sh b/scripts/print-latest-changelogs.sh new file mode 100755 index 000000000..8a5e265c9 --- /dev/null +++ b/scripts/print-latest-changelogs.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# +# Show the latest changelogs for all platforms (Android, iOS, Browser Extension) +# Outputs formatted changelog content for easy copy-paste +# + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +METADATA_DIR="$ROOT_DIR/fastlane/metadata" + +# Function to get the latest changelog file from a directory +get_latest_changelog() { + local dir="$1" + if [ -d "$dir" ]; then + # Sort files by version number (handles both numeric and semver formats) + ls -1 "$dir" 2>/dev/null | sort -V | tail -1 + fi +} + +# Function to print Android changelogs (XML format, no spaces between languages) +print_android() { + local changelog_dir="$METADATA_DIR/android/en-US/changelogs" + local latest_file=$(get_latest_changelog "$changelog_dir") + + echo "================================================================================" + echo "ANDROID (latest: $latest_file)" + echo "================================================================================" + echo "" + + # Print all locales in XML format without blank lines between them + for locale_dir in "$METADATA_DIR/android"/*; do + if [ -d "$locale_dir/changelogs" ]; then + locale=$(basename "$locale_dir") + local file="$locale_dir/changelogs/$latest_file" + if [ -f "$file" ]; then + echo "<$locale>" + cat "$file" + echo "" + fi + fi + done + echo "" +} + +# Function to print iOS/Browser Extension changelogs (each language separate) +print_simple() { + local platform="$1" + local display_name="$2" + local changelog_dir="$METADATA_DIR/$platform/en-US/changelogs" + local latest_file=$(get_latest_changelog "$changelog_dir") + + echo "================================================================================" + echo "$display_name (latest: $latest_file)" + echo "================================================================================" + + # Print each locale separately + for locale_dir in "$METADATA_DIR/$platform"/*; do + if [ -d "$locale_dir/changelogs" ]; then + locale=$(basename "$locale_dir") + local file="$locale_dir/changelogs/$latest_file" + if [ -f "$file" ]; then + echo "" + echo "--- $locale ---" + cat "$file" + fi + fi + done + echo "" +} + +echo "" +echo "Latest Changelogs Summary" +echo "" + +print_android +print_simple "ios" "iOS" +print_simple "browser-extension" "BROWSER EXTENSION" + +echo "================================================================================" +echo ""