From 5e77adcf3edd282b50fa4246d066b6c6c910e523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Tue, 17 Dec 2024 12:43:50 +0100 Subject: [PATCH] Refactor finding rpId for login (#2751) * Refactor finding rpId for login * Add tests * Fix build --- src/frontend/src/utils/iiConnection.test.ts | 132 +++++++++++++++++- src/frontend/src/utils/iiConnection.ts | 25 +++- .../src/utils/multiWebAuthnIdentity.ts | 25 ++-- src/showcase/src/flows.ts | 2 +- 4 files changed, 158 insertions(+), 26 deletions(-) diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts index 552e136640..f1a541d3d9 100644 --- a/src/frontend/src/utils/iiConnection.test.ts +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -1,13 +1,30 @@ -import { MetadataMapV2, _SERVICE } from "$generated/internet_identity_types"; +import { + DeviceData, + MetadataMapV2, + _SERVICE, +} from "$generated/internet_identity_types"; +import { DOMAIN_COMPATIBILITY } from "$src/featureFlags"; import { IdentityMetadata, RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, } from "$src/repositories/identityMetadata"; -import { ActorSubclass } from "@dfinity/agent"; -import { DelegationIdentity } from "@dfinity/identity"; -import { AuthenticatedConnection } from "./iiConnection"; +import { ActorSubclass, DerEncodedPublicKey, Signature } from "@dfinity/agent"; +import { DelegationIdentity, WebAuthnIdentity } from "@dfinity/identity"; +import { CredentialData, convertToCredentialData } from "./credential-devices"; +import { AuthenticatedConnection, Connection } from "./iiConnection"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; +const mockDevice: DeviceData = { + alias: "mockDevice", + metadata: [], + origin: [], + protection: { protected: null }, + pubkey: new Uint8Array(), + key_type: { platform: null }, + purpose: { authentication: null }, + credential_id: [], +}; + const mockDelegationIdentity = { getDelegation() { return { @@ -37,17 +54,22 @@ const mockActor = { return { Ok: { metadata: mockRawMetadata } }; }), identity_metadata_replace: vi.fn().mockResolvedValue({ Ok: null }), + lookup: vi.fn().mockResolvedValue([mockDevice]), } as unknown as ActorSubclass<_SERVICE>; beforeEach(() => { infoResponse = undefined; vi.clearAllMocks(); + vi.stubGlobal("location", { + origin: "https://identity.internetcomputer.org", + }); + DOMAIN_COMPATIBILITY.reset(); }); test("initializes identity metadata repository", async () => { const connection = new AuthenticatedConnection( "12345", - MultiWebAuthnIdentity.fromCredentials([]), + MultiWebAuthnIdentity.fromCredentials([], undefined), mockDelegationIdentity, BigInt(1234), mockActor @@ -62,7 +84,7 @@ test("commits changes on identity metadata", async () => { const userNumber = BigInt(1234); const connection = new AuthenticatedConnection( "12345", - MultiWebAuthnIdentity.fromCredentials([]), + MultiWebAuthnIdentity.fromCredentials([], undefined), mockDelegationIdentity, userNumber, mockActor @@ -88,3 +110,101 @@ test("commits changes on identity metadata", async () => { ], ]); }); + +describe("Connection.login", () => { + beforeEach(() => { + vi.spyOn(MultiWebAuthnIdentity, "fromCredentials").mockImplementation( + () => { + const mockIdentity = { + getPublicKey: () => { + return { + toDer: () => new ArrayBuffer(0) as DerEncodedPublicKey, + toRaw: () => new ArrayBuffer(0), + rawKey: () => new ArrayBuffer(0), + derKey: () => new ArrayBuffer(0) as DerEncodedPublicKey, + }; + }, + } as unknown as WebAuthnIdentity; + class MockMultiWebAuthnIdentity extends MultiWebAuthnIdentity { + static fromCredentials( + credentials: CredentialData[], + rpId: string | undefined + ) { + return new MockMultiWebAuthnIdentity(credentials, rpId); + } + override sign() { + this._actualIdentity = mockIdentity; + return Promise.resolve(new ArrayBuffer(0) as Signature); + } + } + return MockMultiWebAuthnIdentity.fromCredentials([], undefined); + } + ); + }); + + it("login returns authenticated connection with expected rpID", async () => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + "identity.ic0.app" + ); + } + }); + + it("login returns authenticated connection without rpID if flag is not enabled", async () => { + DOMAIN_COMPATIBILITY.set(false); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }); + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); + + it("login returns authenticated connection without rpID if browser doesn't support it", async () => { + DOMAIN_COMPATIBILITY.set(true); + vi.stubGlobal("navigator", { + // Supports RoR + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + }); + const connection = new Connection("aaaaa-aa", mockActor); + + const loginResult = await connection.login(BigInt(12345)); + + expect(loginResult.kind).toBe("loginSuccess"); + if (loginResult.kind === "loginSuccess") { + expect(loginResult.connection).toBeInstanceOf(AuthenticatedConnection); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledTimes(1); + expect(MultiWebAuthnIdentity.fromCredentials).toHaveBeenCalledWith( + [convertToCredentialData(mockDevice)], + undefined + ); + } + }); +}); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index 28ddfc7731..877816319a 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -28,6 +28,7 @@ import { VerifyTentativeDeviceResponse, } from "$generated/internet_identity_types"; import { fromMnemonicWithoutValidation } from "$src/crypto/ed25519"; +import { DOMAIN_COMPATIBILITY } from "$src/featureFlags"; import { features } from "$src/features"; import { IdentityMetadata, @@ -50,8 +51,10 @@ import { import { Principal } from "@dfinity/principal"; import { isNullish, nonNullish } from "@dfinity/utils"; import { convertToCredentialData, CredentialData } from "./credential-devices"; +import { findWebAuthnRpId, relatedDomains } from "./findWebAuthnRpId"; import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; import { isRecoveryDevice, RecoveryDevice } from "./recoveryDevice"; +import { supportsWebauthRoR } from "./userAgent"; import { isWebAuthnCancel } from "./webAuthnErrorUtils"; /* @@ -134,7 +137,11 @@ export interface IIWebAuthnIdentity extends SignIdentity { } export class Connection { - public constructor(readonly canisterId: string) {} + public constructor( + readonly canisterId: string, + // Used for testing purposes + readonly overrideActor?: ActorSubclass<_SERVICE> + ) {} identity_registration_start = async ({ tempIdentity, @@ -369,13 +376,24 @@ export class Connection { userNumber: bigint, credentials: CredentialData[] ): Promise => { + // TODO: Filter out the credentials from the used rpIDs. + const rpId = + DOMAIN_COMPATIBILITY.isEnabled() && + supportsWebauthRoR(window.navigator.userAgent) + ? findWebAuthnRpId( + window.location.origin, + credentials, + relatedDomains() + ) + : undefined; + /* Recover the Identity (i.e. key pair) used when creating the anchor. * If the "DUMMY_AUTH" feature is set, we use a dummy identity, the same identity * that is used in the register flow. */ const identity = features.DUMMY_AUTH ? new DummyIdentity() - : MultiWebAuthnIdentity.fromCredentials(credentials); + : MultiWebAuthnIdentity.fromCredentials(credentials, rpId); let delegationIdentity: DelegationIdentity; // Here we expect a webauth exception if the user canceled the webauthn prompt (triggered by @@ -520,6 +538,9 @@ export class Connection { createActor = async ( identity?: SignIdentity ): Promise> => { + if (this.overrideActor !== undefined) { + return this.overrideActor; + } const agent = await HttpAgent.create({ identity, host: inferHost(), diff --git a/src/frontend/src/utils/multiWebAuthnIdentity.ts b/src/frontend/src/utils/multiWebAuthnIdentity.ts index d97a273f90..90f93a4838 100644 --- a/src/frontend/src/utils/multiWebAuthnIdentity.ts +++ b/src/frontend/src/utils/multiWebAuthnIdentity.ts @@ -7,15 +7,12 @@ * then we know which one the user is actually using * - It doesn't support creating credentials; use `WebAuthnIdentity` for that */ -import { DOMAIN_COMPATIBILITY } from "$src/featureFlags"; import { PublicKey, Signature, SignIdentity } from "@dfinity/agent"; import { DER_COSE_OID, unwrapDER, WebAuthnIdentity } from "@dfinity/identity"; import { isNullish } from "@dfinity/utils"; import borc from "borc"; import { CredentialData } from "./credential-devices"; -import { findWebAuthnRpId, relatedDomains } from "./findWebAuthnRpId"; import { bufferEqual } from "./iiConnection"; -import { supportsWebauthRoR } from "./userAgent"; /** * A SignIdentity that uses `navigator.credentials`. See https://webauthn.guide/ for @@ -27,15 +24,19 @@ export class MultiWebAuthnIdentity extends SignIdentity { * @param json - json to parse */ public static fromCredentials( - credentialData: CredentialData[] + credentialData: CredentialData[], + rpId: string | undefined ): MultiWebAuthnIdentity { - return new this(credentialData); + return new this(credentialData, rpId); } /* Set after the first `sign`, see `sign()` for more info. */ protected _actualIdentity?: WebAuthnIdentity; - protected constructor(readonly credentialData: CredentialData[]) { + protected constructor( + readonly credentialData: CredentialData[], + readonly rpId: string | undefined + ) { super(); this._actualIdentity = undefined; } @@ -67,16 +68,6 @@ export class MultiWebAuthnIdentity extends SignIdentity { return this._actualIdentity.sign(blob); } - const rpId = - DOMAIN_COMPATIBILITY.isEnabled() && - supportsWebauthRoR(window.navigator.userAgent) - ? findWebAuthnRpId( - window.location.origin, - this.credentialData, - relatedDomains() - ) - : undefined; - const result = (await navigator.credentials.get({ publicKey: { allowCredentials: this.credentialData.map((cd) => ({ @@ -85,7 +76,7 @@ export class MultiWebAuthnIdentity extends SignIdentity { })), challenge: blob, userVerification: "discouraged", - rpId, + rpId: this.rpId, }, })) as PublicKeyCredential; diff --git a/src/showcase/src/flows.ts b/src/showcase/src/flows.ts index 18758ef0ba..8724cb89f0 100644 --- a/src/showcase/src/flows.ts +++ b/src/showcase/src/flows.ts @@ -48,7 +48,7 @@ class MockAuthenticatedConnection extends AuthenticatedConnection { constructor() { super( "12345", - MultiWebAuthnIdentity.fromCredentials([]), + MultiWebAuthnIdentity.fromCredentials([], undefined), mockDelegationIdentity, BigInt(12345), mockActor