From 8c2eccc1cce4cee926878935e4055cea56ccaf60 Mon Sep 17 00:00:00 2001 From: Nicolas Mattia Date: Fri, 5 May 2023 17:58:59 +0200 Subject: [PATCH] Infer authenticator aliases (#1556) * Infer authenticator aliases This introduces heuristics to give default aliases to authenticators. The alias prompt is removed from the flows (the user will have to explicitly rename the device if the alias is not ok). The UAParser module is lazily loaded to make sure existing users don't incur the cost of an increased bundle size. * Clean up --- demos/using-dev-build/specs/auth.e2e.ts | 7 -- package-lock.json | 20 +++- package.json | 4 +- .../flows/addDevice/manage/addFIDODevice.ts | 15 ++- .../welcomeView/registerTentativeDevice.ts | 22 ++-- .../src/flows/manage/authenticatorsSection.ts | 5 +- src/frontend/src/flows/register/index.ts | 107 +++++++++++++++++- src/frontend/src/styles/main.css | 2 +- src/frontend/src/test-e2e/constants.ts | 3 +- src/frontend/src/test-e2e/flows.ts | 1 - src/frontend/src/test-e2e/register.test.ts | 36 ++---- src/frontend/src/test-e2e/views.ts | 30 ++--- 12 files changed, 162 insertions(+), 90 deletions(-) diff --git a/demos/using-dev-build/specs/auth.e2e.ts b/demos/using-dev-build/specs/auth.e2e.ts index b510c27791..1fa8f14c26 100644 --- a/demos/using-dev-build/specs/auth.e2e.ts +++ b/demos/using-dev-build/specs/auth.e2e.ts @@ -7,13 +7,6 @@ describe("authentication", () => { // Click on "Create an Internet Identity Anchor" await browser.$("#registerButton").click(); - // Set the name of the device and submit - const registerAlias = await browser.$("#pickAliasInput"); - await registerAlias.waitForExist(); - await registerAlias.setValue("My Device"); - - await browser.$('button[type="submit"]').click(); - // Construct Identity (no-op) const constructIdentity = await browser.$( '[data-action="construct-identity"]' diff --git a/package-lock.json b/package-lock.json index 498f036c4b..3f0bb5d189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,13 +18,15 @@ "lit-html": "^2.7.2", "process": "^0.11.10", "qr-creator": "^1.0.0", - "stream-browserify": "^3.0.0" + "stream-browserify": "^3.0.0", + "ua-parser-js": "^1.0.35" }, "devDependencies": { "@testing-library/jest-dom": "~5", "@trust/webcrypto": "^0.9.2", "@types/jest": "~29", "@types/selenium-standalone": "^7.0.1", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "5.45.1", "@typescript-eslint/parser": "5.45.1", "@wdio/globals": "^8.6.9", @@ -1982,6 +1984,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -8945,7 +8953,6 @@ "version": "1.0.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -11077,6 +11084,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -16279,8 +16292,7 @@ "ua-parser-js": { "version": "1.0.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", - "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", - "dev": true + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==" }, "unbzip2-stream": { "version": "1.4.3", diff --git a/package.json b/package.json index 379ba66c1d..f2320fbaba 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@trust/webcrypto": "^0.9.2", "@types/jest": "~29", "@types/selenium-standalone": "^7.0.1", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "5.45.1", "@typescript-eslint/parser": "5.45.1", "@wdio/globals": "^8.6.9", @@ -58,7 +59,8 @@ "lit-html": "^2.7.2", "process": "^0.11.10", "qr-creator": "^1.0.0", - "stream-browserify": "^3.0.0" + "stream-browserify": "^3.0.0", + "ua-parser-js": "^1.0.35" }, "engines": { "npm": ">=9.0.0 <10.0.0", diff --git a/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts b/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts index 647cb0b8cd..11245366db 100644 --- a/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts +++ b/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts @@ -1,7 +1,7 @@ import { DeviceData } from "$generated/internet_identity_types"; -import { promptDeviceAlias } from "$src/components/alias"; import { displayError } from "$src/components/displayError"; import { withLoader } from "$src/components/loader"; +import { inferAlias, loadUAParser } from "$src/flows/register"; import { authenticatorAttachmentToKeyType } from "$src/utils/authenticatorAttachment"; import { AuthenticatedConnection, @@ -39,6 +39,8 @@ export const addFIDODevice = async ( connection: AuthenticatedConnection, devices: DeviceData[] ): Promise => { + // Kick-off fetching "ua-parser-js"; + const uaParser = loadUAParser(); let newDevice: WebAuthnIdentity; try { newDevice = await WebAuthnIdentity.create({ @@ -56,11 +58,12 @@ export const addFIDODevice = async ( } return; } - const deviceName = await promptDeviceAlias({ title: "Add a Trusted Device" }); - if (deviceName === null) { - // user clicked "cancel", so we return - return; - } + + const deviceName = await inferAlias({ + authenticatorType: newDevice.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); try { await withLoader(() => connection.add( diff --git a/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts b/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts index f4d7098c27..58ab5bbac9 100644 --- a/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts +++ b/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts @@ -3,9 +3,9 @@ import { CredentialId, DeviceData, } from "$generated/internet_identity_types"; -import { promptDeviceAlias } from "$src/components/alias"; import { displayError } from "$src/components/displayError"; import { withLoader } from "$src/components/loader"; +import { inferAlias, loadUAParser } from "$src/flows/register"; import { authenticatorAttachmentToKeyType } from "$src/utils/authenticatorAttachment"; import { Connection, creationOptions } from "$src/utils/iiConnection"; import { setAnchorUsed } from "$src/utils/userNumber"; @@ -17,7 +17,6 @@ import { isDuplicateDeviceError, } from "$src/utils/webAuthnErrorUtils"; import { WebAuthnIdentity } from "@dfinity/identity"; -import { html } from "lit-html"; import { deviceRegistrationDisabledInfo } from "./deviceRegistrationModeDisabled"; import { showVerificationCode } from "./showVerificationCode"; @@ -29,17 +28,8 @@ export const registerTentativeDevice = async ( userNumber: bigint, connection: Connection ): Promise<{ alias: string }> => { - // First, we need an alias for the device to (tentatively) add - const alias = await promptDeviceAlias({ - title: "Add a Trusted Device", - message: html` What device do you want to add to Internet Identity - ${userNumber}?`, - }); - - if (alias === null) { - // TODO L2-309: do this without reload - return window.location.reload() as never; - } + // Kick-off fetching "ua-parser-js"; + const uaParser = loadUAParser(); // Then, we create local WebAuthn credentials for the device const result = await withLoader(() => @@ -66,6 +56,12 @@ export const registerTentativeDevice = async ( return window.location.reload() as never; } + const alias = await inferAlias({ + authenticatorType: result.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); + // Finally, we submit it to the canister const device: Omit & { credential_id: [CredentialId] } = { diff --git a/src/frontend/src/flows/manage/authenticatorsSection.ts b/src/frontend/src/flows/manage/authenticatorsSection.ts index c79cac6f2d..e698330aa9 100644 --- a/src/frontend/src/flows/manage/authenticatorsSection.ts +++ b/src/frontend/src/flows/manage/authenticatorsSection.ts @@ -67,7 +67,10 @@ export const authenticatorsSection = ({ ${ warnFewDevices - ? html`

+ ? html`

Add a Passkey or recovery method to make your Internet Identity more secure.

` diff --git a/src/frontend/src/flows/register/index.ts b/src/frontend/src/flows/register/index.ts index 311f7caf33..cb9d98ed6c 100644 --- a/src/frontend/src/flows/register/index.ts +++ b/src/frontend/src/flows/register/index.ts @@ -1,12 +1,13 @@ -import { promptDeviceAlias } from "$src/components/alias"; import { apiResultToLoginFlowResult, cancel, LoginFlowResult, } from "$src/utils/flowResult"; -import { Connection } from "$src/utils/iiConnection"; +import { Connection, IIWebAuthnIdentity } from "$src/utils/iiConnection"; import { setAnchorUsed } from "$src/utils/userNumber"; import { unknownToString } from "$src/utils/utils"; +import { nonNullish } from "@dfinity/utils"; +import type { UAParser } from "ua-parser-js"; import { promptCaptcha } from "./captcha"; import { displayUserNumber } from "./finish"; import { savePasskey } from "./passkey"; @@ -18,10 +19,8 @@ export const register = async ({ connection: Connection; }): Promise => { try { - const alias = await promptDeviceAlias({ title: "Register this device" }); - if (alias === null) { - return cancel; - } + // Kick-off fetching "ua-parser-js"; + const uaParser = loadUAParser(); // Kick-off the challenge request early, so that we might already // have a captcha to show once we get to the CAPTCHA screen @@ -31,6 +30,12 @@ export const register = async ({ return cancel; } + const alias = await inferAlias({ + authenticatorType: identity.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); + const captchaResult = await promptCaptcha({ connection, challenge: preloadedChallenge, @@ -57,3 +62,93 @@ export const register = async ({ }; } }; + +type AuthenticatorType = ReturnType< + IIWebAuthnIdentity["getAuthenticatorAttachment"] +>; +type PreloadedUAParser = ReturnType; + +// Logic for inferring a passkey alias based on the authenticator type & user agent +export const inferAlias = async ({ + authenticatorType, + userAgent, + uaParser: uaParser_, +}: { + authenticatorType: AuthenticatorType; + userAgent: typeof navigator.userAgent; + uaParser: PreloadedUAParser; +}): Promise => { + const UNNAMED = "Unnamed Passkey"; + const FIDO = "FIDO Passkey"; + const ICLOUD = "iCloud Passkey"; + + // If the authenticator is cross platform, then it's FIDO + if (authenticatorType === "cross-platform") { + return FIDO; + } + + // Otherwise, make sure the UA parser module is loaded, because + // everything from here will use UA heuristics + const UAParser = await uaParser_; + if (UAParser === undefined) { + return UNNAMED; + } + const uaParser = new UAParser(userAgent); + + if ( + authenticatorType === "platform" && + uaParser.getEngine().name === "WebKit" + ) { + // Safari, including Chrome, FireFox etc on iOS/iPadOs + const version = uaParser.getBrowser().version; + + if (nonNullish(version) && Number(version) >= 16.2) { + // Safari 16.2 enforce usage of iCloud passkeys + return ICLOUD; + } else { + // If the Safari version is older, then we just give the device (since + // each apple device like iPhone, iPad, etc has its own OS, there is no + // need to duplicate the info with the OS) + const device = uaParser.getDevice(); + if (nonNullish(device) && nonNullish(device.model)) { + return device.model; + } + } + } + + if ( + authenticatorType !== "platform" && + uaParser.getEngine().name === "Gecko" && + uaParser.getOS().name === "Mac OS" + ) { + // FireFox on Mac OS does not support TouchID, so if it's not a "platform" authenticator it's some sort + // of FIDO device, even if no authenticator type was provided + return FIDO; + } + + const os = uaParser.getOS().name; + + // As a last resort, we try to show something like "Chrome on Linux" or just "Chrome" or just "Linux" + const browser = uaParser.getBrowser().name; + const browserOn = [ + ...(nonNullish(browser) ? [browser] : []), + ...(nonNullish(os) ? [os] : []), + ]; + authenticatorType satisfies undefined | "platform"; + if (browserOn.length !== 0) { + return browserOn.join(" on "); + } + + // If all else fails, the device is unnamed + return UNNAMED; +}; + +// Dynamically load the user agent parser module +export const loadUAParser = async (): Promise => { + try { + return (await import("ua-parser-js")).default; + } catch (e) { + console.error(e); + return undefined; + } +}; diff --git a/src/frontend/src/styles/main.css b/src/frontend/src/styles/main.css index b4625195c2..edfc1d7da8 100644 --- a/src/frontend/src/styles/main.css +++ b/src/frontend/src/styles/main.css @@ -621,7 +621,7 @@ a:hover, .l-container { position: relative; font-size: 1.6rem; - min-width: 30rem; + min-width: 40rem; max-width: 40rem; /* centers the container and adds a bit of space to make sure the footer does not stick to it */ margin: 0 auto 2rem; diff --git a/src/frontend/src/test-e2e/constants.ts b/src/frontend/src/test-e2e/constants.ts index e8876d9cc7..9d1cf2c98a 100644 --- a/src/frontend/src/test-e2e/constants.ts +++ b/src/frontend/src/test-e2e/constants.ts @@ -13,6 +13,5 @@ export const REPLICA_URL = "https://icp-api.io"; export const II_URL = process.env.II_URL ?? "https://identity.internetcomputer.org"; -export const DEVICE_NAME1 = "Virtual WebAuthn device"; -export const DEVICE_NAME2 = "Other WebAuthn device"; +export const DEVICE_NAME1 = "FIDO Passkey"; export const RECOVERY_PHRASE_NAME = "Recovery Phrase"; diff --git a/src/frontend/src/test-e2e/flows.ts b/src/frontend/src/test-e2e/flows.ts index fee5be1e41..4dbe2ff984 100644 --- a/src/frontend/src/test-e2e/flows.ts +++ b/src/frontend/src/test-e2e/flows.ts @@ -14,7 +14,6 @@ export const FLOWS = { ): Promise { const registerView = new RegisterView(browser); await registerView.waitForDisplay(); - await registerView.enterAlias(deviceName); await registerView.create(); await registerView.waitForRegisterConfirm(); await registerView.confirmRegisterConfirm(); diff --git a/src/frontend/src/test-e2e/register.test.ts b/src/frontend/src/test-e2e/register.test.ts index 7002ded57b..133a127540 100644 --- a/src/frontend/src/test-e2e/register.test.ts +++ b/src/frontend/src/test-e2e/register.test.ts @@ -11,10 +11,8 @@ import { waitToClose, } from "./util"; import { - AddDeviceAliasView, AddDeviceSuccessView, AddIdentityAnchorView, - AddRemoteDeviceAliasView, AddRemoteDeviceInstructionsView, AddRemoteDeviceVerificationCodeView, AuthenticateView, @@ -30,7 +28,7 @@ import { // Read canister ids from the corresponding dfx files. // This assumes that they have been successfully dfx-deployed import { readFileSync } from "fs"; -import { II_URL, REPLICA_URL } from "./constants"; +import { DEVICE_NAME1, II_URL, REPLICA_URL } from "./constants"; export const test_app_canister_ids = JSON.parse( readFileSync("./demos/test-app/.dfx/local/canister_ids.json", "utf-8") ); @@ -39,9 +37,6 @@ const TEST_APP_CANISTER_ID = test_app_canister_ids.test_app.local; const TEST_APP_CANONICAL_URL = `https://${TEST_APP_CANISTER_ID}.ic0.app`; const TEST_APP_NICE_URL = "https://nice-name.com"; -const DEVICE_NAME1 = "Virtual WebAuthn device"; -const DEVICE_NAME2 = "Other WebAuthn device"; - test("Register new identity and login with it", async () => { await runInBrowser(async (browser: WebdriverIO.Browser) => { await browser.url(II_URL); @@ -84,11 +79,6 @@ test("Register new identity and add additional device", async () => { ); await addRemoteDeviceInstructionsView.addFIDODevice(); - const addDeviceAliasView = new AddDeviceAliasView(browser); - await addDeviceAliasView.waitForDisplay(); - await addDeviceAliasView.addAdditionalDevice(DEVICE_NAME2); - await addDeviceAliasView.continue(); - await browser.pause(10_000); // success page @@ -97,8 +87,9 @@ test("Register new identity and add additional device", async () => { await addDeviceSuccessView.continue(); // home - await mainView.waitForDeviceDisplay(DEVICE_NAME1); - await mainView.waitForDeviceDisplay(DEVICE_NAME2); + await mainView.waitForDisplay(); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); await mainView.logout(); await FLOWS.login(userNumber, DEVICE_NAME1, browser); @@ -125,10 +116,6 @@ test("Register new identity and add additional remote device", async () => { await runInBrowser(async (browser2: WebdriverIO.Browser) => { await addVirtualAuthenticator(browser2); await browser2.url(addDeviceLink); - const addRemoteDeviceView = new AddRemoteDeviceAliasView(browser2); - await addRemoteDeviceView.waitForDisplay(); - await addRemoteDeviceView.selectAlias(DEVICE_NAME2); - await addRemoteDeviceView.continue(); const verificationCodeView = new AddRemoteDeviceVerificationCodeView( browser2 @@ -151,8 +138,8 @@ test("Register new identity and add additional remote device", async () => { await addDeviceSuccessView.continue(); await mainView.waitForDisplay(); - await mainView.waitForDeviceDisplay(DEVICE_NAME1); - await mainView.waitForDeviceDisplay(DEVICE_NAME2); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); // Verify success on Browser 2 // browser 2 again @@ -168,8 +155,8 @@ test("Register new identity and add additional remote device", async () => { // main page signed-in const mainView2 = new MainView(browser2); - await mainView2.waitForDeviceDisplay(DEVICE_NAME1); - await mainView2.waitForDeviceDisplay(DEVICE_NAME2); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); }); }); }, 300_000); @@ -194,10 +181,6 @@ test("Register new identity and add additional remote device starting on new dev const addIdentityAnchorView2 = new AddIdentityAnchorView(browser2); await addIdentityAnchorView2.waitForDisplay(); await addIdentityAnchorView2.continue(userNumber); - const addRemoteDeviceView = new AddRemoteDeviceAliasView(browser2); - await addRemoteDeviceView.waitForDisplay(); - await addRemoteDeviceView.selectAlias(DEVICE_NAME2); - await addRemoteDeviceView.continue(); const notInRegistrationModeView = new NotInRegistrationModeView(browser2); await notInRegistrationModeView.waitForDisplay(); @@ -233,7 +216,8 @@ test("Register new identity and add additional remote device starting on new dev }); await mainView.waitForDisplay(); - await mainView.waitForDeviceDisplay(DEVICE_NAME2); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); }); }, 300_000); diff --git a/src/frontend/src/test-e2e/views.ts b/src/frontend/src/test-e2e/views.ts index 4125596fb5..88c6b68864 100644 --- a/src/frontend/src/test-e2e/views.ts +++ b/src/frontend/src/test-e2e/views.ts @@ -59,16 +59,11 @@ export class RenameView extends View { export class RegisterView extends View { async waitForDisplay(): Promise { await this.browser - .$("#pickAliasInput") + .$('[data-action="construct-identity"') .waitForDisplayed({ timeout: 10_000 }); } - async enterAlias(alias: string): Promise { - await this.browser.$("#pickAliasInput").setValue(alias); - } - async create(): Promise { - await this.browser.$("#pickAliasSubmit").click(); await this.browser.$('[data-action="construct-identity"').click(); } @@ -219,6 +214,13 @@ export class MainView extends View { .waitForDisplayed({ timeout: 10_000 }); } + async waitForDeviceCount(deviceName: string, count: number): Promise { + const elems = await this.browser.$$(`//li[@data-device="${deviceName}"]`); + if (elems.length !== count) { + throw Error("Bad number of elements"); + } + } + async waitForDeviceDisplay(deviceName: string): Promise { await this.browser .$(`//li[@data-device="${deviceName}"]`) @@ -365,22 +367,6 @@ export class MainView extends View { } } -export class AddDeviceAliasView extends View { - async waitForDisplay(): Promise { - await this.browser - .$("#pickAliasSubmit") - .waitForDisplayed({ timeout: 3_000 }); - } - - async addAdditionalDevice(alias: string): Promise { - await this.browser.$("#pickAliasInput").setValue(alias); - } - - async continue(): Promise { - await this.browser.$("#pickAliasSubmit").click(); - } -} - export class AddRemoteDeviceAliasView extends View { async waitForDisplay(): Promise { await this.browser