mirror of
https://github.com/bitwarden/android.git
synced 2024-11-25 19:06:05 +03:00
[PM-8137] Passkey creation navigation and account switching (#1380)
This commit is contained in:
parent
19f0990c2f
commit
3a8f3aa0f6
43 changed files with 2690 additions and 29 deletions
481
app/src/main/assets/fido2_privileged_allow_list.json
Normal file
481
app/src/main/assets/fido2_privileged_allow_list.json
Normal file
|
@ -0,0 +1,481 @@
|
|||
{
|
||||
"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": "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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.bitwarden.core.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
|
||||
|
@ -37,16 +38,16 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
|
|||
/**
|
||||
* A view model that helps launch actions for the [MainActivity].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(
|
||||
autofillSelectionManager: AutofillSelectionManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val garbageCollectionManager: GarbageCollectionManager,
|
||||
private val intentManager: IntentManager,
|
||||
authRepository: AuthRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<MainState, MainEvent, MainAction>(
|
||||
MainState(
|
||||
|
@ -172,6 +173,7 @@ class MainViewModel @Inject constructor(
|
|||
val shareData = intentManager.getShareDataFromIntent(intent)
|
||||
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
|
||||
when {
|
||||
passwordlessRequestData != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
|
@ -210,6 +212,20 @@ class MainViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fido2CredentialRequestData != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequestData,
|
||||
)
|
||||
|
||||
// Switch accounts if the selected user is not the active user.
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != fido2CredentialRequestData.userId
|
||||
) {
|
||||
authRepository.switchAccount(fido2CredentialRequestData.userId)
|
||||
}
|
||||
}
|
||||
|
||||
hasGeneratorShortcut -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.GeneratorShortcut
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* Defines calls to an RP digital asset link file.
|
||||
*/
|
||||
interface DigitalAssetLinkApi {
|
||||
|
||||
/**
|
||||
* Attempts to download the asset links file from the RP.
|
||||
*/
|
||||
@GET
|
||||
suspend fun getDigitalAssetLinks(
|
||||
@Url url: String,
|
||||
): Result<List<DigitalAssetLinkResponseJson>>
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.di
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkServiceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import retrofit2.create
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides network dependencies in the fido2 package.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object Fido2NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDigitalAssetLinkService(
|
||||
retrofits: Retrofits,
|
||||
): DigitalAssetLinkService =
|
||||
DigitalAssetLinkServiceImpl(
|
||||
digitalAssetLinkApi = retrofits
|
||||
.staticRetrofitBuilder
|
||||
// This URL will be overridden dynamically.
|
||||
.baseUrl("https://www.bitwarden.com")
|
||||
.build()
|
||||
.create(),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models a response from an RP digital asset link request.
|
||||
*/
|
||||
@Serializable
|
||||
data class DigitalAssetLinkResponseJson(
|
||||
@SerialName("relation")
|
||||
val relation: List<String>,
|
||||
|
||||
@SerialName("target")
|
||||
val target: Target,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Represents targets for an asset link statement.
|
||||
*/
|
||||
@Serializable
|
||||
data class Target(
|
||||
@SerialName("namespace")
|
||||
val namespace: String,
|
||||
|
||||
@SerialName("package_name")
|
||||
val packageName: String?,
|
||||
|
||||
@SerialName("sha256_cert_fingerprints")
|
||||
val sha256CertFingerprints: List<String>?,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models a FIDO 2 credential creation request options received from a Relying Party (RP).
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialCreationOptions(
|
||||
@SerialName("authenticatorSelection")
|
||||
val authenticatorSelection: AuthenticatorSelectionCriteria,
|
||||
@SerialName("challenge")
|
||||
val challenge: String,
|
||||
@SerialName("excludedCredentials")
|
||||
val excludeCredentials: List<PublicKeyCredentialDescriptor> = emptyList(),
|
||||
@SerialName("pubKeyCredParams")
|
||||
val pubKeyCredParams: List<PublicKeyCredentialParameters>,
|
||||
@SerialName("rp")
|
||||
val relyingParty: PublicKeyCredentialRpEntity,
|
||||
@SerialName("user")
|
||||
val user: PublicKeyCredentialUserEntity,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Represents criteria that must be respected when selecting a credential.
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthenticatorSelectionCriteria(
|
||||
@SerialName("authenticatorAttachment")
|
||||
val authenticatorAttachment: AuthenticatorAttachment? = null,
|
||||
@SerialName("residentKey")
|
||||
val residentKey: ResidentKeyRequirement? = null,
|
||||
) {
|
||||
/**
|
||||
* Enum class representing the types of attachments associated with selection criteria.
|
||||
*/
|
||||
@Serializable
|
||||
enum class AuthenticatorAttachment {
|
||||
@SerialName("platform")
|
||||
PLATFORM,
|
||||
|
||||
@SerialName("cross_platform")
|
||||
CROSS_PLATFORM,
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum class indicating the type of authentication expected by the selection criteria.
|
||||
*/
|
||||
@Serializable
|
||||
enum class ResidentKeyRequirement {
|
||||
/**
|
||||
* User verification is preferred during selection, if supported.
|
||||
*/
|
||||
@SerialName("preferred")
|
||||
PREFERRED,
|
||||
|
||||
/**
|
||||
* User verification is required during selection.
|
||||
*/
|
||||
@SerialName("required")
|
||||
REQUIRED,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents details about a credential provided in the creation options.
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialDescriptor(
|
||||
@SerialName("type")
|
||||
val type: String,
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
@SerialName("transports")
|
||||
val transports: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents parameters for a credential in the creation options.
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialParameters(
|
||||
@SerialName("type")
|
||||
val type: String,
|
||||
@SerialName("alg")
|
||||
val alg: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the RP associated with the credential options.
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialRpEntity(
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the user associated with teh credential options.
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialUserEntity(
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
@SerialName("displayName")
|
||||
val displayName: String,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
|
||||
|
||||
/**
|
||||
* Provides an API for querying digital asset links.
|
||||
*/
|
||||
interface DigitalAssetLinkService {
|
||||
|
||||
/**
|
||||
* Attempt to retrieve the asset links file from the provided [relyingParty].
|
||||
*/
|
||||
suspend fun getDigitalAssetLinkForRp(
|
||||
scheme: String = "https://",
|
||||
relyingParty: String,
|
||||
): Result<List<DigitalAssetLinkResponseJson>>
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api.DigitalAssetLinkApi
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
|
||||
|
||||
/**
|
||||
* Primary implementation of [DigitalAssetLinkService].
|
||||
*/
|
||||
class DigitalAssetLinkServiceImpl(
|
||||
private val digitalAssetLinkApi: DigitalAssetLinkApi,
|
||||
) : DigitalAssetLinkService {
|
||||
|
||||
override suspend fun getDigitalAssetLinkForRp(
|
||||
scheme: String,
|
||||
relyingParty: String,
|
||||
): Result<List<DigitalAssetLinkResponseJson>> =
|
||||
digitalAssetLinkApi
|
||||
.getDigitalAssetLinks(
|
||||
url = "$scheme$relyingParty/.well-known/assetlinks.json",
|
||||
)
|
||||
}
|
|
@ -4,9 +4,13 @@ import android.content.Context
|
|||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import dagger.Module
|
||||
|
@ -14,6 +18,7 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
|
@ -25,8 +30,8 @@ import javax.inject.Singleton
|
|||
object Fido2ProviderModule {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Singleton
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialProviderProcessor(
|
||||
@ApplicationContext context: Context,
|
||||
authRepository: AuthRepository,
|
||||
|
@ -39,4 +44,17 @@ object Fido2ProviderModule {
|
|||
intentManager,
|
||||
dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFido2CredentialManager(
|
||||
assetManager: AssetManager,
|
||||
digitalAssetLinkService: DigitalAssetLinkService,
|
||||
json: Json,
|
||||
): Fido2CredentialManager =
|
||||
Fido2CredentialManagerImpl(
|
||||
assetManager = assetManager,
|
||||
digitalAssetLinkService = digitalAssetLinkService,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.manager
|
||||
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
|
||||
/**
|
||||
* Responsible for managing FIDO 2 credential creation and authentication.
|
||||
*/
|
||||
interface Fido2CredentialManager {
|
||||
|
||||
/**
|
||||
* Attempt to validate the RP and origin of the provided [fido2CredentialRequest].
|
||||
*/
|
||||
suspend fun validateOrigin(
|
||||
fido2CredentialRequest: Fido2CredentialRequest,
|
||||
): Fido2ValidateOriginResult
|
||||
|
||||
/**
|
||||
* Attempt to create a FIDO2 credential from the given [credentialRequest] and associate it to
|
||||
* the given [cipherView].
|
||||
*/
|
||||
fun createCredentialForCipher(
|
||||
credentialRequest: Fido2CredentialRequest,
|
||||
cipherView: CipherView,
|
||||
): Fido2CreateCredentialResult
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.manager
|
||||
|
||||
import androidx.credentials.exceptions.CreateCredentialUnknownException
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.platform.util.flatMap
|
||||
import com.x8bit.bitwarden.data.platform.util.getCallingAppApkFingerprint
|
||||
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json"
|
||||
|
||||
/**
|
||||
* Primary implementation of [Fido2CredentialManager].
|
||||
*/
|
||||
class Fido2CredentialManagerImpl(
|
||||
private val assetManager: AssetManager,
|
||||
private val digitalAssetLinkService: DigitalAssetLinkService,
|
||||
private val json: Json,
|
||||
) : Fido2CredentialManager {
|
||||
|
||||
override suspend fun validateOrigin(
|
||||
fido2CredentialRequest: Fido2CredentialRequest,
|
||||
): Fido2ValidateOriginResult {
|
||||
val callingAppInfo = fido2CredentialRequest.callingAppInfo
|
||||
return if (callingAppInfo.isOriginPopulated()) {
|
||||
validatePrivilegedAppOrigin(callingAppInfo)
|
||||
} else {
|
||||
validateCallingApplicationAssetLinks(fido2CredentialRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun validateCallingApplicationAssetLinks(
|
||||
fido2CredentialRequest: Fido2CredentialRequest,
|
||||
): Fido2ValidateOriginResult {
|
||||
val callingAppInfo = fido2CredentialRequest.callingAppInfo
|
||||
return fido2CredentialRequest
|
||||
.requestJson
|
||||
.getRpId(json)
|
||||
.flatMap { rpId ->
|
||||
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = rpId)
|
||||
}
|
||||
.onFailure {
|
||||
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
|
||||
}
|
||||
.map { statements ->
|
||||
statements
|
||||
.filterMatchingAppStatementsOrNull(
|
||||
rpPackageName = callingAppInfo.packageName,
|
||||
)
|
||||
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
|
||||
}
|
||||
.map { matchingStatements ->
|
||||
matchingStatements
|
||||
.filterMatchingAppSignaturesOrNull(
|
||||
signature = callingAppInfo.getCallingAppApkFingerprint(),
|
||||
)
|
||||
?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified
|
||||
}
|
||||
.fold(
|
||||
onSuccess = {
|
||||
Fido2ValidateOriginResult.Success
|
||||
},
|
||||
onFailure = {
|
||||
Fido2ValidateOriginResult.Error.Unknown
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun validatePrivilegedAppOrigin(
|
||||
callingAppInfo: CallingAppInfo,
|
||||
): Fido2ValidateOriginResult =
|
||||
assetManager
|
||||
.readAsset(ALLOW_LIST_FILE_NAME)
|
||||
.map { allowList ->
|
||||
callingAppInfo.validatePrivilegedApp(
|
||||
allowList = allowList,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
|
||||
)
|
||||
|
||||
override fun createCredentialForCipher(
|
||||
credentialRequest: Fido2CredentialRequest,
|
||||
cipherView: CipherView,
|
||||
): Fido2CreateCredentialResult {
|
||||
// TODO [PM-8137]: Create and save passkey to cipher.
|
||||
return Fido2CreateCredentialResult.Error(CreateCredentialUnknownException())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns statements targeting the calling Android application, or null.
|
||||
*/
|
||||
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppStatementsOrNull(
|
||||
rpPackageName: String,
|
||||
): List<DigitalAssetLinkResponseJson>? =
|
||||
filter { statement ->
|
||||
val target = statement.target
|
||||
target.namespace == "android_app" &&
|
||||
target.packageName == rpPackageName &&
|
||||
statement.relation.containsAll(
|
||||
listOf(
|
||||
"delegate_permission/common.get_login_creds",
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
),
|
||||
)
|
||||
}
|
||||
.takeUnless { it.isEmpty() }
|
||||
|
||||
/**
|
||||
* Returns statements that match the given [signature], or null.
|
||||
*/
|
||||
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppSignaturesOrNull(
|
||||
signature: String,
|
||||
): List<DigitalAssetLinkResponseJson>? =
|
||||
filter { statement ->
|
||||
statement.target.sha256CertFingerprints
|
||||
?.contains(signature)
|
||||
?: false
|
||||
}
|
||||
.takeUnless { it.isEmpty() }
|
||||
|
||||
private fun String.getRpId(json: Json): Result<String> {
|
||||
return try {
|
||||
json
|
||||
.decodeFromString<PublicKeyCredentialCreationOptions>(this)
|
||||
.relyingParty
|
||||
.id
|
||||
.asSuccess()
|
||||
} catch (e: SerializationException) {
|
||||
e.asFailure()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.asFailure()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.model
|
||||
|
||||
import androidx.credentials.exceptions.CreateCredentialException
|
||||
|
||||
/**
|
||||
* Models the data returned from creating a FIDO 2 credential.
|
||||
*/
|
||||
sealed class Fido2CreateCredentialResult {
|
||||
|
||||
/**
|
||||
* Models a successful response for creating a credential.
|
||||
*/
|
||||
data class Success(
|
||||
val registrationResponse: String,
|
||||
) : Fido2CreateCredentialResult()
|
||||
|
||||
/**
|
||||
* Models an error response for creating a credential.
|
||||
*/
|
||||
data class Error(
|
||||
val exception: CreateCredentialException,
|
||||
) : Fido2CreateCredentialResult()
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.model
|
||||
|
||||
import android.content.pm.SigningInfo
|
||||
import android.os.Parcelable
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents raw data from the a user deciding to create a passkey in their vault via the
|
||||
* credential manager framework.
|
||||
*
|
||||
* @property userId The user under which the passkey should be saved.
|
||||
* @property requestJson JSON payload containing the RP request.
|
||||
* @property callingAppInfo Information about the application that initiated the request.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Fido2CredentialRequest(
|
||||
val userId: String,
|
||||
val requestJson: String,
|
||||
val packageName: String,
|
||||
val signingInfo: SigningInfo,
|
||||
val origin: String?,
|
||||
) : Parcelable {
|
||||
val callingAppInfo: CallingAppInfo
|
||||
get() = CallingAppInfo(
|
||||
packageName = packageName,
|
||||
signingInfo = signingInfo,
|
||||
origin = origin,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.model
|
||||
|
||||
/**
|
||||
* Models the result of validating the origin of a FIDO2 request.
|
||||
*/
|
||||
sealed class Fido2ValidateOriginResult {
|
||||
|
||||
/**
|
||||
* Represents a successful origin validation.
|
||||
*/
|
||||
data object Success : Fido2ValidateOriginResult()
|
||||
|
||||
/**
|
||||
* Represents a validation error.
|
||||
*/
|
||||
sealed class Error : Fido2ValidateOriginResult() {
|
||||
|
||||
/**
|
||||
* Indicates the digital asset links file could not be located.
|
||||
*/
|
||||
data object AssetLinkNotFound : Error()
|
||||
|
||||
/**
|
||||
* Indicates the application package name was not found in the digital asset links file.
|
||||
*/
|
||||
data object ApplicationNotFound : Error()
|
||||
|
||||
/**
|
||||
* Indicates the application fingerprint was not found the digital asset links file.
|
||||
*/
|
||||
data object ApplicationNotVerified : Error()
|
||||
|
||||
/**
|
||||
* Indicates the calling application is privileged but its package name is not found within
|
||||
* the privileged app allow list.
|
||||
*/
|
||||
data object PrivilegedAppNotAllowed : Error()
|
||||
|
||||
/**
|
||||
* Indicates the calling app is privileged but but no matching signing certificate signature
|
||||
* is present in the allow list.
|
||||
*/
|
||||
data object PrivilegedAppSignatureNotFound : Error()
|
||||
|
||||
/**
|
||||
* Indicates passkeys are not supported for the requesting application.
|
||||
*/
|
||||
data object PasskeyNotSupportedForApp : Error()
|
||||
|
||||
/**
|
||||
* Indicates an unknown error was encountered while validating the origin.
|
||||
*/
|
||||
data object Unknown : Error()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.util
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.credentials.CreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.provider.PendingIntentHandler
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID
|
||||
|
||||
/**
|
||||
* Checks if this [Intent] contains a [Fido2CredentialRequest] related to an ongoing FIDO 2
|
||||
* credential creation process.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
@OmitFromCoverage
|
||||
fun Intent.getFido2CredentialRequestOrNull(): Fido2CredentialRequest? {
|
||||
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null
|
||||
|
||||
val systemRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this)
|
||||
?: return null
|
||||
|
||||
val createPublicKeyRequest =
|
||||
systemRequest.callingRequest as? CreatePublicKeyCredentialRequest
|
||||
?: return null
|
||||
|
||||
val userId = getStringExtra(EXTRA_KEY_USER_ID)
|
||||
?: return null
|
||||
|
||||
return Fido2CredentialRequest(
|
||||
userId = userId,
|
||||
requestJson = createPublicKeyRequest.requestJson,
|
||||
packageName = systemRequest.callingAppInfo.packageName,
|
||||
signingInfo = systemRequest.callingAppInfo.signingInfo,
|
||||
origin = systemRequest.callingAppInfo.origin,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
/**
|
||||
* Manages reading assets.
|
||||
*/
|
||||
interface AssetManager {
|
||||
|
||||
/**
|
||||
* Read [fileName] from the assets folder. A successful result will contain the contents as a
|
||||
* String.
|
||||
*/
|
||||
suspend fun readAsset(fileName: String): Result<String>
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import android.content.Context
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Primary implementation of [AssetManager].
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
class AssetManagerImpl(
|
||||
private val context: Context,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : AssetManager {
|
||||
|
||||
override suspend fun readAsset(fileName: String): Result<String> = runCatching {
|
||||
withContext(dispatcherManager.io) {
|
||||
context
|
||||
.assets
|
||||
.open(fileName)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlI
|
|||
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
|
||||
|
@ -177,4 +179,14 @@ object PlatformManagerModule {
|
|||
settingsRepository = settingsRepository,
|
||||
legacyAppCenterMigrator = legacyAppCenterMigrator,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAssetManager(
|
||||
@ApplicationContext context: Context,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): AssetManager = AssetManagerImpl(
|
||||
context = context,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
|
@ -47,6 +48,15 @@ sealed class SpecialCircumstance : Parcelable {
|
|||
val shouldFinishWhenComplete: Boolean,
|
||||
) : SpecialCircumstance()
|
||||
|
||||
/**
|
||||
* The app was launched via the credential manager framework in order to allow the user to
|
||||
* manually save a passkey to their vault.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Fido2Save(
|
||||
val fido2CredentialRequest: Fido2CredentialRequest,
|
||||
) : SpecialCircumstance()
|
||||
|
||||
/**
|
||||
* The app was launched via deeplink to the generator.
|
||||
*/
|
||||
|
|
|
@ -15,6 +15,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
|
|||
is SpecialCircumstance.ShareNewSend -> null
|
||||
SpecialCircumstance.GeneratorShortcut -> null
|
||||
SpecialCircumstance.VaultShortcut -> null
|
||||
is SpecialCircumstance.Fido2Save -> null
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -28,4 +29,5 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
|
|||
is SpecialCircumstance.ShareNewSend -> null
|
||||
SpecialCircumstance.GeneratorShortcut -> null
|
||||
SpecialCircumstance.VaultShortcut -> null
|
||||
is SpecialCircumstance.Fido2Save -> null
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import android.os.Build
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
|
||||
/**
|
||||
* Returns true if the current OS build version is below the provided [version].
|
||||
*
|
||||
* @see Build.VERSION_CODES
|
||||
*/
|
||||
fun isBuildVersionBelow(version: Int): Boolean = version > Build.VERSION.SDK_INT
|
|
@ -0,0 +1,60 @@
|
|||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Returns the name of the RP. If this [CallingAppInfo] is a privileged app the RP host name will be
|
||||
* returned. If this [CallingAppInfo] is a native RP application the package name will be returned.
|
||||
* Otherwise, `null` is returned.
|
||||
*/
|
||||
fun CallingAppInfo.getFido2RpOrNull(): String? {
|
||||
return if (isOriginPopulated()) {
|
||||
origin?.toHostOrPathOrNull()
|
||||
} else {
|
||||
packageName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signing certificate hash formatted as a hex string.
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun CallingAppInfo.getCallingAppApkFingerprint(): String {
|
||||
val cert = signingInfo.apkContentsSigners[0].toByteArray()
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val certHash = md.digest(cert)
|
||||
return certHash
|
||||
.joinToString(":") { b ->
|
||||
b.toHexString(HexFormat.UpperCase)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this [CallingAppInfo] is present in the privileged app [allowList]. Otherwise,
|
||||
* returns false.
|
||||
*/
|
||||
fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOriginResult {
|
||||
|
||||
if (!allowList.contains("\"package_name\": \"$packageName\"")) {
|
||||
return Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed
|
||||
}
|
||||
|
||||
return try {
|
||||
if (getOrigin(allowList) != null) {
|
||||
Fido2ValidateOriginResult.Success
|
||||
} else {
|
||||
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
// We know the package name is in the allow list so we can infer that this exception is
|
||||
// thrown because no matching signature is found.
|
||||
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// The allow list is not formatted correctly so we notify the user passkeys are not
|
||||
// supported for this application
|
||||
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package com.x8bit.bitwarden.ui.autofill.fido2.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
|
||||
|
||||
/**
|
||||
* A manager for completing the FIDO 2 creation process.
|
||||
*/
|
||||
interface Fido2CompletionManager {
|
||||
|
||||
/**
|
||||
* Completes the FIDO 2 creation process with the provided [result].
|
||||
*/
|
||||
fun completeFido2Create(result: Fido2CreateCredentialResult)
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package com.x8bit.bitwarden.ui.autofill.fido2.manager
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.credentials.CreatePublicKeyCredentialResponse
|
||||
import androidx.credentials.provider.PendingIntentHandler
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
|
||||
/**
|
||||
* Primary implementation of [Fido2CompletionManager].
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
class Fido2CompletionManagerImpl(
|
||||
private val activity: Activity,
|
||||
) : Fido2CompletionManager {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
override fun completeFido2Create(result: Fido2CreateCredentialResult) {
|
||||
activity.also {
|
||||
val intent = Intent()
|
||||
when (result) {
|
||||
is Fido2CreateCredentialResult.Error -> {
|
||||
PendingIntentHandler
|
||||
.setCreateCredentialException(
|
||||
intent = intent,
|
||||
exception = result.exception,
|
||||
)
|
||||
}
|
||||
|
||||
is Fido2CreateCredentialResult.Success -> {
|
||||
PendingIntentHandler
|
||||
.setCreateCredentialResponse(
|
||||
intent = intent,
|
||||
response = CreatePublicKeyCredentialResponse(
|
||||
registrationResponseJson = result.registrationResponse,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
it.setResult(Activity.RESULT_OK, intent)
|
||||
it.finish()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,6 +25,11 @@ import kotlin.math.floor
|
|||
*/
|
||||
const val ZERO_WIDTH_CHARACTER: String = "\u200B"
|
||||
|
||||
/**
|
||||
* URI scheme for a native Android application.
|
||||
*/
|
||||
private const val ANDROID_APP_URI_SCHEME: String = "androidapp://"
|
||||
|
||||
/**
|
||||
* Returns the original [String] only if:
|
||||
*
|
||||
|
@ -68,6 +73,13 @@ fun String.toHostOrPathOrNull(): String? {
|
|||
return uri.host ?: uri.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original [String] prefixed with `androidapp://` if it doesn't already contain.
|
||||
*/
|
||||
fun String.toAndroidAppUriString(): String {
|
||||
return if (this.startsWith(ANDROID_APP_URI_SCHEME)) this else "$ANDROID_APP_URI_SCHEME$this"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original [String] only if:
|
||||
*
|
||||
|
|
|
@ -9,6 +9,8 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
|
||||
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManagerImpl
|
||||
|
@ -33,6 +35,7 @@ fun LocalManagerProvider(content: @Composable () -> Unit) {
|
|||
LocalExitManager provides ExitManagerImpl(activity),
|
||||
LocalBiometricsManager provides BiometricsManagerImpl(activity),
|
||||
LocalNfcManager provides NfcManagerImpl(activity),
|
||||
LocalFido2CompletionManager provides Fido2CompletionManagerImpl(activity),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
@ -72,3 +75,8 @@ val LocalPermissionsManager: ProvidableCompositionLocal<PermissionsManager> = co
|
|||
val LocalNfcManager: ProvidableCompositionLocal<NfcManager> = compositionLocalOf {
|
||||
error("CompositionLocal NfcManager not present")
|
||||
}
|
||||
|
||||
val LocalFido2CompletionManager: ProvidableCompositionLocal<Fido2CompletionManager> =
|
||||
compositionLocalOf {
|
||||
error("CompositionLocal Fido2CompletionManager not present")
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
|
|||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
@ -105,6 +106,7 @@ fun RootNavScreen(
|
|||
is RootNavState.VaultUnlockedForAutofillSelection,
|
||||
is RootNavState.VaultUnlockedForNewSend,
|
||||
is RootNavState.VaultUnlockedForAuthRequest,
|
||||
is RootNavState.VaultUnlockedForFido2Save,
|
||||
-> VAULT_UNLOCKED_GRAPH_ROUTE
|
||||
}
|
||||
val currentRoute = navController.currentDestination?.rootLevelRoute()
|
||||
|
@ -182,6 +184,14 @@ fun RootNavScreen(
|
|||
navOptions = rootNavOptions,
|
||||
)
|
||||
}
|
||||
|
||||
is RootNavState.VaultUnlockedForFido2Save -> {
|
||||
navController.navigateToVaultUnlockedGraph(rootNavOptions)
|
||||
navController.navigateToVaultItemListingAsRoot(
|
||||
vaultItemListingType = VaultItemListingType.Login,
|
||||
navOptions = rootNavOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.os.Parcelable
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
|
@ -93,6 +94,13 @@ class RootNavViewModel @Inject constructor(
|
|||
RootNavState.VaultUnlockedForAuthRequest
|
||||
}
|
||||
|
||||
is SpecialCircumstance.Fido2Save -> {
|
||||
RootNavState.VaultUnlockedForFido2Save(
|
||||
activeUserId = userState.activeUserId,
|
||||
fido2CredentialRequest = specialCircumstance.fido2CredentialRequest,
|
||||
)
|
||||
}
|
||||
|
||||
SpecialCircumstance.GeneratorShortcut,
|
||||
SpecialCircumstance.VaultShortcut,
|
||||
null,
|
||||
|
@ -172,6 +180,20 @@ sealed class RootNavState : Parcelable {
|
|||
val type: AutofillSelectionData.Type,
|
||||
) : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show an add item screen for a user to complete the saving of data collected by
|
||||
* the fido2 credential manager framework
|
||||
*
|
||||
* @param activeUserId ID of the active user. Indirectly used to notify [RootNavViewModel] the
|
||||
* active user has changed.
|
||||
* @param fido2CredentialRequest System request containing FIDO credential data.
|
||||
*/
|
||||
@Parcelize
|
||||
data class VaultUnlockedForFido2Save(
|
||||
val activeUserId: String,
|
||||
val fido2CredentialRequest: Fido2CredentialRequest,
|
||||
) : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show the new send screen for an unlocked user.
|
||||
*/
|
||||
|
|
|
@ -49,7 +49,7 @@ private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp"
|
|||
*
|
||||
* @see IntentManager.createFido2CreationPendingIntent
|
||||
*/
|
||||
private const val EXTRA_KEY_USER_ID: String = "EXTRA_KEY_USER_ID"
|
||||
const val EXTRA_KEY_USER_ID: String = "user_id"
|
||||
|
||||
/**
|
||||
* The default implementation of the [IntentManager] for simplifying the handling of Android
|
||||
|
|
|
@ -27,6 +27,7 @@ import androidx.core.net.toUri
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher
|
||||
|
@ -43,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
|||
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
|
@ -69,6 +71,7 @@ fun VaultItemListingScreen(
|
|||
onNavigateToEditSendItem: (sendId: String) -> Unit,
|
||||
onNavigateToSearch: (searchType: SearchType) -> Unit,
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current,
|
||||
viewModel: VaultItemListingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
@ -130,6 +133,10 @@ fun VaultItemListingScreen(
|
|||
is VaultItemListingEvent.NavigateToCollectionItem -> {
|
||||
onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId))
|
||||
}
|
||||
|
||||
is VaultItemListingEvent.CompleteFido2Create -> {
|
||||
fido2CompletionManager.completeFido2Create(event.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,6 +145,13 @@ fun VaultItemListingScreen(
|
|||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultItemListingsAction.DismissDialogClick) }
|
||||
},
|
||||
onDismissFido2ErrorDialog = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
VaultItemListingsAction.DismissFido2CreationErrorDialogClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
VaultItemListingScaffold(
|
||||
|
@ -153,6 +167,7 @@ fun VaultItemListingScreen(
|
|||
private fun VaultItemListingDialogs(
|
||||
dialogState: VaultItemListingState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
onDismissFido2ErrorDialog: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog(
|
||||
|
@ -167,6 +182,14 @@ private fun VaultItemListingDialogs(
|
|||
visibilityState = LoadingDialogState.Shown(dialogState.message),
|
||||
)
|
||||
|
||||
is VaultItemListingState.DialogState.Fido2CreationFail -> BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = dialogState.title,
|
||||
message = dialogState.message,
|
||||
),
|
||||
onDismissRequest = onDismissFido2ErrorDialog,
|
||||
)
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.credentials.exceptions.CreateCredentialUnknownException
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
|
@ -19,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
|||
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.map
|
||||
import com.x8bit.bitwarden.data.platform.util.getFido2RpOrNull
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
|
@ -29,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
|||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAndroidAppUriString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
|
@ -61,7 +68,7 @@ import javax.inject.Inject
|
|||
* and launches [VaultItemListingEvent] for the [VaultItemListingScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList")
|
||||
@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList", "LargeClass")
|
||||
class VaultItemListingViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val clock: Clock,
|
||||
|
@ -74,13 +81,20 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
private val cipherMatchingManager: CipherMatchingManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val policyManager: PolicyManager,
|
||||
private val fido2CredentialManager: Fido2CredentialManager,
|
||||
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
|
||||
initialState = run {
|
||||
val userState = requireNotNull(authRepository.userStateFlow.value)
|
||||
val activeAccountSummary = userState.toActiveAccountSummary()
|
||||
val accountSummaries = userState.toAccountSummaries()
|
||||
val specialCircumstance =
|
||||
specialCircumstanceManager.specialCircumstance as? SpecialCircumstance.AutofillSelection
|
||||
val specialCircumstance = specialCircumstanceManager.specialCircumstance
|
||||
val autofillSelectionData = specialCircumstance as? SpecialCircumstance.AutofillSelection
|
||||
val fido2CreationData = specialCircumstance as? SpecialCircumstance.Fido2Save
|
||||
val shouldFinishOnComplete = autofillSelectionData
|
||||
?.shouldFinishWhenComplete
|
||||
?: (fido2CreationData != null)
|
||||
val dialogState = fido2CreationData
|
||||
?.let { VaultItemListingState.DialogState.Loading(R.string.loading.asText()) }
|
||||
VaultItemListingState(
|
||||
itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle)
|
||||
.vaultItemListingType
|
||||
|
@ -93,13 +107,14 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
|
||||
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
|
||||
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
|
||||
dialogState = null,
|
||||
dialogState = dialogState,
|
||||
policyDisablesSend = policyManager
|
||||
.getActivePolicies(type = PolicyTypeJson.DISABLE_SEND)
|
||||
.any(),
|
||||
autofillSelectionData = specialCircumstance?.autofillSelectionData,
|
||||
shouldFinishOnComplete = specialCircumstance?.shouldFinishWhenComplete ?: false,
|
||||
autofillSelectionData = autofillSelectionData?.autofillSelectionData,
|
||||
shouldFinishOnComplete = shouldFinishOnComplete,
|
||||
hasMasterPassword = userState.activeAccount.hasMasterPassword,
|
||||
fido2CredentialRequest = fido2CreationData?.fido2CredentialRequest,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
@ -116,16 +131,18 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
.onEach { sendAction(VaultItemListingsAction.Internal.IconLoadingSettingReceive(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
vaultRepository
|
||||
.vaultDataStateFlow
|
||||
.onEach {
|
||||
viewModelScope.launch {
|
||||
state
|
||||
.fido2CredentialRequest
|
||||
?.let { request ->
|
||||
sendAction(
|
||||
VaultItemListingsAction.Internal.VaultDataReceive(
|
||||
it.filterForAutofillIfNecessary(),
|
||||
VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive(
|
||||
result = fido2CredentialManager.validateOrigin(request),
|
||||
),
|
||||
)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
?: observeVaultData()
|
||||
}
|
||||
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND)
|
||||
|
@ -134,12 +151,30 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun observeVaultData() {
|
||||
vaultRepository
|
||||
.vaultDataStateFlow
|
||||
.map {
|
||||
VaultItemListingsAction.Internal.VaultDataReceive(
|
||||
it
|
||||
.filterForAutofillIfNecessary()
|
||||
.filterForFido2CreationIfNecessary(),
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: VaultItemListingsAction) {
|
||||
when (action) {
|
||||
is VaultItemListingsAction.LockAccountClick -> handleLockAccountClick(action)
|
||||
is VaultItemListingsAction.LogoutAccountClick -> handleLogoutAccountClick(action)
|
||||
is VaultItemListingsAction.SwitchAccountClick -> handleSwitchAccountClick(action)
|
||||
is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick()
|
||||
is VaultItemListingsAction.DismissFido2CreationErrorDialogClick -> {
|
||||
handleDismissFido2ErrorDialogClick()
|
||||
}
|
||||
|
||||
is VaultItemListingsAction.BackClick -> handleBackClick()
|
||||
is VaultItemListingsAction.FolderClick -> handleFolderClick(action)
|
||||
is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action)
|
||||
|
@ -249,6 +284,7 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
sendEvent(VaultItemListingEvent.NavigateToSendItem(id = action.sendId))
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun handleItemClick(action: VaultItemListingsAction.ItemClick) {
|
||||
if (state.isAutofill) {
|
||||
val cipherView = getCipherViewOrNull(action.id) ?: return
|
||||
|
@ -256,6 +292,16 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
if (state.isFido2Creation) {
|
||||
val cipherView = getCipherViewOrNull(action.id) ?: return
|
||||
val credentialRequest = state.fido2CredentialRequest ?: return
|
||||
fido2CredentialManager.createCredentialForCipher(
|
||||
credentialRequest = credentialRequest,
|
||||
cipherView = cipherView,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val event = when (state.itemListingType) {
|
||||
is VaultItemListingState.ItemListingType.Vault -> {
|
||||
VaultItemListingEvent.NavigateToVaultItem(id = action.id)
|
||||
|
@ -337,6 +383,16 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleDismissFido2ErrorDialogClick() {
|
||||
sendEvent(
|
||||
VaultItemListingEvent.CompleteFido2Create(
|
||||
result = Fido2CreateCredentialResult.Error(
|
||||
exception = CreateCredentialUnknownException(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(
|
||||
event = VaultItemListingEvent.NavigateBack,
|
||||
|
@ -456,6 +512,10 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
is VaultItemListingsAction.Internal.PolicyUpdateReceive -> {
|
||||
handlePolicyUpdateReceive(action)
|
||||
}
|
||||
|
||||
is VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive -> {
|
||||
handleValidateFido2OriginResultReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -671,6 +731,64 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleValidateFido2OriginResultReceive(
|
||||
action: VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is Fido2ValidateOriginResult.Error -> {
|
||||
handleFido2OriginValidationFail(result)
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Success -> {
|
||||
handleFido2OriginValidationSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFido2OriginValidationFail(error: Fido2ValidateOriginResult.Error) {
|
||||
val messageResId = when (error) {
|
||||
Fido2ValidateOriginResult.Error.ApplicationNotFound -> {
|
||||
R.string.passkey_operation_failed_because_app_not_found_in_asset_links
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Error.ApplicationNotVerified -> {
|
||||
R.string.passkey_operation_failed_because_app_could_not_be_verified
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Error.AssetLinkNotFound -> {
|
||||
R.string.passkey_operation_failed_because_of_missing_asset_links
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed -> {
|
||||
R.string.passkey_operation_failed_because_browser_is_not_privileged
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp -> {
|
||||
R.string.passkeys_not_supported_for_this_app
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound -> {
|
||||
R.string.passkey_operation_failed_because_browser_signature_does_not_match
|
||||
}
|
||||
|
||||
Fido2ValidateOriginResult.Error.Unknown -> {
|
||||
R.string.generic_error_message
|
||||
}
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = messageResId.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFido2OriginValidationSuccess() {
|
||||
observeVaultData()
|
||||
}
|
||||
|
||||
private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) {
|
||||
mutableStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
|
@ -691,6 +809,7 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
baseIconUrl = state.baseIconUrl,
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
autofillSelectionData = state.autofillSelectionData,
|
||||
fido2CreationData = state.fido2CredentialRequest,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -736,6 +855,26 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the given vault data and filters it for fido2 credential creation if necessary.
|
||||
*/
|
||||
@Suppress("MaxLineLength")
|
||||
private suspend fun DataState<VaultData>.filterForFido2CreationIfNecessary(): DataState<VaultData> {
|
||||
val request = state.fido2CredentialRequest ?: return this
|
||||
return this.map { vaultData ->
|
||||
val matchUri = request.origin
|
||||
?: request.packageName
|
||||
.toAndroidAppUriString()
|
||||
|
||||
vaultData.copy(
|
||||
cipherViewList = cipherMatchingManager.filterCiphersForMatches(
|
||||
ciphers = vaultData.cipherViewList,
|
||||
matchUri = matchUri,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -755,6 +894,7 @@ data class VaultItemListingState(
|
|||
// Internal
|
||||
private val isPullToRefreshSettingEnabled: Boolean,
|
||||
val autofillSelectionData: AutofillSelectionData? = null,
|
||||
val fido2CredentialRequest: Fido2CredentialRequest? = null,
|
||||
val shouldFinishOnComplete: Boolean = false,
|
||||
val hasMasterPassword: Boolean,
|
||||
) {
|
||||
|
@ -764,6 +904,12 @@ data class VaultItemListingState(
|
|||
val isAutofill: Boolean
|
||||
get() = autofillSelectionData != null
|
||||
|
||||
/**
|
||||
* Whether or not this represents a listing screen for FIDO2 creation.
|
||||
*/
|
||||
val isFido2Creation: Boolean
|
||||
get() = fido2CredentialRequest != null
|
||||
|
||||
/**
|
||||
* A displayable title for the AppBar.
|
||||
*/
|
||||
|
@ -772,6 +918,10 @@ data class VaultItemListingState(
|
|||
?.uri
|
||||
?.toHostOrPathOrNull()
|
||||
?.let { R.string.items_for_uri.asText(it) }
|
||||
?: fido2CredentialRequest
|
||||
?.callingAppInfo
|
||||
?.getFido2RpOrNull()
|
||||
?.let { R.string.items_for_uri.asText(it) }
|
||||
?: itemListingType.titleText
|
||||
|
||||
/**
|
||||
|
@ -783,17 +933,17 @@ data class VaultItemListingState(
|
|||
/**
|
||||
* Whether or not the account switcher should be shown.
|
||||
*/
|
||||
val shouldShowAccountSwitcher: Boolean get() = isAutofill
|
||||
val shouldShowAccountSwitcher: Boolean get() = isAutofill || isFido2Creation
|
||||
|
||||
/**
|
||||
* Whether or not the navigation icon should be shown.
|
||||
*/
|
||||
val shouldShowNavigationIcon: Boolean get() = !isAutofill
|
||||
val shouldShowNavigationIcon: Boolean get() = !isAutofill && !isFido2Creation
|
||||
|
||||
/**
|
||||
* Whether or not the overflow menu should be shown.
|
||||
*/
|
||||
val shouldShowOverflowMenu: Boolean get() = !isAutofill
|
||||
val shouldShowOverflowMenu: Boolean get() = !isAutofill && !isFido2Creation
|
||||
|
||||
/**
|
||||
* Represents the current state of any dialogs on the screen.
|
||||
|
@ -809,6 +959,16 @@ data class VaultItemListingState(
|
|||
val message: Text,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* Represents a dialog indicating that the FIDO 2 credential creation flow was not
|
||||
* successful.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Fido2CreationFail(
|
||||
val title: Text,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* Represents a loading dialog with the given [message].
|
||||
*/
|
||||
|
@ -888,6 +1048,7 @@ data class VaultItemListingState(
|
|||
* @property overflowOptions list of options for the item's overflow menu.
|
||||
* @property optionsTestTag The test tag associated with the [overflowOptions].
|
||||
* @property isAutofill whether or not this screen is part of an autofill flow.
|
||||
* @property isFido2Creation whether or not this screen is part of fido2 creation flow.
|
||||
* @property shouldShowMasterPasswordReprompt whether or not a master password reprompt is
|
||||
* required for various secure actions.
|
||||
*/
|
||||
|
@ -903,6 +1064,7 @@ data class VaultItemListingState(
|
|||
val overflowOptions: List<ListingItemOverflowAction>,
|
||||
val optionsTestTag: String,
|
||||
val isAutofill: Boolean,
|
||||
val isFido2Creation: Boolean,
|
||||
val shouldShowMasterPasswordReprompt: Boolean,
|
||||
)
|
||||
|
||||
|
@ -1131,6 +1293,15 @@ sealed class VaultItemListingEvent {
|
|||
* @property text the text to display.
|
||||
*/
|
||||
data class ShowToast(val text: Text) : VaultItemListingEvent()
|
||||
|
||||
/**
|
||||
* Complete the current FIDO 2 credential creation process.
|
||||
*
|
||||
* @property result the result of FIDO 2 credential creation.
|
||||
*/
|
||||
data class CompleteFido2Create(
|
||||
val result: Fido2CreateCredentialResult,
|
||||
) : VaultItemListingEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1165,6 +1336,11 @@ sealed class VaultItemListingsAction {
|
|||
*/
|
||||
data object DismissDialogClick : VaultItemListingsAction()
|
||||
|
||||
/**
|
||||
* Click to dismiss the FIDO 2 creation error dialog.
|
||||
*/
|
||||
data object DismissFido2CreationErrorDialogClick : VaultItemListingsAction()
|
||||
|
||||
/**
|
||||
* Click the refresh button.
|
||||
*/
|
||||
|
@ -1293,6 +1469,14 @@ sealed class VaultItemListingsAction {
|
|||
data class PolicyUpdateReceive(
|
||||
val policyDisablesSend: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that a result for validating the relying party's origin during a FIDO 2
|
||||
* request.
|
||||
*/
|
||||
data class ValidateFido2OriginResultReceive(
|
||||
val result: Fido2ValidateOriginResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.bitwarden.core.FolderView
|
|||
import com.bitwarden.core.SendType
|
||||
import com.bitwarden.core.SendView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.util.subtitle
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
|
@ -99,6 +100,7 @@ fun VaultData.toViewState(
|
|||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
autofillSelectionData: AutofillSelectionData?,
|
||||
fido2CreationData: Fido2CredentialRequest?,
|
||||
): VaultItemListingState.ViewState {
|
||||
val filteredCipherViewList = cipherViewList
|
||||
.filter { cipherView ->
|
||||
|
@ -126,6 +128,7 @@ fun VaultData.toViewState(
|
|||
hasMasterPassword = hasMasterPassword,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
isAutofill = autofillSelectionData != null,
|
||||
isFido2Creation = fido2CreationData != null,
|
||||
),
|
||||
displayFolderList = folderList.map { folderView ->
|
||||
VaultItemListingState.FolderDisplayItem(
|
||||
|
@ -251,6 +254,7 @@ private fun List<CipherView>.toDisplayItemList(
|
|||
hasMasterPassword: Boolean,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
isAutofill: Boolean,
|
||||
isFido2Creation: Boolean,
|
||||
): List<VaultItemListingState.DisplayItem> =
|
||||
this.map {
|
||||
it.toDisplayItem(
|
||||
|
@ -258,6 +262,7 @@ private fun List<CipherView>.toDisplayItemList(
|
|||
hasMasterPassword = hasMasterPassword,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
isAutofill = isAutofill,
|
||||
isFido2Creation = isFido2Creation,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -277,6 +282,7 @@ private fun CipherView.toDisplayItem(
|
|||
hasMasterPassword: Boolean,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
isAutofill: Boolean,
|
||||
isFido2Creation: Boolean,
|
||||
): VaultItemListingState.DisplayItem =
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = id.orEmpty(),
|
||||
|
@ -293,6 +299,7 @@ private fun CipherView.toDisplayItem(
|
|||
overflowOptions = toOverflowActions(hasMasterPassword = hasMasterPassword),
|
||||
optionsTestTag = "CipherOptionsButton",
|
||||
isAutofill = isAutofill,
|
||||
isFido2Creation = isFido2Creation,
|
||||
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
|
||||
)
|
||||
|
||||
|
@ -344,6 +351,7 @@ private fun SendView.toDisplayItem(
|
|||
optionsTestTag = "SendOptionsButton",
|
||||
isAutofill = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
isFido2Creation = false,
|
||||
)
|
||||
|
||||
@get:DrawableRes
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
package com.x8bit.bitwarden
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.SigningInfo
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
|
@ -18,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
|
|||
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
|
@ -26,6 +33,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem
|
|||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -45,16 +53,19 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
|
||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
|
||||
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
|
||||
private val fido2CredentialManager = mockk<Fido2CredentialManager>()
|
||||
private val settingsRepository = mockk<SettingsRepository> {
|
||||
every { appTheme } returns AppTheme.DEFAULT
|
||||
every { appThemeStateFlow } returns mutableAppThemeFlow
|
||||
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
|
||||
}
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
every { switchAccount(any()) } returns SwitchAccountResult.NoChange
|
||||
}
|
||||
private val mutableVaultStateEventFlow = bufferedMutableSharedFlow<VaultStateEvent>()
|
||||
private val vaultRepository = mockk<VaultRepository> {
|
||||
every { vaultStateEventFlow } returns mutableVaultStateEventFlow
|
||||
|
@ -74,6 +85,7 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
Intent::getPasswordlessRequestDataIntentOrNull,
|
||||
Intent::getAutofillSaveItemOrNull,
|
||||
Intent::getAutofillSelectionDataOrNull,
|
||||
Intent::getFido2CredentialRequestOrNull,
|
||||
)
|
||||
mockkStatic(
|
||||
Intent::isMyVaultShortcut,
|
||||
|
@ -349,6 +361,111 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on ReceiveFirstIntent with fido2 request data should set the special circumstance to Fido2Save`() {
|
||||
val viewModel = createViewModel()
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = DEFAULT_USER_STATE.activeUserId,
|
||||
requestJson = """{"mockRequestJson":1}""",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = "mockOrigin",
|
||||
)
|
||||
val mockIntent = mockk<Intent> {
|
||||
every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest
|
||||
every { getPasswordlessRequestDataIntentOrNull() } returns null
|
||||
every { getAutofillSelectionDataOrNull() } returns null
|
||||
every { getAutofillSaveItemOrNull() } returns null
|
||||
every { isMyVaultShortcut } returns false
|
||||
every { isPasswordGeneratorShortcut } returns false
|
||||
}
|
||||
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(any())
|
||||
} returns Fido2ValidateOriginResult.Success
|
||||
|
||||
viewModel.trySendAction(
|
||||
MainAction.ReceiveFirstIntent(
|
||||
intent = mockIntent,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
),
|
||||
specialCircumstanceManager.specialCircumstance,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on ReceiveFirstIntent with fido2 request data should switch users if active user is not selected`() {
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
val viewModel = createViewModel()
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "selectedUserId",
|
||||
requestJson = """{"mockRequestJson":1}""",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = "mockOrigin",
|
||||
)
|
||||
val mockIntent = mockk<Intent> {
|
||||
every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest
|
||||
every { getPasswordlessRequestDataIntentOrNull() } returns null
|
||||
every { getAutofillSelectionDataOrNull() } returns null
|
||||
every { getAutofillSaveItemOrNull() } returns null
|
||||
every { isMyVaultShortcut } returns false
|
||||
every { isPasswordGeneratorShortcut } returns false
|
||||
}
|
||||
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(any())
|
||||
} returns Fido2ValidateOriginResult.Success
|
||||
|
||||
viewModel.trySendAction(
|
||||
MainAction.ReceiveFirstIntent(
|
||||
intent = mockIntent,
|
||||
),
|
||||
)
|
||||
|
||||
verify(exactly = 1) { authRepository.switchAccount(fido2CredentialRequest.userId) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on ReceiveFirstIntent with fido2 request data should not switch users if active user is selected`() {
|
||||
val viewModel = createViewModel()
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = DEFAULT_USER_STATE.activeUserId,
|
||||
requestJson = """{"mockRequestJson":1}""",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = "mockOrigin",
|
||||
)
|
||||
val mockIntent = mockk<Intent> {
|
||||
every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest
|
||||
every { getPasswordlessRequestDataIntentOrNull() } returns null
|
||||
every { getAutofillSelectionDataOrNull() } returns null
|
||||
every { getAutofillSaveItemOrNull() } returns null
|
||||
every { isMyVaultShortcut } returns false
|
||||
every { isPasswordGeneratorShortcut } returns false
|
||||
}
|
||||
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(any())
|
||||
} returns Fido2ValidateOriginResult.Success
|
||||
|
||||
viewModel.trySendAction(
|
||||
MainAction.ReceiveFirstIntent(
|
||||
intent = mockIntent,
|
||||
),
|
||||
)
|
||||
|
||||
verify(exactly = 0) { authRepository.switchAccount(fido2CredentialRequest.userId) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() {
|
||||
|
@ -532,10 +649,10 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
autofillSelectionManager = autofillSelectionManager,
|
||||
specialCircumstanceManager = specialCircumstanceManager,
|
||||
garbageCollectionManager = garbageCollectionManager,
|
||||
authRepository = authRepository,
|
||||
intentManager = intentManager,
|
||||
settingsRepository = settingsRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
intentManager = intentManager,
|
||||
authRepository = authRepository,
|
||||
savedStateHandle = savedStateHandle.apply {
|
||||
set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance)
|
||||
},
|
||||
|
@ -543,3 +660,23 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
private const val SPECIAL_CIRCUMSTANCE_KEY: String = "special-circumstance"
|
||||
private val DEFAULT_ACCOUNT = UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@bitwarden.com",
|
||||
environment = Environment.Us,
|
||||
avatarColorHex = "#aa00aa",
|
||||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
needsMasterPassword = false,
|
||||
trustedDevice = null,
|
||||
)
|
||||
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = "activeUserId",
|
||||
accounts = listOf(DEFAULT_ACCOUNT),
|
||||
)
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api.DigitalAssetLinkApi
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
|
||||
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.create
|
||||
|
||||
class DigitalAssetLinkServiceTest : BaseServiceTest() {
|
||||
|
||||
private val digitalAssetLinkApi: DigitalAssetLinkApi = retrofit.create()
|
||||
|
||||
private val digitalAssetLinkService: DigitalAssetLinkService = DigitalAssetLinkServiceImpl(
|
||||
digitalAssetLinkApi = digitalAssetLinkApi,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getDigitalAssetLinkForRp should return the correct response`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(GET_DIGITAL_ASSET_LINK_SUCCESS_JSON))
|
||||
val result = digitalAssetLinkService.getDigitalAssetLinkForRp(
|
||||
scheme = url.scheme,
|
||||
relyingParty = url.host,
|
||||
)
|
||||
assertEquals(
|
||||
createDigitalAssetLinkResponse(),
|
||||
result.getOrThrow(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
private fun createDigitalAssetLinkResponse() = listOf(
|
||||
DigitalAssetLinkResponseJson(
|
||||
relation = listOf(
|
||||
"delegate_permission/common.get_login_creds",
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
),
|
||||
target = DigitalAssetLinkResponseJson.Target(
|
||||
namespace = "android_app",
|
||||
packageName = "com.mock.package",
|
||||
sha256CertFingerprints = listOf(
|
||||
"00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val GET_DIGITAL_ASSET_LINK_SUCCESS_JSON = """
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.get_login_creds",
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.mock.package",
|
||||
"sha256_cert_fingerprints": [
|
||||
"00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
|
@ -0,0 +1,331 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.manager
|
||||
|
||||
import android.content.pm.Signature
|
||||
import android.content.pm.SigningInfo
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.security.MessageDigest
|
||||
|
||||
class Fido2CredentialManagerTest {
|
||||
|
||||
private lateinit var fido2CredentialManager: Fido2CredentialManager
|
||||
|
||||
private val assetManager: AssetManager = mockk {
|
||||
coEvery { readAsset(any()) } returns DEFAULT_ALLOW_LIST.asSuccess()
|
||||
}
|
||||
private val digitalAssetLinkService = mockk<DigitalAssetLinkService> {
|
||||
coEvery {
|
||||
getDigitalAssetLinkForRp(relyingParty = any())
|
||||
} returns DEFAULT_STATEMENT_LIST.asSuccess()
|
||||
}
|
||||
private val mockCreateOptions = mockk<PublicKeyCredentialCreationOptions> {
|
||||
every {
|
||||
relyingParty
|
||||
} returns PublicKeyCredentialCreationOptions.PublicKeyCredentialRpEntity(
|
||||
name = "mockRpName",
|
||||
id = "www.bitwarden.com",
|
||||
)
|
||||
}
|
||||
private val json = mockk<Json> {
|
||||
every {
|
||||
decodeFromString<PublicKeyCredentialCreationOptions>(any())
|
||||
} returns mockCreateOptions
|
||||
}
|
||||
private val mockPrivilegedCallingAppInfo = mockk<CallingAppInfo> {
|
||||
every { packageName } returns "com.x8bit.bitwarden"
|
||||
every { isOriginPopulated() } returns true
|
||||
every { getOrigin(any()) } returns "com.x8bit.bitwarden"
|
||||
}
|
||||
private val mockPrivilegedAppRequest = mockk<Fido2CredentialRequest> {
|
||||
every { callingAppInfo } returns mockPrivilegedCallingAppInfo
|
||||
}
|
||||
private val mockSigningInfo = mockk<SigningInfo> {
|
||||
every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF"))
|
||||
}
|
||||
private val mockUnprivilegedCallingAppInfo = CallingAppInfo(
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = mockSigningInfo,
|
||||
origin = null,
|
||||
)
|
||||
private val mockUnprivilegedAppRequest = mockk<Fido2CredentialRequest> {
|
||||
every { callingAppInfo } returns mockUnprivilegedCallingAppInfo
|
||||
every { requestJson } returns "{}"
|
||||
}
|
||||
private val mockMessageDigest = mockk<MessageDigest> {
|
||||
every { digest(any()) } returns "0987654321ABCDEF".toByteArray()
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkStatic(MessageDigest::class)
|
||||
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
|
||||
|
||||
fido2CredentialManager = Fido2CredentialManagerImpl(
|
||||
assetManager = assetManager,
|
||||
digitalAssetLinkService = digitalAssetLinkService,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(MessageDigest::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should load allow list when origin is populated`() =
|
||||
runTest {
|
||||
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
assetManager.readAsset(
|
||||
fileName = "fido2_privileged_allow_list.json",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return Success when privileged app is allowed`() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Success,
|
||||
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `validateOrigin should return PrivilegedAppSignatureNotFound when privileged app signature is not found in allow list`() =
|
||||
runTest {
|
||||
every { mockPrivilegedCallingAppInfo.getOrigin(any()) } throws IllegalStateException()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound,
|
||||
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `validateOrigin should return PrivilegedAppNotAllowed when privileged app package name is not found in allow list`() =
|
||||
runTest {
|
||||
coEvery { assetManager.readAsset(any()) } returns MISSING_PACKAGE_ALLOW_LIST.asSuccess()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed,
|
||||
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return error when allow list is unreadable`() = runTest {
|
||||
coEvery { assetManager.readAsset(any()) } returns IllegalStateException().asFailure()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.Unknown,
|
||||
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return PasskeyNotSupportedForApp when allow list is invalid`() =
|
||||
runTest {
|
||||
every {
|
||||
mockPrivilegedCallingAppInfo.getOrigin(any())
|
||||
} throws IllegalArgumentException()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
|
||||
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return success when asset links contains matching statement`() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Success,
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return error when request cannot be decoded`() = runTest {
|
||||
every {
|
||||
json.decodeFromString<PublicKeyCredentialCreationOptions>(any())
|
||||
} throws SerializationException()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return error when request cannot be cast to object type`() =
|
||||
runTest {
|
||||
every {
|
||||
json.decodeFromString<PublicKeyCredentialCreationOptions>(any())
|
||||
} throws IllegalArgumentException()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return error when asset links are unavailable`() = runTest {
|
||||
coEvery {
|
||||
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any())
|
||||
} returns Throwable().asFailure()
|
||||
|
||||
assertEquals(
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return error when asset links does not contain package name`() =
|
||||
runTest {
|
||||
every { mockUnprivilegedAppRequest.callingAppInfo } returns CallingAppInfo(
|
||||
packageName = "its.a.trap",
|
||||
signingInfo = mockSigningInfo,
|
||||
origin = null,
|
||||
)
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.ApplicationNotFound,
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `validateOrigin should return error when asset links does not contain android app namespace`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any())
|
||||
} returns listOf(
|
||||
DEFAULT_STATEMENT.copy(
|
||||
target = DEFAULT_STATEMENT.target.copy(
|
||||
namespace = "its_a_trap",
|
||||
),
|
||||
),
|
||||
)
|
||||
.asSuccess()
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.ApplicationNotFound,
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOrigin should return error when asset links certificate hash no match`() =
|
||||
runTest {
|
||||
every {
|
||||
mockMessageDigest.digest(any())
|
||||
} returns "ITSATRAP".toByteArray()
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.ApplicationNotVerified,
|
||||
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createCredentialForCipher should return error while not implemented`() {
|
||||
val result = fido2CredentialManager.createCredentialForCipher(
|
||||
credentialRequest = mockk(),
|
||||
cipherView = mockk(),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
result is Fido2CreateCredentialResult.Error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
private const val DEFAULT_CERT_FINGERPRINT =
|
||||
"30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
|
||||
private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson(
|
||||
relation = listOf(
|
||||
"delegate_permission/common.get_login_creds",
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
),
|
||||
target = DigitalAssetLinkResponseJson.Target(
|
||||
namespace = "android_app",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
sha256CertFingerprints = listOf(
|
||||
DEFAULT_CERT_FINGERPRINT,
|
||||
),
|
||||
),
|
||||
)
|
||||
private val DEFAULT_STATEMENT_LIST = listOf(DEFAULT_STATEMENT)
|
||||
private const val DEFAULT_ALLOW_LIST = """
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.x8bit.bitwarden",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
private const val MISSING_PACKAGE_ALLOW_LIST = """
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.base
|
|||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
|
@ -17,10 +18,12 @@ abstract class BaseServiceTest {
|
|||
|
||||
protected val server = MockWebServer().apply { start() }
|
||||
|
||||
protected val url: HttpUrl = server.url("/")
|
||||
|
||||
protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}"
|
||||
|
||||
protected val retrofit: Retrofit = Retrofit.Builder()
|
||||
.baseUrl(server.url("/").toString())
|
||||
.baseUrl(url.toString())
|
||||
.addCallAdapterFactory(ResultCallAdapterFactory())
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import android.content.pm.Signature
|
||||
import android.content.pm.SigningInfo
|
||||
import android.util.Base64
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.security.MessageDigest
|
||||
|
||||
class CallingAppInfoExtensionsTest {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkStatic(MessageDigest::class)
|
||||
mockkStatic(Base64::class)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(MessageDigest::class)
|
||||
unmockkStatic(Base64::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFido2RpOrNull should return null when origin is populated with invalid URI`() {
|
||||
val mockCallingAppInfo = mockk<CallingAppInfo> {
|
||||
every { isOriginPopulated() } returns true
|
||||
every { origin } returns "invalidUri9685%^$^&(*"
|
||||
}
|
||||
|
||||
assertNull(mockCallingAppInfo.getFido2RpOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFido2RpOrNull should return origin when origin is populated`() {
|
||||
val mockCallingAppInfo = mockk<CallingAppInfo> {
|
||||
every { isOriginPopulated() } returns true
|
||||
every { origin } returns "mockUri"
|
||||
}
|
||||
|
||||
assertEquals("mockUri", mockCallingAppInfo.getFido2RpOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFido2RpOrNull should return null when origin is null`() {
|
||||
val mockCallingAppInfo = mockk<CallingAppInfo> {
|
||||
every { isOriginPopulated() } returns true
|
||||
every { origin } returns null
|
||||
}
|
||||
|
||||
assertNull(mockCallingAppInfo.getFido2RpOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFido2RpOrNull should return package name when origin is not populated`() {
|
||||
val mockCallingAppInfo = mockk<CallingAppInfo> {
|
||||
every { isOriginPopulated() } returns false
|
||||
every { packageName } returns "mockPackageName"
|
||||
}
|
||||
|
||||
assertEquals("mockPackageName", mockCallingAppInfo.getFido2RpOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCallingAppApkFingerprint should return key hash`() {
|
||||
val mockMessageDigest = mockk<MessageDigest> {
|
||||
every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray()
|
||||
}
|
||||
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
|
||||
every { Base64.encodeToString(any(), any()) } returns DEFAULT_SIGNATURE
|
||||
|
||||
val mockSigningInfo = mockk<SigningInfo> {
|
||||
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_SIGNATURE))
|
||||
}
|
||||
val appInfo = mockk<CallingAppInfo> {
|
||||
every { packageName } returns "packageName"
|
||||
every { signingInfo } returns mockSigningInfo
|
||||
every { origin } returns null
|
||||
}
|
||||
assertEquals(
|
||||
DEFAULT_SIGNATURE_HASH,
|
||||
appInfo.getCallingAppApkFingerprint(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePrivilegedApp should return Success when privileged app is allowed`() {
|
||||
val mockAppInfo = mockk<CallingAppInfo> {
|
||||
every { getOrigin(any()) } returns "origin"
|
||||
every { packageName } returns "com.x8bit.bitwarden"
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Success,
|
||||
mockAppInfo.validatePrivilegedApp(
|
||||
allowList = DEFAULT_ALLOW_LIST,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `validatePrivilegedApp should return PasskeyNotSupportedForApp when allow list is invalid`() {
|
||||
val appInfo = mockk<CallingAppInfo> {
|
||||
every { packageName } returns "com.x8bit.bitwarden"
|
||||
every { getOrigin(any()) } throws IllegalArgumentException()
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
|
||||
appInfo.validatePrivilegedApp(
|
||||
allowList = INVALID_ALLOW_LIST,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `validatePrivilegedApp should return PrivilegedAppNotAllowed when calling app is not present in allow list`() {
|
||||
val appInfo = mockk<CallingAppInfo> {
|
||||
every { packageName } returns "packageName"
|
||||
every { origin } returns "origin"
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed,
|
||||
appInfo.validatePrivilegedApp(
|
||||
allowList = DEFAULT_ALLOW_LIST,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"
|
||||
private const val DEFAULT_SIGNATURE_HASH = "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
|
||||
private const val DEFAULT_ALLOW_LIST = """
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.x8bit.bitwarden",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
private const val INVALID_ALLOW_LIST = """
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.x8bit.bitwarden",
|
||||
"signatures": [
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
|
@ -1,7 +1,9 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.rootnav
|
||||
|
||||
import android.content.pm.SigningInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
|
||||
|
@ -404,6 +406,50 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when the active user has an unlocked vault but there is a Fido2Save special circumstance the nav state should be VaultUnlockedForFido2Save`() {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "activeUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = "mockOrigin",
|
||||
)
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.Fido2Save(fido2CredentialRequest)
|
||||
mutableUserStateFlow.tryEmit(
|
||||
UserState(
|
||||
activeUserId = "activeUserId",
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "name",
|
||||
email = "email",
|
||||
avatarColorHex = "avatarHexColor",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
needsMasterPassword = false,
|
||||
trustedDevice = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(
|
||||
RootNavState.VaultUnlockedForFido2Save(
|
||||
activeUserId = "activeUserId",
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the active user has a locked vault the nav state should be VaultLocked`() {
|
||||
mutableUserStateFlow.tryEmit(
|
||||
|
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.test.performClick
|
|||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
|
||||
|
@ -80,6 +81,9 @@ class VaultItemListingScreenTest : BaseComposeTest() {
|
|||
every { shareText(any()) } just runs
|
||||
every { launchUri(any()) } just runs
|
||||
}
|
||||
private val fido2CompletionManager: Fido2CompletionManager = mockk {
|
||||
every { completeFido2Create(any()) } just runs
|
||||
}
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<VaultItemListingEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<VaultItemListingViewModel>(relaxed = true) {
|
||||
|
@ -95,6 +99,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
|
|||
VaultItemListingScreen(
|
||||
viewModel = viewModel,
|
||||
intentManager = intentManager,
|
||||
fido2CompletionManager = fido2CompletionManager,
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToVaultItem = { onNavigateToVaultItemId = it },
|
||||
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
|
||||
|
@ -1555,6 +1560,7 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
|
|||
),
|
||||
optionsTestTag = "SendOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = null,
|
||||
)
|
||||
|
@ -1576,6 +1582,7 @@ private fun createCipherDisplayItem(number: Int): VaultItemListingState.DisplayI
|
|||
),
|
||||
optionsTestTag = "CipherOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = null,
|
||||
)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
|
||||
|
||||
import android.content.pm.SigningInfo
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
|
@ -9,6 +10,9 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
|
@ -45,7 +49,9 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
|
|||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import io.mockk.Ordering
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -120,6 +126,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
every { getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) } returns emptyList()
|
||||
every { getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) } returns emptyFlow()
|
||||
}
|
||||
private val fido2CredentialManager: Fido2CredentialManager = mockk {
|
||||
coEvery { validateOrigin(any()) } returns Fido2ValidateOriginResult.Success
|
||||
}
|
||||
|
||||
private val initialState = createVaultItemListingState()
|
||||
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
|
||||
|
@ -136,6 +145,35 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial dialog state should be correct when fido2Request is present`() = runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
"mockUserId",
|
||||
"{}",
|
||||
"com.x8bit.bitwarden",
|
||||
SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
dialogState = VaultItemListingState.DialogState.Loading(
|
||||
message = R.string.loading.asText(),
|
||||
),
|
||||
shouldFinishOnComplete = true,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on LockAccountClick should call lockVault for the given account`() {
|
||||
val accountUserId = "userId"
|
||||
|
@ -933,6 +971,65 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loaded with items and fido2 filtering should update ViewState to Content with filtered data`() =
|
||||
runTest {
|
||||
setupMockUri()
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(any())
|
||||
} returns Fido2ValidateOriginResult.Success
|
||||
|
||||
val cipherView1 = createMockCipherView(number = 1)
|
||||
val cipherView2 = createMockCipherView(number = 2)
|
||||
|
||||
mockFilteredCiphers = listOf(cipherView1)
|
||||
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "activeUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = "mockOrigin",
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
val dataState = DataState.Loaded(
|
||||
data = VaultData(
|
||||
cipherViewList = listOf(cipherView1, cipherView2),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
collectionViewList = listOf(createMockCollectionView(number = 1)),
|
||||
sendViewList = listOf(createMockSendView(number = 1)),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
mutableVaultDataStateFlow.value = dataState
|
||||
|
||||
assertEquals(
|
||||
createVaultItemListingState(
|
||||
viewState = VaultItemListingState.ViewState.Content(
|
||||
displayCollectionList = emptyList(),
|
||||
displayItemList = listOf(
|
||||
createMockDisplayItemForCipher(number = 1)
|
||||
.copy(isFido2Creation = true),
|
||||
),
|
||||
displayFolderList = emptyList(),
|
||||
),
|
||||
)
|
||||
.copy(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
shouldFinishOnComplete = true,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems`() =
|
||||
runTest {
|
||||
|
@ -1376,6 +1473,242 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Fido2Request should be evaluated before observing vault data`() {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
"mockUserId",
|
||||
"{}",
|
||||
"com.x8bit.bitwarden",
|
||||
SigningInfo(),
|
||||
origin = "com.x8bit.bitwarden",
|
||||
)
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest,
|
||||
)
|
||||
|
||||
createVaultItemListingViewModel()
|
||||
|
||||
coVerify(ordering = Ordering.ORDERED) {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
vaultRepository.vaultDataStateFlow
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on Unknown error`() = runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.Unknown
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.generic_error_message.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppNotAllowed error`() =
|
||||
runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.passkey_operation_failed_because_browser_is_not_privileged.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppSignatureNotFound error`() =
|
||||
runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.passkey_operation_failed_because_browser_signature_does_not_match.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on PasskeyNotSupportedForApp error`() =
|
||||
runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.passkeys_not_supported_for_this_app.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotFound error`() =
|
||||
runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.ApplicationNotFound
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.passkey_operation_failed_because_app_not_found_in_asset_links.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on AssetLinkNotFound error`() =
|
||||
runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.AssetLinkNotFound
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.passkey_operation_failed_because_of_missing_asset_links.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotVerified error`() =
|
||||
runTest {
|
||||
val fido2CredentialRequest = Fido2CredentialRequest(
|
||||
userId = "mockUserId",
|
||||
requestJson = "{}",
|
||||
packageName = "com.x8bit.bitwarden",
|
||||
signingInfo = SigningInfo(),
|
||||
origin = null,
|
||||
)
|
||||
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
|
||||
fido2CredentialRequest = fido2CredentialRequest,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
|
||||
} returns Fido2ValidateOriginResult.Error.ApplicationNotVerified
|
||||
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.DialogState.Fido2CreationFail(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
R.string.passkey_operation_failed_because_app_could_not_be_verified.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialogState,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun createSavedStateHandleWithVaultItemListingType(
|
||||
vaultItemListingType: VaultItemListingType,
|
||||
|
@ -1433,6 +1766,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
cipherMatchingManager = cipherMatchingManager,
|
||||
specialCircumstanceManager = specialCircumstanceManager,
|
||||
policyManager = policyManager,
|
||||
fido2CredentialManager = fido2CredentialManager,
|
||||
)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
|
|
|
@ -393,6 +393,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
autofillSelectionData = null,
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
)
|
||||
|
||||
|
@ -467,6 +468,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
type = AutofillSelectionData.Type.LOGIN,
|
||||
uri = null,
|
||||
),
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
)
|
||||
|
||||
|
@ -518,6 +520,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
autofillSelectionData = null,
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
),
|
||||
)
|
||||
|
@ -536,6 +539,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
autofillSelectionData = null,
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
),
|
||||
)
|
||||
|
@ -552,6 +556,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
autofillSelectionData = null,
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
),
|
||||
)
|
||||
|
@ -571,6 +576,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
type = AutofillSelectionData.Type.LOGIN,
|
||||
uri = "https://www.test.com",
|
||||
),
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
),
|
||||
)
|
||||
|
@ -710,6 +716,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
autofillSelectionData = null,
|
||||
itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("1"),
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
)
|
||||
|
||||
|
@ -750,6 +757,7 @@ class VaultItemListingDataExtensionsTest {
|
|||
autofillSelectionData = null,
|
||||
itemListingType = VaultItemListingState.ItemListingType.Vault.Collection("mockId-1"),
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
fido2CreationData = null,
|
||||
hasMasterPassword = true,
|
||||
)
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
optionsTestTag = "CipherOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = "LoginCipherIcon",
|
||||
)
|
||||
|
@ -100,6 +101,7 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
optionsTestTag = "CipherOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = "SecureNoteCipherIcon",
|
||||
)
|
||||
|
@ -142,6 +144,7 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
optionsTestTag = "CipherOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = "CardCipherIcon",
|
||||
)
|
||||
|
@ -176,6 +179,7 @@ fun createMockDisplayItemForCipher(
|
|||
),
|
||||
optionsTestTag = "CipherOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = "IdentityCipherIcon",
|
||||
)
|
||||
|
@ -224,6 +228,7 @@ fun createMockDisplayItemForSend(
|
|||
),
|
||||
optionsTestTag = "SendOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = null,
|
||||
)
|
||||
|
@ -262,6 +267,7 @@ fun createMockDisplayItemForSend(
|
|||
),
|
||||
optionsTestTag = "SendOptionsButton",
|
||||
isAutofill = false,
|
||||
isFido2Creation = false,
|
||||
shouldShowMasterPasswordReprompt = false,
|
||||
iconTestTag = null,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue