diff --git a/src/background-safari/services/NativeAppService.ts b/src/background-safari/services/NativeAppService.ts index 1f5adf5..ece46c8 100644 --- a/src/background-safari/services/NativeAppService.ts +++ b/src/background-safari/services/NativeAppService.ts @@ -22,6 +22,7 @@ import { NativeRequest } from "@web-eid.js/models/message/NativeRequest"; import NativeUnavailableError from "@web-eid.js/errors/NativeUnavailableError"; +import { SerializedError } from "@web-eid.js/errors/SerializedError"; import { deserializeError } from "@web-eid.js/utils/errorSerializer"; import libraryConfig from "@web-eid.js/config"; @@ -30,9 +31,11 @@ import calculateJsonSize from "../../shared/utils/calculateJsonSize"; import config from "../../config"; type NativeAppPendingRequest = - | { resolve?: (value: PromiseLike) => void; reject?: (reason?: any) => void } + | { reject?: (reason?: unknown) => void } | null; +type NativeAppResponse = T | { error: SerializedError }; + export enum NativeAppState { UNINITIALIZED, CONNECTING, @@ -61,10 +64,10 @@ export default class NativeAppService { libraryConfig.NATIVE_APP_HANDSHAKE_TIMEOUT, ); - browser.runtime.sendNativeMessage( + void browser.runtime.sendNativeMessage( "application.id", { command: "status" }, - (response) => resolve(response), + (response) => resolve(response as { version: string }), ); }); @@ -90,15 +93,17 @@ export default class NativeAppService { } } - close(error?: any): void { - config.DEBUG && console.log("Disconnecting from native app"); + close(error?: unknown): void { + if (config.DEBUG) { + console.log("Disconnecting from native app"); + } this.state = NativeAppState.DISCONNECTED; this.pending?.reject?.(error); this.pending = null; } - async send(message: NativeRequest, timeout: number, throwAfterTimeout: Error): Promise { + async send(message: NativeRequest, timeout: number, throwAfterTimeout: Error): Promise { if (message.command == "quit") return {} as T; switch (this.state) { @@ -106,29 +111,35 @@ export default class NativeAppService { const releaseLock = await commandMutex.acquire(); const sendPromise = new Promise((resolve, reject) => { - this.pending = { resolve, reject }; + this.pending = { reject }; setTimeout(() => { reject(throwAfterTimeout); }, timeout ); - const onResponse = (message: any): void => { + const onResponse = (message: NativeAppResponse): void => { this.pending = null; - if (message.error) { + if ("error" in message) { reject(deserializeError(message.error)); } else { resolve(message); } }; - config.DEBUG && console.log("Sending message to native app", JSON.stringify(message)); + if (config.DEBUG) { + console.log("Sending message to native app", JSON.stringify(message)); + } const messageSize = calculateJsonSize(message); if (messageSize > config.NATIVE_MESSAGE_MAX_BYTES) { - throw new Error(`native application message exceeded ${config.NATIVE_MESSAGE_MAX_BYTES} bytes`); + const error = new Error(`native application message exceeded ${config.NATIVE_MESSAGE_MAX_BYTES} bytes`); + + this.close(error); + reject(error); + return; } - browser.runtime.sendNativeMessage("application.id", message, onResponse); + void browser.runtime.sendNativeMessage("application.id", message, onResponse); }); return sendPromise.finally(() => releaseLock()); diff --git a/src/background/actions/TokenSigning/errorToResponse.ts b/src/background/actions/TokenSigning/errorToResponse.ts index fdf871b..53ce8ad 100644 --- a/src/background/actions/TokenSigning/errorToResponse.ts +++ b/src/background/actions/TokenSigning/errorToResponse.ts @@ -21,20 +21,26 @@ */ import ErrorCode from "@web-eid.js/errors/ErrorCode"; + import { serializeError } from "@web-eid.js/utils/errorSerializer"; +import { getErrorCode, toError } from "../../../shared/utils/error"; + import { TokenSigningErrorResponse } from "../../../models/TokenSigning/TokenSigningResponse"; + import tokenSigningResponse from "../../../shared/tokenSigningResponse"; -export default function errorToResponse(nonce: string, error: any): TokenSigningErrorResponse { - if (error.code === ErrorCode.ERR_WEBEID_USER_CANCELLED) { +export default function errorToResponse(nonce: string, error: unknown): TokenSigningErrorResponse { + const errorCode = getErrorCode(error); + + if (errorCode === ErrorCode.ERR_WEBEID_USER_CANCELLED) { return tokenSigningResponse("user_cancel", nonce); - } else if (error.code === ErrorCode.ERR_WEBEID_NATIVE_FATAL || - error.code === ErrorCode.ERR_WEBEID_NATIVE_INVALID_ARGUMENT) { - const nativeException = serializeError(error); + } else if (errorCode === ErrorCode.ERR_WEBEID_NATIVE_FATAL || + errorCode === ErrorCode.ERR_WEBEID_NATIVE_INVALID_ARGUMENT) { + const nativeException = serializeError(toError(error)); return tokenSigningResponse("driver_error", nonce, { nativeException }); } else { - return tokenSigningResponse("technical_error", nonce, { error }); + return tokenSigningResponse("technical_error", nonce, { error: serializeError(toError(error)) }); } } diff --git a/src/background/actions/TokenSigning/getCertificate.ts b/src/background/actions/TokenSigning/getCertificate.ts index c350512..cf4019b 100644 --- a/src/background/actions/TokenSigning/getCertificate.ts +++ b/src/background/actions/TokenSigning/getCertificate.ts @@ -61,7 +61,9 @@ export default async function getCertificate( try { const nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Get certificate: connected to native", nativeAppStatus); + if (config.DEBUG) { + console.log("Get certificate: connected to native", nativeAppStatus); + } const message: NativeGetSigningCertificateRequest = { command: "get-signing-certificate", diff --git a/src/background/actions/TokenSigning/sign.ts b/src/background/actions/TokenSigning/sign.ts index 713d030..205273a 100644 --- a/src/background/actions/TokenSigning/sign.ts +++ b/src/background/actions/TokenSigning/sign.ts @@ -77,7 +77,9 @@ export default async function sign( const warnings: Array = []; const nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Sign: connected to native", nativeAppStatus); + if (config.DEBUG) { + console.log("Sign: connected to native", nativeAppStatus); + } let hashFunction = ( Object.keys(digestCommandToHashFunction).includes(algorithm) diff --git a/src/background/actions/__tests__/native-actions-test.ts b/src/background/actions/__tests__/native-actions-test.ts new file mode 100644 index 0000000..344f9d2 --- /dev/null +++ b/src/background/actions/__tests__/native-actions-test.ts @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2020-2026 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import Action from "@web-eid.js/models/Action"; +import ErrorCode from "@web-eid.js/errors/ErrorCode"; +import UserCancelledError from "@web-eid.js/errors/UserCancelledError"; + +import { + NativeAuthenticateResponse, + NativeGetSigningCertificateResponse, + NativeSignResponse, +} from "@web-eid.js/models/message/NativeResponse"; + +import { MessageSender } from "../../../models/Browser/Runtime"; +import NativeAppService from "../../services/NativeAppService"; +import authenticate from "../authenticate"; +import getSigningCertificate from "../getSigningCertificate"; +import sign from "../sign"; + +const SENDER: MessageSender = { + tab: { id: 1 }, + url: "https://example.com/path?query=1#fragment", +}; + +function mockNativeConnect(version = "2.4.1"): jest.SpyInstance { + return jest.spyOn(NativeAppService.prototype, "connect").mockResolvedValue({ version }); +} + +function mockNativeSend(response: object): jest.SpyInstance { + return jest.spyOn(NativeAppService.prototype, "send").mockResolvedValue(response); +} + +describe("background native IPC actions", () => { + let closeSpy: jest.SpyInstance; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + closeSpy = jest.spyOn(NativeAppService.prototype, "close").mockImplementation(() => undefined); + consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("sends authenticate request arguments to web-eid-app and maps the response", async () => { + mockNativeConnect("2.9.0+0"); + const nativeResponse: NativeAuthenticateResponse = { + algorithm: "ES256", + appVersion: "https://web-eid.eu/web-eid-app/releases/2.9.0+0", + format: "web-eid:1.0", + signature: "base64-signature", + unverifiedCertificate: "base64-auth-certificate", + }; + const sendSpy = mockNativeSend(nativeResponse); + + const response = await authenticate( + "12345678123456781234567812345678912356789123", + SENDER, + "2.4.1", + 12345, + "et" + ); + + expect(sendSpy).toHaveBeenCalledWith( + { + command: "authenticate", + arguments: { + challengeNonce: "12345678123456781234567812345678912356789123", + lang: "et", + origin: "https://example.com", + }, + }, + 12345, + expect.objectContaining({ code: ErrorCode.ERR_WEBEID_USER_TIMEOUT }) + ); + expect(response).toEqual({ + action: Action.AUTHENTICATE_SUCCESS, + ...nativeResponse, + }); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("sends get-signing-certificate request arguments to web-eid-app and maps the response", async () => { + mockNativeConnect(); + const nativeResponse: NativeGetSigningCertificateResponse = { + certificate: "base64-signing-certificate", + supportedSignatureAlgorithms: [ + { + cryptoAlgorithm: "ECC", + hashFunction: "SHA-384", + paddingScheme: "NONE", + }, + ], + }; + const sendSpy = mockNativeSend(nativeResponse); + + const response = await getSigningCertificate(SENDER, "2.4.1", 12345, "en"); + + expect(sendSpy).toHaveBeenCalledWith( + { + command: "get-signing-certificate", + arguments: { + lang: "en", + origin: "https://example.com", + }, + }, + 12345, + expect.objectContaining({ code: ErrorCode.ERR_WEBEID_USER_TIMEOUT }) + ); + expect(response).toEqual({ + action: Action.GET_SIGNING_CERTIFICATE_SUCCESS, + ...nativeResponse, + }); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("sends sign request arguments to web-eid-app and maps the response", async () => { + mockNativeConnect(); + const nativeResponse: NativeSignResponse = { + signature: "base64-signature", + signatureAlgorithm: { + cryptoAlgorithm: "RSA", + hashFunction: "SHA-384", + paddingScheme: "PSS", + }, + }; + const sendSpy = mockNativeSend(nativeResponse); + + const response = await sign( + "base64-signing-certificate", + "base64-document-hash", + "SHA-384", + SENDER, + "2.4.1", + 12345, + "fi" + ); + + expect(sendSpy).toHaveBeenCalledWith( + { + command: "sign", + arguments: { + certificate: "base64-signing-certificate", + hash: "base64-document-hash", + hashFunction: "SHA-384", + lang: "fi", + origin: "https://example.com", + }, + }, + 12345, + expect.objectContaining({ code: ErrorCode.ERR_WEBEID_USER_TIMEOUT }) + ); + expect(response).toEqual({ + action: Action.SIGN_SUCCESS, + ...nativeResponse, + }); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("maps native user cancellation to an extension failure response", async () => { + mockNativeConnect(); + jest.spyOn(NativeAppService.prototype, "send").mockRejectedValue( + new UserCancelledError("User cancelled") + ); + + const response = await authenticate( + "12345678123456781234567812345678912356789123", + SENDER, + "2.4.1", + 12345 + ); + + expect(response).toEqual({ + action: Action.AUTHENTICATE_FAILURE, + error: expect.objectContaining({ + code: ErrorCode.ERR_WEBEID_USER_CANCELLED, + message: "User cancelled", + name: "UserCancelledError", + }), + }); + expect(consoleError).toHaveBeenCalled(); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("maps native connection failures to extension failure responses before sending a request", async () => { + const connectError = new Error("missing native application port"); + jest.spyOn(NativeAppService.prototype, "connect").mockRejectedValue(connectError); + const sendSpy = jest.spyOn(NativeAppService.prototype, "send"); + + const response = await getSigningCertificate(SENDER, "2.4.1", 12345); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(response).toEqual({ + action: Action.GET_SIGNING_CERTIFICATE_FAILURE, + error: expect.objectContaining({ + code: ErrorCode.ERR_WEBEID_UNKNOWN_ERROR, + message: "missing native application port", + }), + }); + expect(consoleError).toHaveBeenCalled(); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/background/actions/authenticate.ts b/src/background/actions/authenticate.ts index c7e2e22..5b21537 100644 --- a/src/background/actions/authenticate.ts +++ b/src/background/actions/authenticate.ts @@ -47,7 +47,9 @@ export default async function authenticate( nativeAppService = new NativeAppService(); nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Authenticate: connected to native", nativeAppStatus); + if (config.DEBUG) { + console.log("Authenticate: connected to native", nativeAppStatus); + } const message: NativeAuthenticateRequest = { command: "authenticate", @@ -67,7 +69,9 @@ export default async function authenticate( new UserTimeoutError(), ); - config.DEBUG && console.log("Authenticate: authentication token received"); + if (config.DEBUG) { + console.log("Authenticate: authentication token received"); + } const isResponseValid = ( response?.unverifiedCertificate && diff --git a/src/background/actions/getSigningCertificate.ts b/src/background/actions/getSigningCertificate.ts index e3620ac..b3f96fd 100644 --- a/src/background/actions/getSigningCertificate.ts +++ b/src/background/actions/getSigningCertificate.ts @@ -50,7 +50,9 @@ export default async function getSigningCertificate( nativeAppService = new NativeAppService(); nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("getSigningCertificate: connected to native", nativeAppStatus); + if (config.DEBUG) { + console.log("getSigningCertificate: connected to native", nativeAppStatus); + } const message: NativeGetSigningCertificateRequest = { command: "get-signing-certificate", @@ -78,7 +80,7 @@ export default async function getSigningCertificate( } else { throw new UnknownError("unexpected response from native application"); } - } catch (error: any) { + } catch (error) { console.error("GetSigningCertificate:", error); return actionErrorHandler(Action.GET_SIGNING_CERTIFICATE_FAILURE, error, libraryVersion, nativeAppStatus?.version); diff --git a/src/background/actions/sign.ts b/src/background/actions/sign.ts index 4ad980d..a055685 100644 --- a/src/background/actions/sign.ts +++ b/src/background/actions/sign.ts @@ -54,7 +54,9 @@ export default async function sign( nativeAppService = new NativeAppService(); nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Sign: connected to native", nativeAppStatus); + if (config.DEBUG) { + console.log("Sign: connected to native", nativeAppStatus); + } const message: NativeSignRequest = { command: "sign", diff --git a/src/background/actions/status.ts b/src/background/actions/status.ts index f66b147..41369c2 100644 --- a/src/background/actions/status.ts +++ b/src/background/actions/status.ts @@ -34,6 +34,7 @@ import { serializeError } from "@web-eid.js/utils/errorSerializer"; import NativeAppService from "../services/NativeAppService"; import checkCompatibility from "../../shared/utils/checkCompatibility"; import config from "../../config"; +import { withExtensionVersion } from "../../shared/utils/error"; export default async function status(libraryVersion: string): Promise { const extensionVersion = config.VERSION; @@ -73,14 +74,14 @@ export default async function status(libraryVersion: string): Promise { + const respond = sendResponse as (value: void | object) => void; + if ((message as ExtensionRequest).action) { - onAction(message, sender).then(sendResponse); + void onAction(message as ExtensionRequest, sender).then(respond); } else if ((message as TokenSigningMessage).type) { - onTokenSigningAction(message, sender).then(sendResponse); + void onTokenSigningAction(message as TokenSigningMessage, sender).then(respond); } return true; }); diff --git a/src/background/errors/WebEidError.ts b/src/background/errors/WebEidError.ts new file mode 100644 index 0000000..8a71193 --- /dev/null +++ b/src/background/errors/WebEidError.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020-2026 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import ActionPendingError from "@web-eid.js/errors/ActionPendingError"; +import ActionTimeoutError from "@web-eid.js/errors/ActionTimeoutError"; +import ContextInsecureError from "@web-eid.js/errors/ContextInsecureError"; +import ExtensionUnavailableError from "@web-eid.js/errors/ExtensionUnavailableError"; +import MissingParameterError from "@web-eid.js/errors/MissingParameterError"; +import NativeFatalError from "@web-eid.js/errors/NativeFatalError"; +import NativeInvalidArgumentError from "@web-eid.js/errors/NativeInvalidArgumentError"; +import NativeUnavailableError from "@web-eid.js/errors/NativeUnavailableError"; +import UnknownError from "@web-eid.js/errors/UnknownError"; +import UserCancelledError from "@web-eid.js/errors/UserCancelledError"; +import UserTimeoutError from "@web-eid.js/errors/UserTimeoutError"; +import VersionInvalidError from "@web-eid.js/errors/VersionInvalidError"; +import VersionMismatchError from "@web-eid.js/errors/VersionMismatchError"; + +type WebEidError = + | ActionPendingError + | ActionTimeoutError + | ContextInsecureError + | ExtensionUnavailableError + | MissingParameterError + | NativeFatalError + | NativeInvalidArgumentError + | NativeUnavailableError + | UnknownError + | UserCancelledError + | UserTimeoutError + | VersionInvalidError + | VersionMismatchError + | Error; + +export default WebEidError; diff --git a/src/background/services/NativeAppService.ts b/src/background/services/NativeAppService.ts index fd83a15..f9021d5 100644 --- a/src/background/services/NativeAppService.ts +++ b/src/background/services/NativeAppService.ts @@ -21,6 +21,7 @@ */ import NativeUnavailableError from "@web-eid.js/errors/NativeUnavailableError"; +import { SerializedError } from "@web-eid.js/errors/SerializedError"; import { deserializeError } from "@web-eid.js/utils/errorSerializer"; import libraryConfig from "@web-eid.js/config"; @@ -36,6 +37,8 @@ export enum NativeAppState { DISCONNECTED, } +type NativeAppResponse = T | { error: SerializedError }; + export default class NativeAppService { public state: NativeAppState = NativeAppState.UNINITIALIZED; @@ -48,7 +51,7 @@ export default class NativeAppService { this.port.onDisconnect.addListener(this.disconnectListener.bind(this)); try { - const message = await this.nextMessage( + const message = await this.nextMessage<{ version: string }>( libraryConfig.NATIVE_APP_HANDSHAKE_TIMEOUT, new NativeUnavailableError( `native application handshake timeout, ${libraryConfig.NATIVE_APP_HANDSHAKE_TIMEOUT}ms` @@ -86,10 +89,12 @@ export default class NativeAppService { } disconnectListener(): void { - config.DEBUG && console.log("Native app disconnected."); + if (config.DEBUG) { + console.log("Native app disconnected."); + } // Accessing lastError when it exists stops chrome from throwing it unnecessarily. - chrome?.runtime?.lastError; + void chrome?.runtime?.lastError; this.state = NativeAppState.DISCONNECTED; } @@ -100,24 +105,29 @@ export default class NativeAppService { this.state = NativeAppState.DISCONNECTED; this.port?.disconnect(); - config.DEBUG && console.log("Native app port closed by extension."); + if (config.DEBUG) { + console.log("Native app port closed by extension."); + } } - async send(message: NativeRequest, timeout: number, throwAfterTimeout: Error): Promise { + async send(message: NativeRequest, timeout: number, throwAfterTimeout: Error): Promise { switch (this.state) { case NativeAppState.CONNECTED: { - config.DEBUG && console.log("Sending message to native app", JSON.stringify(message)); + if (config.DEBUG) { + console.log("Sending message to native app", JSON.stringify(message)); + } const messageSize = calculateJsonSize(message); if (messageSize > config.NATIVE_MESSAGE_MAX_BYTES) { + this.close(); throw new Error(`native application message exceeded ${config.NATIVE_MESSAGE_MAX_BYTES} bytes`); } this.port?.postMessage(message); try { - const response = await this.nextMessage(timeout, throwAfterTimeout); + const response = await this.nextMessage(timeout, throwAfterTimeout); this.close(); @@ -157,14 +167,14 @@ export default class NativeAppService { } } - nextMessage(timeout: number, throwAfterTimeout: Error): Promise { + nextMessage(timeout: number, throwAfterTimeout: Error): Promise { return new Promise((resolve, reject) => { let cleanup: (() => void) | null = null; let timer: ReturnType | null = null; - const onMessageListener = (message: any): void => { + const onMessageListener = (message: NativeAppResponse): void => { cleanup?.(); - if (message.error) { + if ("error" in message) { reject(deserializeError(message.error)); } else { resolve(message); diff --git a/src/background/services/__tests__/NativeAppService-test.ts b/src/background/services/__tests__/NativeAppService-test.ts new file mode 100644 index 0000000..e3008dd --- /dev/null +++ b/src/background/services/__tests__/NativeAppService-test.ts @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2020-2026 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import ErrorCode from "@web-eid.js/errors/ErrorCode"; +import NativeUnavailableError from "@web-eid.js/errors/NativeUnavailableError"; +import libraryConfig from "@web-eid.js/config"; + +import { NativeRequest } from "@web-eid.js/models/message/NativeRequest"; +import NativeAppService, { NativeAppState } from "../NativeAppService"; +import config from "../../../config"; + +type MessageListener = (message: object) => void; +type DisconnectListener = () => void; + +class ListenerCollection) => void> { + private listeners = new Set(); + + addListener = jest.fn((listener: TListener) => { + this.listeners.add(listener); + }); + + removeListener = jest.fn((listener: TListener) => { + this.listeners.delete(listener); + }); + + emit(...args: Parameters): void { + this.listeners.forEach((listener) => listener(...args)); + } +} + +class MockNativePort { + name = "eu.webeid"; + error = undefined as unknown as Error; + disconnect = jest.fn(); + onDisconnect = new ListenerCollection(); + onMessage = new ListenerCollection(); + postMessage = jest.fn(); + + emitMessage(message: object): void { + this.onMessage.emit(message); + } + + emitDisconnect(): void { + this.onDisconnect.emit(); + } +} + +function installBrowserMock(port: MockNativePort): jest.Mock { + const connectNative = jest.fn(() => port); + + Object.defineProperty(globalThis, "browser", { + configurable: true, + value: { + runtime: { + connectNative, + }, + }, + }); + + Object.defineProperty(globalThis, "chrome", { + configurable: true, + value: { + runtime: {}, + }, + }); + + return connectNative; +} + +function emitHandshake(port: MockNativePort, version = "2.4.1"): void { + port.emitMessage({ version }); +} + +describe("NativeAppService native messaging IPC", () => { + let port: MockNativePort; + let connectNative: jest.Mock; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + port = new MockNativePort(); + connectNative = installBrowserMock(port); + consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("connects to the configured native host and resolves the version handshake", async () => { + const nativeAppService = new NativeAppService(); + + const connected = nativeAppService.connect(); + emitHandshake(port, "2.9.0+0"); + + await expect(connected).resolves.toEqual({ version: "2.9.0+0" }); + expect(connectNative).toHaveBeenCalledWith(config.NATIVE_APP_NAME); + expect(nativeAppService.state).toBe(NativeAppState.CONNECTED); + }); + + it("rejects when the handshake response is not a version object", async () => { + const nativeAppService = new NativeAppService(); + + const connected = nativeAppService.connect(); + port.emitMessage({ unexpected: "response" }); + + await expect(connected).rejects.toMatchObject({ + code: ErrorCode.ERR_WEBEID_NATIVE_UNAVAILABLE, + message: "expected native application to reply with a version, got {\"unexpected\":\"response\"}", + }); + }); + + it("rejects when the native app closes before the handshake response", async () => { + const nativeAppService = new NativeAppService(); + + const connected = nativeAppService.connect(); + port.emitDisconnect(); + + await expect(connected).rejects.toMatchObject({ + code: ErrorCode.ERR_WEBEID_NATIVE_UNAVAILABLE, + message: "a message from native application was expected, but native application closed connection", + }); + }); + + it("rejects on native app handshake timeout", async () => { + jest.useFakeTimers(); + + const nativeAppService = new NativeAppService(); + const connected = nativeAppService.connect(); + + jest.advanceTimersByTime(libraryConfig.NATIVE_APP_HANDSHAKE_TIMEOUT); + + await expect(connected).rejects.toMatchObject({ + code: ErrorCode.ERR_WEBEID_NATIVE_UNAVAILABLE, + message: `native application handshake timeout, ${libraryConfig.NATIVE_APP_HANDSHAKE_TIMEOUT}ms`, + }); + }); + + it("posts a command request and resolves the native response", async () => { + const nativeAppService = new NativeAppService(); + const connected = nativeAppService.connect(); + emitHandshake(port); + await connected; + + const request: NativeRequest = { + command: "quit", + arguments: {}, + }; + + const sent = nativeAppService.send(request, 1000, new Error("timeout")); + port.emitMessage({}); + + await expect(sent).resolves.toEqual({}); + expect(port.postMessage).toHaveBeenCalledWith(request); + expect(port.disconnect).toHaveBeenCalledTimes(1); + expect(nativeAppService.state).toBe(NativeAppState.DISCONNECTED); + }); + + it("deserializes native error responses", async () => { + const nativeAppService = new NativeAppService(); + const connected = nativeAppService.connect(); + emitHandshake(port); + await connected; + + const request: NativeRequest = { + command: "quit", + arguments: {}, + }; + + const sent = nativeAppService.send(request, 1000, new Error("timeout")); + port.emitMessage({ + error: { + code: ErrorCode.ERR_WEBEID_NATIVE_FATAL, + message: "Technical error, see application logs", + }, + }); + + await expect(sent).rejects.toMatchObject({ + code: ErrorCode.ERR_WEBEID_NATIVE_FATAL, + message: "Technical error, see application logs", + name: "NativeFatalError", + }); + expect(port.disconnect).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalled(); + }); + + it("rejects and closes the port when a command times out", async () => { + jest.useFakeTimers(); + + const nativeAppService = new NativeAppService(); + const connected = nativeAppService.connect(); + emitHandshake(port); + await connected; + + const timeoutError = new NativeUnavailableError("native app did not answer"); + const sent = nativeAppService.send( + { command: "quit", arguments: {} }, + 250, + timeoutError + ); + + jest.advanceTimersByTime(250); + + await expect(sent).rejects.toBe(timeoutError); + expect(port.disconnect).toHaveBeenCalledTimes(1); + }); + + it("rejects without posting when the native command exceeds the message size limit", async () => { + const nativeAppService = new NativeAppService(); + const connected = nativeAppService.connect(); + emitHandshake(port); + await connected; + + const request: NativeRequest = { + command: "sign", + arguments: { + certificate: "certificate", + hash: "x".repeat(config.NATIVE_MESSAGE_MAX_BYTES), + hashFunction: "SHA-384", + origin: "https://example.com", + }, + }; + + await expect(nativeAppService.send(request, 1000, new Error("timeout"))).rejects.toThrow( + `native application message exceeded ${config.NATIVE_MESSAGE_MAX_BYTES} bytes` + ); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(port.disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/content/TokenSigning/injectPageScript.ts b/src/content/TokenSigning/injectPageScript.ts index 92d77ee..9f8086f 100644 --- a/src/content/TokenSigning/injectPageScript.ts +++ b/src/content/TokenSigning/injectPageScript.ts @@ -49,10 +49,10 @@ export default function injectPageScript(): void { s.setAttribute("data-by", "Web-eID extension"); } - if (browser.runtime.getManifest()["manifest_version"] >= 3) { + if (browser.runtime.getManifest().manifest_version >= 3) { s.src = browser.runtime.getURL("token-signing-page-script.js"); } else { - s.innerHTML = "(" + pageScript + ")();"; + s.innerHTML = `(${pageScript.toString()})();`; } (document.head || document.documentElement).appendChild(s); diff --git a/src/content/content.ts b/src/content/content.ts index 5ef03e3..e3b9e9f 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -23,38 +23,71 @@ import Action from "@web-eid.js/models/Action"; import ContextInsecureError from "@web-eid.js/errors/ContextInsecureError"; +import { isRecord } from "../shared/utils/typeGuards"; + import { TokenSigningErrorResponse } from "../models/TokenSigning/TokenSigningResponse"; + import config from "../config"; import injectPageScript from "./TokenSigning/injectPageScript"; import tokenSigningResponse from "../shared/tokenSigningResponse"; -function isWebeidEvent(event: MessageEvent): boolean { +type WebeidPageMessage = Record & { + action: string; +}; + +type TokenSigningPageMessage = Record & { + nonce: string; + type: "VERSION" | "CERT" | "SIGN"; +}; + +type RuntimeResponseWithWarnings = object & { + warnings?: Array; +}; + +const WEBEID_ACTION = { + AUTHENTICATE: Action.AUTHENTICATE as string, + GET_SIGNING_CERTIFICATE: Action.GET_SIGNING_CERTIFICATE as string, + SIGN: Action.SIGN as string, + STATUS: Action.STATUS as string, + WARNING: Action.WARNING as string, +}; + +function isWebeidEvent(event: MessageEvent): event is MessageEvent { + const data = event.data; + return ( event.source === window && - event.data?.action?.startsWith?.("web-eid:") + isRecord(data) && + typeof data.action === "string" && + data.action.startsWith("web-eid:") ); } -function isTokenSigningEvent(event: MessageEvent): boolean { +function isTokenSigningEvent(event: MessageEvent): event is MessageEvent { + const data = event.data; + return ( event.source === window && - event.data.nonce && - ["VERSION", "CERT", "SIGN"].includes(event.data.type) + isRecord(data) && + typeof data.nonce === "string" && + (data.type === "VERSION" || data.type === "CERT" || data.type === "SIGN") ); } async function send(message: object): Promise { - const response = await browser.runtime.sendMessage(message); + const response = await browser.runtime.sendMessage(message); return response; } -window.addEventListener("message", async (event) => { +async function handleMessage(event: MessageEvent): Promise { if (isWebeidEvent(event)) { // Warning messages should be ignored. // When there are deprecation warnings, these messages would be sent by the content script and handled by the Web-eID library. - if (event.data.action === Action.WARNING) return; + if (event.data.action === WEBEID_ACTION.WARNING) return; - config.DEBUG && console.log("Web-eID event: ", JSON.stringify(event)); + if (config.DEBUG) { + console.log("Web-eID event: ", JSON.stringify(event)); + } if (!window.isSecureContext) { const response = { @@ -68,25 +101,25 @@ window.addEventListener("message", async (event) => { let response; switch (event.data.action) { - case Action.STATUS: { + case WEBEID_ACTION.STATUS: { window.postMessage({ action: Action.STATUS_ACK }, event.origin); response = await send(event.data); break; } - case Action.AUTHENTICATE: { + case WEBEID_ACTION.AUTHENTICATE: { window.postMessage({ action: Action.AUTHENTICATE_ACK }, event.origin); response = await send(event.data); break; } - case Action.SIGN: { + case WEBEID_ACTION.SIGN: { window.postMessage({ action: Action.SIGN_ACK }, event.origin); response = await send(event.data); break; } - case Action.GET_SIGNING_CERTIFICATE: { + case WEBEID_ACTION.GET_SIGNING_CERTIFICATE: { window.postMessage({ action: Action.GET_SIGNING_CERTIFICATE_ACK }, event.origin); response = await send(event.data); break; @@ -98,7 +131,9 @@ window.addEventListener("message", async (event) => { } } } else if (config.TOKEN_SIGNING_BACKWARDS_COMPATIBILITY && isTokenSigningEvent(event)) { - config.DEBUG && console.log("TokenSigning event:", JSON.stringify(event)); + if (config.DEBUG) { + console.log("TokenSigning event:", JSON.stringify(event)); + } if (!window.isSecureContext) { console.error(new ContextInsecureError()); @@ -108,13 +143,17 @@ window.addEventListener("message", async (event) => { window.postMessage(response, event.origin); } else { - const response = await send(event.data) as { warnings?: [], [key: string]: any } | void; + const response = await send(event.data) as RuntimeResponseWithWarnings | void; response?.warnings?.forEach((warning) => console.warn(warning)); window.postMessage(response, event.origin); } } +} + +window.addEventListener("message", (event: MessageEvent) => { + void handleMessage(event); }); // --[ chrome-token-signing backwards compatibility ]--------------------------- diff --git a/src/models/Browser.ts b/src/models/Browser.ts index 0f72d6f..957226b 100644 --- a/src/models/Browser.ts +++ b/src/models/Browser.ts @@ -26,7 +26,13 @@ import Tabs from "./Browser/Tabs"; declare global { const browser: Browser; - const chrome: any; + const chrome: Chrome; +} + +interface Chrome { + runtime?: { + lastError?: Error; + }; } export default interface Browser { diff --git a/src/models/Browser/Runtime.ts b/src/models/Browser/Runtime.ts index 9047a9b..6bdc4cc 100644 --- a/src/models/Browser/Runtime.ts +++ b/src/models/Browser/Runtime.ts @@ -72,11 +72,11 @@ export default interface Runtime { * Otherwise it will be fulfilled with no arguments. If an error occurs while connecting to * the native application, the promise will be rejected with an error message. */ - sendNativeMessage: ( + sendNativeMessage: ( application: string, message: object, - callback?: (message: any) => void - ) => Promise; + callback?: (message: TResponse) => void + ) => Promise; /** * Sends a single message to event listeners within your extension or a different extension. @@ -98,13 +98,13 @@ export default interface Runtime { * @param options.toProxyScript Must be `true` if the message is intended for * a PAC file loaded using the proxy API. */ - sendMessage: ( + sendMessage: ( message: object, options?: { includeTlsChannelId?: boolean; toProxyScript?: boolean; } - ) => Promise; + ) => Promise; connectNative: (application: string) => Port; @@ -119,7 +119,11 @@ export default interface Runtime { /** * Get the complete manifest.json file, deserialized from JSON to an object. */ - getManifest: () => any; + getManifest: () => Manifest; +} + +export interface Manifest { + manifest_version: number; } export interface Port { @@ -131,12 +135,12 @@ export interface Port { removeListener: (listener: () => void) => void; }; onMessage: { - addListener: (listener: (message: any) => void) => void; - removeListener: (listener: (message: any) => void) => void; + addListener: (listener: (message: TMessage) => void) => void; + removeListener: (listener: (message: TMessage) => void) => void; }; postMessage: (message: object) => void; - sender?: any; + sender?: MessageSender; } export type OnInstallReason = "install" | "update" | "chrome_update" | "shared_module_update"; @@ -148,8 +152,9 @@ export interface OnInstalledDetails { temporary: boolean; } -export type OnInstalledCallback = (details: OnInstalledDetails, sender: MessageSender, sendResponse?: any) => Promise | void | boolean; -export type OnMessageCallback = (message: any, sender: MessageSender, sendResponse?: any) => Promise | void | boolean; +export type SendResponse = (response?: unknown) => void; +export type OnInstalledCallback = (details: OnInstalledDetails, sender: MessageSender, sendResponse?: SendResponse) => Promise | void | boolean; +export type OnMessageCallback = (message: unknown, sender: MessageSender, sendResponse?: SendResponse) => Promise | void | boolean; export interface MessageSender { /** diff --git a/src/models/Browser/Tabs.ts b/src/models/Browser/Tabs.ts index beab51d..cb5be92 100644 --- a/src/models/Browser/Tabs.ts +++ b/src/models/Browser/Tabs.ts @@ -20,7 +20,7 @@ * SOFTWARE. */ -export type CreateProperties = { +export interface CreateProperties { active?: boolean, index?: number, openerTabId?: number, @@ -28,7 +28,7 @@ export type CreateProperties = { selected?: boolean, url?: string, windowId?: number, -}; +} export type CreateCallback = (tab: object /* Tab */) => void; diff --git a/src/models/TokenSigning/TokenSigningPromise.ts b/src/models/TokenSigning/TokenSigningPromise.ts index 191d4d9..47aefe1 100644 --- a/src/models/TokenSigning/TokenSigningPromise.ts +++ b/src/models/TokenSigning/TokenSigningPromise.ts @@ -20,7 +20,7 @@ * SOFTWARE. */ -export default interface TokenSigningPromise { - resolve: (value?: any) => void; - reject: (reason?: any) => void; +export default interface TokenSigningPromise { + resolve: (value: T) => void; + reject: (reason?: unknown) => void; } diff --git a/src/models/TokenSigning/TokenSigningResponse.ts b/src/models/TokenSigning/TokenSigningResponse.ts index 6aa6b22..68186f2 100644 --- a/src/models/TokenSigning/TokenSigningResponse.ts +++ b/src/models/TokenSigning/TokenSigningResponse.ts @@ -20,6 +20,8 @@ * SOFTWARE. */ +import { SerializedError } from "@web-eid.js/errors/SerializedError"; + type TokenSigningError = "no_certificates" | "invalid_argument" @@ -61,5 +63,6 @@ export interface TokenSigningErrorResponse extends TokenSigningResponse { result: TokenSigningError; // Exception details object, provided by Web-eID extension - nativeException?: any; + nativeException?: SerializedError; + error?: SerializedError; } diff --git a/src/shared/ByteArray.ts b/src/shared/ByteArray.ts index c9b137d..2c665cd 100644 --- a/src/shared/ByteArray.ts +++ b/src/shared/ByteArray.ts @@ -37,7 +37,7 @@ export default class ByteArray { } constructor(byteArray?: Array) { - this.data = byteArray || []; + this.data = byteArray ?? []; } fromBase64(base64: string): ByteArray { diff --git a/src/shared/HwcryptoPatcher.ts b/src/shared/HwcryptoPatcher.ts index 7063241..5eaaa5e 100644 --- a/src/shared/HwcryptoPatcher.ts +++ b/src/shared/HwcryptoPatcher.ts @@ -1,23 +1,27 @@ -interface Hwcrypto { - [key: string]: any; +import { isRecord } from "./utils/typeGuards"; + +type HwcryptoFunction = (this: Hwcrypto, ...args: Array) => Promise; +type PatchableHwcryptoFunctionName = "debug" | "sign" | "getCertificate"; +const PATCHABLE_HWCRYPTO_FUNCTIONS: Array = ["debug", "sign", "getCertificate"]; - use: (backend?: "chrome") => Promise; - sign: (cert: string, hash: string, options: any) => Promise; - debug: () => Promise; - getCertificate: (options: any) => Promise; +interface Hwcrypto { + use: (backend?: "chrome") => Promise; + sign: HwcryptoFunction; + debug: HwcryptoFunction; + getCertificate: HwcryptoFunction; } let isPatched = false; let isRetried = false; -const patchHwcryptoFunction = (hwc: Hwcrypto) => (fnName: string) => { +const patchHwcryptoFunction = (hwc: Hwcrypto) => (fnName: PatchableHwcryptoFunctionName): void => { const originalFn = hwc[fnName]; - hwc[fnName] = async function(...args: Array): Promise { + hwc[fnName] = async function(this: Hwcrypto, ...args: Array): Promise { try { return await originalFn.apply(this, args); } catch (error) { - const isNoImpl = (error as Error)?.message === "no_implementation"; + const isNoImpl = error instanceof Error && error.message === "no_implementation"; if (isNoImpl && !isRetried) { isRetried = true; @@ -35,12 +39,22 @@ const patchHwcryptoFunction = (hwc: Hwcrypto) => (fnName: string) => { }; }; +function isHwcrypto(value: unknown): value is Hwcrypto { + return ( + isRecord(value) && + typeof value.use === "function" && + typeof value.sign === "function" && + typeof value.debug === "function" && + typeof value.getCertificate === "function" + ); +} + export default function patchHwcrypto(): void { - const hwc = (globalThis as any)?.hwcrypto; + const hwc = (globalThis as { hwcrypto?: unknown }).hwcrypto; - if (!hwc || isPatched) return; + if (!isHwcrypto(hwc) || isPatched) return; - ["debug", "sign", "getCertificate"].forEach(patchHwcryptoFunction(hwc)); + PATCHABLE_HWCRYPTO_FUNCTIONS.forEach(patchHwcryptoFunction(hwc)); isPatched = true; } diff --git a/src/shared/Mutex.ts b/src/shared/Mutex.ts index 4c4acf6..7d1112b 100644 --- a/src/shared/Mutex.ts +++ b/src/shared/Mutex.ts @@ -21,12 +21,12 @@ */ export default class Mutex { - private lock: Promise = Promise.resolve(); + private lock: Promise = Promise.resolve(); async acquire(): Promise<() => void> { - let release: (value?: unknown) => void; + let release: () => void; - const newLock = new Promise((resolve) => release = resolve); + const newLock = new Promise((resolve) => release = resolve); const oldLock = this.lock; this.lock = newLock; diff --git a/src/shared/TokenSigningPageScript.ts b/src/shared/TokenSigningPageScript.ts index 6744767..1661709 100644 --- a/src/shared/TokenSigningPageScript.ts +++ b/src/shared/TokenSigningPageScript.ts @@ -20,16 +20,18 @@ * SOFTWARE. */ -import { - TokenSigningCertResponse, - TokenSigningResponse, -} from "../models/TokenSigning/TokenSigningResponse"; import { TokenSigningGetCertificateMessage, TokenSigningMessage, TokenSigningSignMessage, TokenSigningVersionMessage, } from "../models/TokenSigning/TokenSigningMessage"; +import { + TokenSigningResponse, + TokenSigningResult, +} from "../models/TokenSigning/TokenSigningResponse"; + +import { isRecord } from "./utils/typeGuards"; import TokenSigningPromise from "../models/TokenSigning/TokenSigningPromise"; @@ -39,43 +41,84 @@ declare global { } } +interface TokenSigningHexResult { + hex: string; +} + +type TokenSigningLegacyResult = TokenSigningHexResult | string; +type TokenSigningPageResponse = + & TokenSigningResponse + & { + cert?: unknown; + signature?: unknown; + version?: unknown; + }; + +const TOKEN_SIGNING_RESULTS = new Set([ + "ok", + "no_certificates", + "invalid_argument", + "user_cancel", + "not_allowed", + "driver_error", + "pin_blocked", + "technical_error", +]); + +function isTokenSigningResult(result: unknown): result is TokenSigningResult { + return typeof result === "string" && TOKEN_SIGNING_RESULTS.has(result); +} + +function isTokenSigningPageResponse(data: unknown): data is TokenSigningPageResponse { + return ( + isRecord(data) && + data.src === "background.js" && + typeof data.nonce === "string" && + typeof data.extension === "string" && + data.isWebeid === true && + isTokenSigningResult(data.result) + ); +} + export default function pageScript(): void { let hasDeprecationWarningDisplayed = false; - const eidPromises: Record = {}; + const eidPromises: Record> = {}; // Turn the incoming message from extension // into pending Promise resolving - window.addEventListener("message", function (event) { + window.addEventListener("message", function (event: MessageEvent) { if (event.source !== window) return; - if (event.data.src && (event.data.src === "background.js")) { + if (isTokenSigningPageResponse(event.data)) { console.log("Page received: "); console.log(event.data); // Get the promise - if (event.data.nonce) { - const p = eidPromises[event.data.nonce]; - - if (event.data.result === "ok") { - if (event.data.signature !== undefined) { - p.resolve({ hex: event.data.signature }); - } else if (event.data.version !== undefined) { - p.resolve(event.data.extension + "/" + event.data.version); - } else if (event.data.cert !== undefined) { - p.resolve({ hex: event.data.cert }); - } else { - console.log("No idea how to handle message"); - console.log(event.data); - } + const p = eidPromises[event.data.nonce]; + + if (!p) { + console.log("No promise for event msg"); + console.log(event.data); + return; + } + + if (event.data.result === "ok") { + if (typeof event.data.signature === "string") { + p.resolve({ hex: event.data.signature }); + } else if (typeof event.data.version === "string") { + p.resolve(event.data.extension + "/" + event.data.version); + } else if (typeof event.data.cert === "string") { + p.resolve({ hex: event.data.cert }); } else { - p.reject(new Error(event.data.result)); + console.log("No idea how to handle message"); + console.log(event.data); } - - delete eidPromises[event.data.nonce]; } else { - console.log("No nonce in event msg"); + p.reject(new Error(event.data.result)); } + + delete eidPromises[event.data.nonce]; } }, false); @@ -86,8 +129,8 @@ export default function pageScript(): void { return val; } - function messagePromise( - msg: TMessage + function messagePromise( + msg: TokenSigningMessage ): Promise { if (!hasDeprecationWarningDisplayed) { console.warn("TokenSigning API is deprecated. Please consider switching to the new Web-eID library."); @@ -98,12 +141,15 @@ export default function pageScript(): void { // send message window.postMessage(msg, "*"); // and store promise callbacks - eidPromises[msg.nonce] = { resolve, reject }; + eidPromises[msg.nonce] = { + reject, + resolve: (value) => resolve(value as TResponse), + }; }); } window.TokenSigning = class TokenSigning { - getCertificate(options: { lang?: string; filter?: "AUTH" | "SIGN" }): Promise { + getCertificate(options: { lang?: string; filter?: "AUTH" | "SIGN" }): Promise { const msg: TokenSigningGetCertificateMessage = { src: "page.js", nonce: nonce(), @@ -120,7 +166,7 @@ export default function pageScript(): void { cert: { hex: string }, hash: { type: string; hex: string }, options: { lang: string; info: string } - ): Promise { + ): Promise { const msg: TokenSigningSignMessage = { src: "page.js", nonce: nonce(), @@ -136,7 +182,7 @@ export default function pageScript(): void { return messagePromise(msg); } - getVersion(): Promise { + getVersion(): Promise { const msg: TokenSigningVersionMessage = { src: "page.js", nonce: nonce(), diff --git a/src/shared/actionErrorHandler.ts b/src/shared/actionErrorHandler.ts index c9c2ff0..39514ca 100644 --- a/src/shared/actionErrorHandler.ts +++ b/src/shared/actionErrorHandler.ts @@ -20,11 +20,14 @@ * SOFTWARE. */ +import { ExtensionFailureResponse } from "@web-eid.js/models/message/ExtensionResponse"; +import { serializeError } from "@web-eid.js/utils/errorSerializer"; + import Action from "@web-eid.js/models/Action"; import ErrorCode from "@web-eid.js/errors/ErrorCode"; -import { ExtensionFailureResponse } from "@web-eid.js/models/message/ExtensionResponse"; import VersionMismatchError from "@web-eid.js/errors/VersionMismatchError"; -import { serializeError } from "@web-eid.js/utils/errorSerializer"; + +import { getErrorCode, toError } from "./utils/error"; import checkCompatibility from "./utils/checkCompatibility"; import config from "../config"; @@ -35,11 +38,12 @@ export default function actionErrorHandler( | Action.GET_SIGNING_CERTIFICATE_FAILURE | Action.SIGN_FAILURE, - originalError: any, + originalError: unknown, libraryVersion: string, nativeAppVersion?: string, ): ExtensionFailureResponse { - let error; + let error = toError(originalError); + const errorCode = getErrorCode(originalError); /** * Always show the original error when native app version is unavailable. @@ -50,8 +54,8 @@ export default function actionErrorHandler( * Always show the original error when the error code is ERR_WEBEID_USER_CANCELLED. * In this case the user cancelled the operation in native app UI, which means the native app API had to be compatible for this action. */ - if (!nativeAppVersion || originalError?.code === ErrorCode.ERR_WEBEID_USER_CANCELLED) { - error = originalError; + if (!nativeAppVersion || errorCode === ErrorCode.ERR_WEBEID_USER_CANCELLED) { + error = toError(originalError); } else { const versions = { @@ -65,7 +69,7 @@ export default function actionErrorHandler( error = ( (requiresUpdate.extension || requiresUpdate.nativeApp) ? new VersionMismatchError(undefined, versions, requiresUpdate) - : originalError + : toError(originalError) ); } diff --git a/src/shared/tokenSigningResponse.ts b/src/shared/tokenSigningResponse.ts index 2f7cbca..c21101a 100644 --- a/src/shared/tokenSigningResponse.ts +++ b/src/shared/tokenSigningResponse.ts @@ -39,7 +39,7 @@ import config from "../config"; export default function tokenSigningResponse( result: TokenSigningResult, nonce: string, - optional?: Record + optional?: Record ): T { const response = { nonce, diff --git a/src/shared/utils/calculateJsonSize.ts b/src/shared/utils/calculateJsonSize.ts index f1e849f..c1a206a 100644 --- a/src/shared/utils/calculateJsonSize.ts +++ b/src/shared/utils/calculateJsonSize.ts @@ -27,8 +27,10 @@ * * @returns Size in bytes */ -export default function calculateJsonSize(object: any): number { +export default function calculateJsonSize(object: unknown): number { const objectString = JSON.stringify(object); + if (objectString === undefined) return 0; + const objectStringBlob = new Blob([objectString]); return objectStringBlob.size; diff --git a/src/shared/utils/error.ts b/src/shared/utils/error.ts new file mode 100644 index 0000000..ad4df56 --- /dev/null +++ b/src/shared/utils/error.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020-2026 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import ErrorCode from "@web-eid.js/errors/ErrorCode"; +import UnknownError from "@web-eid.js/errors/UnknownError"; + +import { isRecord } from "./typeGuards"; + +export type ErrorWithMetadata = Error & { + code?: ErrorCode | string; + extension?: string; +}; + +export function getErrorCode(error: unknown): ErrorCode | string | undefined { + if (!isRecord(error) || typeof error.code !== "string") { + return undefined; + } + + return error.code; +} + +export function toError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + const fallback = new UnknownError( + typeof error === "string" ? error : undefined + ); + + if (isRecord(error)) { + Object.entries(error).forEach(([key, value]) => { + Reflect.set(fallback, key, value); + }); + } + + return fallback; +} + +export function withExtensionVersion(error: unknown, extensionVersion: string): ErrorWithMetadata { + const errorWithMetadata = toError(error) as ErrorWithMetadata; + errorWithMetadata.extension = extensionVersion; + + return errorWithMetadata; +} diff --git a/src/shared/utils/semver.ts b/src/shared/utils/semver.ts index d09cc1e..7047553 100644 --- a/src/shared/utils/semver.ts +++ b/src/shared/utils/semver.ts @@ -52,7 +52,8 @@ interface SemverDiff { } export function parseSemver(string = ""): Semver { - const result = string.match(semverPattern); + // const result = string.match(semverPattern);TODO: verify + const result = semverPattern.exec(string); const [, majorStr, minorStr, patchStr, rc, build] = result ? result : []; diff --git a/src/shared/utils/typeGuards.ts b/src/shared/utils/typeGuards.ts new file mode 100644 index 0000000..e0eede8 --- /dev/null +++ b/src/shared/utils/typeGuards.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020-2026 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +}