From 66edea7dea1c25156134d209dc54a74c5cb5b1ec Mon Sep 17 00:00:00 2001 From: Andri Schatz Date: Tue, 17 Dec 2024 12:05:06 +0100 Subject: [PATCH 1/2] Add tests to make sure .well-known routes are not cached (#2749) * create tests to check headers on .well-known routes * add config to .well-known/webauthn test --- .../tests/integration/http.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/internet_identity/tests/integration/http.rs b/src/internet_identity/tests/integration/http.rs index d6493678ad..4fb94e9ac3 100644 --- a/src/internet_identity/tests/integration/http.rs +++ b/src/internet_identity/tests/integration/http.rs @@ -517,6 +517,99 @@ fn should_set_cache_control_for_icons() -> Result<(), CallError> { Ok(()) } +#[test] +fn must_not_cache_well_known_ic_domains() -> Result<(), CallError> { + const CERTIFICATION_VERSION: u16 = 2; + let env = env(); + let canister_id = install_ii_canister(&env, II_WASM.clone()); + + // Get index page + let well_known_request = HttpRequest { + method: "GET".to_string(), + url: "/.well-known/ic-domains".to_string(), + headers: vec![], + body: ByteBuf::new(), + certificate_version: Some(CERTIFICATION_VERSION), + }; + let well_known_response = http_request(&env, canister_id, &well_known_request)?; + + assert_eq!(well_known_response.status_code, 200); + println!("{:?}", well_known_response.headers); + assert!( + !well_known_response // Make sure we have no cache-control headers whatsoever on the response + .headers + .clone() + .into_iter() + .map(|headers| headers.0) // Get only the key + .collect::>() + .contains(&"Cache-Control".to_string()) + ); + + let result = verify_response_certification( + &env, + canister_id, + well_known_request, + well_known_response, + CERTIFICATION_VERSION, + ); + assert_eq!(result.verification_version, CERTIFICATION_VERSION); + + Ok(()) +} + +#[test] +fn must_not_cache_well_known_webauthn() -> Result<(), CallError> { + const CERTIFICATION_VERSION: u16 = 2; + let env = env(); + let related_origins: Vec = [ + "https://identity.internetcomputer.org".to_string(), + "https://identity.ic0.app".to_string(), + ] + .to_vec(); + let config = InternetIdentityInit { + assigned_user_number_range: None, + archive_config: None, + canister_creation_cycles_cost: None, + register_rate_limit: None, + captcha_config: None, + related_origins: Some(related_origins.clone()), + }; + let canister_id = install_ii_canister_with_arg(&env, II_WASM.clone(), Some(config)); + + // Get index page + let well_known_request = HttpRequest { + method: "GET".to_string(), + url: "/.well-known/webauthn".to_string(), + headers: vec![], + body: ByteBuf::new(), + certificate_version: Some(CERTIFICATION_VERSION), + }; + let well_known_response = http_request(&env, canister_id, &well_known_request)?; + + assert_eq!(well_known_response.status_code, 200); + println!("{:?}", well_known_response.headers); + assert!( + !well_known_response // Make sure we have no cache-control headers whatsoever on the response + .headers + .clone() + .into_iter() + .map(|headers| headers.0) // Get only the key + .collect::>() + .contains(&"Cache-Control".to_string()) + ); + + let result = verify_response_certification( + &env, + canister_id, + well_known_request, + well_known_response, + CERTIFICATION_VERSION, + ); + assert_eq!(result.verification_version, CERTIFICATION_VERSION); + + Ok(()) +} + /// Verifies that expected metrics are available via the HTTP endpoint. #[test] fn ii_canister_serves_http_metrics() -> Result<(), CallError> { 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 2/2] 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