diff --git a/LICENSE b/LICENSE index 03b6ac6..a7252de 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020-2024 Estonian Information System Authority +Copyright (c) 2020-2025 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 diff --git a/README.md b/README.md index 6b6fb05..e1b36c2 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,6 @@ The Web eID extension for Safari is built as a [Safari web extension](https://de TOKEN_SIGNING_BACKWARDS_COMPATIBILITY=true npm run clean build package ``` - During development, for additional logging, set the `DEBUG` environment variable to `true`. - ```bash - DEBUG=true npm run clean build package - ``` - 5. Load in Firefox as a Temporary Extension 1. Open `about:debugging#/runtime/this-firefox` 2. Click "Load temporary Add-on..." and open `/web-eid-webextension/dist/manifest.json` @@ -62,3 +57,33 @@ The Web eID extension for Safari is built as a [Safari web extension](https://de Make sure the `NATIVE_APP_NAME` value in `src/config.ts` matches the one in the Web-eID native application manifest file. +### Developer tools + +The Web eID DevTools tab can be useful while integrating Web eID on a website. + +#### Features +- Event history between the website, extension and native application. +- Extension's internal log messages. +- Option to override extension settings, without needing to compile the extension yourself. +- Option to allow the http://localhost origin when authenticating and signing + +#### Enable or disable the Web eID developer tools tab + +**Firefox** +1. Open [Menu -> Settings -> Extensions and themes] + or navigate to the address `about:addons` +2. Open the Web eID extension details +3. Open the "Permissions" tab +4. Toggle "Extend developer tools to access your data in open tabs" + +**Chrome** +1. Open [Settings -> Extensions] + or navigate to the address `chrome://extensions/` +2. Open the Web eID extension details +3. Open "Extension options" +4. Toggle "Enable developer tools" + +**Safari** +1. Open [Settings -> Extensions] +2. Open the Web eID Settings +3. Toggle "Enable developer tools" diff --git a/lib/web-eid.js b/lib/web-eid.js index 4138a66..3b46b55 160000 --- a/lib/web-eid.js +++ b/lib/web-eid.js @@ -1 +1 @@ -Subproject commit 4138a66e5ba0596bfe9a76d2b75aebe13c7a3216 +Subproject commit 3b46b55051a0e1c4d79b86d0f682e8f04f226baf diff --git a/package-lock.json b/package-lock.json index 78a610e..cbb8c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "web-eid-webextension", - "version": "2.4.1", + "version": "2.4.2-rc1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web-eid-webextension", - "version": "2.4.1", + "version": "2.4.2-rc1", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.1", diff --git a/package.json b/package.json index 07c8d40..5a8bb1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-eid-webextension", - "version": "2.4.1", + "version": "2.4.2-rc1", "description": "", "main": "src/index.js", "scripts": { diff --git a/rollup.config.mjs b/rollup.config.mjs index 893ddc4..4173daf 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -8,13 +8,10 @@ import license from "rollup-plugin-license"; import polyfill from "rollup-plugin-polyfill"; import resolve from "@rollup/plugin-node-resolve"; -const projectRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url))); - // List of browsers to build for. const browsers = ["chrome", "firefox", "safari"]; const processEnvConf = { - DEBUG: process.env.DEBUG, TOKEN_SIGNING_BACKWARDS_COMPATIBILITY: process.env.TOKEN_SIGNING_BACKWARDS_COMPATIBILITY, } @@ -22,7 +19,7 @@ const pluginsConf = (environment) => [ alias({ entries: [{ find: "@web-eid.js", - replacement: path.resolve(projectRootDir, "dist/lib/web-eid.js/src"), + replacement: path.resolve(import.meta.dirname, "dist/lib/web-eid.js/src"), }], }), resolve({ rootDir: "./dist" }), @@ -30,7 +27,7 @@ const pluginsConf = (environment) => [ license({ banner: { content: { - file: path.join(projectRootDir, "LICENSE"), + file: path.join(import.meta.dirname, "LICENSE"), encoding: "utf-8", }, }, diff --git a/scripts/build.mjs b/scripts/build.mjs index d2953b5..088399b 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -111,10 +111,24 @@ const targets = { await cp("./static/icons", "./dist/safari"); rem( - "Copying static consent pages to Firefox dist directory" + "Copying static pages to Firefox dist directory" ); await cp("./static/_locales", "./dist/firefox/_locales"); await cp("./static/views", "./dist/firefox/views"); + await rm("./dist/firefox/views/options.*"); + await cp("./node_modules/webextension-polyfill/dist/browser-polyfill.min.js", "./dist/firefox/views/browser-polyfill.min.js"); + + rem( + "Copying static pages to Chrome dist directory" + ); + await cp("./static/views", "./dist/chrome/views"); + await cp("./node_modules/webextension-polyfill/dist/browser-polyfill.min.js", "./dist/chrome/views/browser-polyfill.min.js"); + + rem( + "Copying static pages to Safari dist directory" + ); + await cp("./static/views", "./dist/safari/views"); + await cp("./node_modules/webextension-polyfill/dist/browser-polyfill.min.js", "./dist/safari/views/browser-polyfill.min.js"); rem( "Setting up the Firefox manifest" diff --git a/src/background-firefox/consent.ts b/src/background-firefox/consent.ts index ecd09c1..8ea9327 100644 --- a/src/background-firefox/consent.ts +++ b/src/background-firefox/consent.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/background-safari/services/NativeAppService.ts b/src/background-safari/services/NativeAppService.ts index 1f5adf5..2cbf4dc 100644 --- a/src/background-safari/services/NativeAppService.ts +++ b/src/background-safari/services/NativeAppService.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -27,7 +27,11 @@ import libraryConfig from "@web-eid.js/config"; import Mutex from "../../shared/Mutex"; import calculateJsonSize from "../../shared/utils/calculateJsonSize"; -import config from "../../config"; +import { config } from "../../shared/configManager"; + +import Logger from "../../shared/Logger"; + +const logger = new Logger("NativeAppService.ts"); type NativeAppPendingRequest = | { resolve?: (value: PromiseLike) => void; reject?: (reason?: any) => void } @@ -47,7 +51,13 @@ export default class NativeAppService { private pending: NativeAppPendingRequest = null; + constructor(tabId?: number) { + logger.tabId = tabId; + } + async connect(): Promise<{ version: string }> { + logger.log("Connecting to the native application " + config.NATIVE_APP_NAME); + this.state = NativeAppState.CONNECTING; try { @@ -71,6 +81,8 @@ export default class NativeAppService { if (message.version) { this.state = NativeAppState.CONNECTED; + logger.info("Connected to the native application", message); + return message; } @@ -91,7 +103,8 @@ export default class NativeAppService { } close(error?: any): void { - config.DEBUG && console.log("Disconnecting from native app"); + logger.info("Closing connection to native application"); + this.state = NativeAppState.DISCONNECTED; this.pending?.reject?.(error); @@ -111,6 +124,8 @@ export default class NativeAppService { setTimeout(() => { reject(throwAfterTimeout); }, timeout ); const onResponse = (message: any): void => { + logger.log("Response from native application", message); + this.pending = null; if (message.error) { @@ -120,13 +135,14 @@ export default class NativeAppService { } }; - config.DEBUG && console.log("Sending message to native app", JSON.stringify(message)); - const messageSize = calculateJsonSize(message); + logger.info("Calculated message size", messageSize); + if (messageSize > config.NATIVE_MESSAGE_MAX_BYTES) { throw new Error(`native application message exceeded ${config.NATIVE_MESSAGE_MAX_BYTES} bytes`); } + logger.log("Sending message to native application", message); browser.runtime.sendNativeMessage("application.id", message, onResponse); }); diff --git a/src/background/actions/TokenSigning/errorToResponse.ts b/src/background/actions/TokenSigning/errorToResponse.ts index fdf871b..03c9bb8 100644 --- a/src/background/actions/TokenSigning/errorToResponse.ts +++ b/src/background/actions/TokenSigning/errorToResponse.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/background/actions/TokenSigning/getCertificate.ts b/src/background/actions/TokenSigning/getCertificate.ts index c350512..1d04cb3 100644 --- a/src/background/actions/TokenSigning/getCertificate.ts +++ b/src/background/actions/TokenSigning/getCertificate.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -30,27 +30,42 @@ import { } from "../../../models/TokenSigning/TokenSigningResponse"; import ByteArray from "../../../shared/ByteArray"; +import { MessageSender } from "../../../models/Browser/Runtime"; import NativeAppService from "../../services/NativeAppService"; -import config from "../../../config"; +import { config } from "../../../shared/configManager"; import errorToResponse from "./errorToResponse"; import threeLetterLanguageCodes from "./threeLetterLanguageCodes"; import tokenSigningResponse from "../../../shared/tokenSigningResponse"; +import Logger from "../../../shared/Logger"; + +const logger = new Logger("TokenSigning/getCertificate.ts"); + export default async function getCertificate( + sender: MessageSender, nonce: string, sourceUrl: string, lang?: string, filter: "AUTH" | "SIGN" = "SIGN", ): Promise { + logger.tabId = sender.tab?.id; + + logger.log("Certificate requested"); + if (lang && Object.keys(threeLetterLanguageCodes).includes(lang)) { lang = threeLetterLanguageCodes[lang]; + logger.log("Language code converted to three-letter code", lang); } - const nativeAppService = new NativeAppService(); + const nativeAppService = new NativeAppService(sender.tab?.id); + + logger.info("Checking 'filter', should be 'SIGN'"); if (filter !== "SIGN") { const { message, name, stack } = new Error("Web-eID only allows signing with a signing certificate"); + logger.error({ message, name, stack }); + return tokenSigningResponse("not_allowed", nonce, { message, name, @@ -59,9 +74,7 @@ export default async function getCertificate( } try { - const nativeAppStatus = await nativeAppService.connect(); - - config.DEBUG && console.log("Get certificate: connected to native", nativeAppStatus); + await nativeAppService.connect(); const message: NativeGetSigningCertificateRequest = { command: "get-signing-certificate", @@ -80,14 +93,20 @@ export default async function getCertificate( ); if (!response?.certificate) { + logger.info("Certificate request failed. Expected 'certificate' was not found in the response"); + return tokenSigningResponse("no_certificates", nonce); } else { + logger.info("Returning success response"); + return tokenSigningResponse("ok", nonce, { cert: new ByteArray().fromBase64(response.certificate).toHex(), }); } } catch (error) { - console.error(error); + logger.info("Certificate request failed"); + logger.error(error); + return errorToResponse(nonce, error); } finally { nativeAppService.close(); diff --git a/src/background/actions/TokenSigning/index.ts b/src/background/actions/TokenSigning/index.ts index 7f69020..8c2673b 100644 --- a/src/background/actions/TokenSigning/index.ts +++ b/src/background/actions/TokenSigning/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/background/actions/TokenSigning/sign.ts b/src/background/actions/TokenSigning/sign.ts index 713d030..783ff5a 100644 --- a/src/background/actions/TokenSigning/sign.ts +++ b/src/background/actions/TokenSigning/sign.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -30,12 +30,17 @@ import { } from "../../../models/TokenSigning/TokenSigningResponse"; import ByteArray from "../../../shared/ByteArray"; +import { MessageSender } from "../../../models/Browser/Runtime"; import NativeAppService from "../../services/NativeAppService"; -import config from "../../../config"; +import { config } from "../../../shared/configManager"; import errorToResponse from "./errorToResponse"; import threeLetterLanguageCodes from "./threeLetterLanguageCodes"; import tokenSigningResponse from "../../../shared/tokenSigningResponse"; +import Logger from "../../../shared/Logger"; + +const logger = new Logger("TokenSigning/sign.ts"); + const digestCommandToHashFunction = { "sha224": "SHA-224", @@ -60,6 +65,7 @@ const hashFunctionToLength = { } as Record; export default async function sign( + sender: MessageSender, nonce: string, sourceUrl: string, certificate: string, @@ -67,17 +73,21 @@ export default async function sign( algorithm: string, lang?: string, ): Promise { + logger.tabId = sender.tab?.id; + + logger.log("Signing requested"); + if (lang && Object.keys(threeLetterLanguageCodes).includes(lang)) { lang = threeLetterLanguageCodes[lang]; + logger.info("Language code converted to three-letter code", lang); } - const nativeAppService = new NativeAppService(); + const nativeAppService = new NativeAppService(sender.tab?.id); try { const warnings: Array = []; - const nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Sign: connected to native", nativeAppStatus); + await nativeAppService.connect(); let hashFunction = ( Object.keys(digestCommandToHashFunction).includes(algorithm) @@ -85,12 +95,16 @@ export default async function sign( : algorithm ); + logger.debug("Selected hashing function", hashFunction); + const expectedHashByteLength = ( Object.keys(hashFunctionToLength).includes(hashFunction) ? hashFunctionToLength[hashFunction] : undefined ); + logger.debug("Hash byte length", expectedHashByteLength); + const hashByteArray = new ByteArray().fromHex(hash); if (hashByteArray.length !== expectedHashByteLength) { @@ -113,6 +127,8 @@ export default async function sign( } } + warnings.forEach((message) => logger.warn(message)); + const message: NativeSignRequest = { command: "sign", @@ -134,8 +150,10 @@ export default async function sign( ); if (!response?.signature) { + logger.info("Signing failed. Expected 'signature' was not found in the response"); return tokenSigningResponse("technical_error", nonce); } else { + logger.info("Returning success response"); return tokenSigningResponse("ok", nonce, { signature: new ByteArray().fromBase64(response.signature).toHex(), @@ -143,7 +161,9 @@ export default async function sign( }); } } catch (error) { - console.error(error); + logger.info("Signing failed"); + logger.error(error); + return errorToResponse(nonce, error); } finally { nativeAppService.close(); diff --git a/src/background/actions/TokenSigning/status.ts b/src/background/actions/TokenSigning/status.ts index fe03b09..4d1990e 100644 --- a/src/background/actions/TokenSigning/status.ts +++ b/src/background/actions/TokenSigning/status.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -27,16 +27,26 @@ import { TokenSigningStatusResponse, } from "../../../models/TokenSigning/TokenSigningResponse"; +import { MessageSender } from "../../../models/Browser/Runtime"; import NativeAppService from "../../services/NativeAppService"; -import config from "../../../config"; +import { config } from "../../../shared/configManager"; import errorToResponse from "./errorToResponse"; import tokenSigningResponse from "../../../shared/tokenSigningResponse"; +import Logger from "../../../shared/Logger"; + +const logger = new Logger("TokenSigning/status.ts"); + export default async function status( + sender: MessageSender, nonce: string, ): Promise { - const nativeAppService = new NativeAppService(); + logger.tabId = sender.tab?.id; + + const nativeAppService = new NativeAppService(sender.tab?.id); + + logger.log("Status requested"); try { const nativeAppStatus = await nativeAppService.connect(); @@ -48,15 +58,21 @@ export default async function status( throw new Error("missing native application version"); } + logger.info("Closing native app by sending the 'quit' command"); + await nativeAppService.send( { command: "quit", arguments: {} }, config.NATIVE_GRACEFUL_DISCONNECT_TIMEOUT, new UnknownError("native application failed to close gracefully"), ); + logger.info("Returning success response"); + return tokenSigningResponse("ok", nonce, { version }); } catch (error) { - console.error(error); + logger.info("Status check failed"); + logger.error(error); + return errorToResponse(nonce, error); } finally { nativeAppService.close(); diff --git a/src/background/actions/TokenSigning/threeLetterLanguageCodes.ts b/src/background/actions/TokenSigning/threeLetterLanguageCodes.ts index d3c67fb..ac7e073 100644 --- a/src/background/actions/TokenSigning/threeLetterLanguageCodes.ts +++ b/src/background/actions/TokenSigning/threeLetterLanguageCodes.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Estonian Information System Authority + * Copyright (c) 2022-2025 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 diff --git a/src/background/actions/authenticate.ts b/src/background/actions/authenticate.ts index c7e2e22..d57065f 100644 --- a/src/background/actions/authenticate.ts +++ b/src/background/actions/authenticate.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -20,19 +20,22 @@ * SOFTWARE. */ +import { ExtensionAuthenticateResponse, ExtensionFailureResponse } from "@web-eid.js/models/message/ExtensionResponse"; import Action from "@web-eid.js/models/Action"; import { NativeAuthenticateRequest } from "@web-eid.js/models/message/NativeRequest"; import { NativeAuthenticateResponse } from "@web-eid.js/models/message/NativeResponse"; import UserTimeoutError from "@web-eid.js/errors/UserTimeoutError"; -import { ExtensionAuthenticateResponse, ExtensionFailureResponse } from "@web-eid.js/models/message/ExtensionResponse"; import { MessageSender } from "../../models/Browser/Runtime"; import NativeAppService from "../services/NativeAppService"; import UnknownError from "@web-eid.js/errors/UnknownError"; import actionErrorHandler from "../../shared/actionErrorHandler"; -import config from "../../config"; import { getSenderUrl } from "../../shared/utils/sender"; +import Logger from "../../shared/Logger"; + +const logger = new Logger("authenticate.ts"); + export default async function authenticate( challengeNonce: string, sender: MessageSender, @@ -40,15 +43,17 @@ export default async function authenticate( userInteractionTimeout: number, lang?: string, ): Promise { + logger.tabId = sender.tab?.id; + + logger.log("Authentication requested"); + let nativeAppService: NativeAppService | undefined; let nativeAppStatus: { version: string } | undefined; try { - nativeAppService = new NativeAppService(); + nativeAppService = new NativeAppService(sender.tab?.id); nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Authenticate: connected to native", nativeAppStatus); - const message: NativeAuthenticateRequest = { command: "authenticate", @@ -67,8 +72,6 @@ export default async function authenticate( new UserTimeoutError(), ); - config.DEBUG && console.log("Authenticate: authentication token received"); - const isResponseValid = ( response?.unverifiedCertificate && response?.algorithm && @@ -78,12 +81,17 @@ export default async function authenticate( ); if (isResponseValid) { + logger.info("Returning success response"); + return { action: Action.AUTHENTICATE_SUCCESS, ...response }; } else { + logger.info("Authentication response is invalid"); + throw new UnknownError("unexpected response from native application"); } } catch (error) { - console.error("Authenticate:", error); + logger.info("Authentication failed"); + logger.error(error); return actionErrorHandler(Action.AUTHENTICATE_FAILURE, error, libraryVersion, nativeAppStatus?.version); } finally { diff --git a/src/background/actions/getSigningCertificate.ts b/src/background/actions/getSigningCertificate.ts index e3620ac..5f8486a 100644 --- a/src/background/actions/getSigningCertificate.ts +++ b/src/background/actions/getSigningCertificate.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -34,24 +34,29 @@ import { import { MessageSender } from "../../models/Browser/Runtime"; import NativeAppService from "../services/NativeAppService"; import actionErrorHandler from "../../shared/actionErrorHandler"; -import config from "../../config"; import { getSenderUrl } from "../../shared/utils/sender"; +import Logger from "../../shared/Logger"; + +const logger = new Logger("getSigningCertificate.ts"); + export default async function getSigningCertificate( sender: MessageSender, libraryVersion: string, userInteractionTimeout: number, lang?: string, ): Promise { + logger.tabId = sender.tab?.id; + + logger.log("Certificate requested"); + let nativeAppService: NativeAppService | undefined; let nativeAppStatus: { version: string } | undefined; try { - nativeAppService = new NativeAppService(); + nativeAppService = new NativeAppService(sender.tab?.id); nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("getSigningCertificate: connected to native", nativeAppStatus); - const message: NativeGetSigningCertificateRequest = { command: "get-signing-certificate", @@ -74,12 +79,17 @@ export default async function getSigningCertificate( ); if (isResponseValid) { + logger.info("Returning success response"); + return { action: Action.GET_SIGNING_CERTIFICATE_SUCCESS, ...response }; } else { + logger.info("Certificate response is invalid"); + throw new UnknownError("unexpected response from native application"); } } catch (error: any) { - console.error("GetSigningCertificate:", error); + logger.info("Certificate request failed"); + logger.error(error); return actionErrorHandler(Action.GET_SIGNING_CERTIFICATE_FAILURE, error, libraryVersion, nativeAppStatus?.version); } finally { diff --git a/src/background/actions/sign.ts b/src/background/actions/sign.ts index 4ad980d..e143bd8 100644 --- a/src/background/actions/sign.ts +++ b/src/background/actions/sign.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -34,9 +34,11 @@ import UserTimeoutError from "@web-eid.js/errors/UserTimeoutError"; import { MessageSender } from "../../models/Browser/Runtime"; import NativeAppService from "../services/NativeAppService"; import actionErrorHandler from "../../shared/actionErrorHandler"; -import config from "../../config"; import { getSenderUrl } from "../../shared/utils/sender"; +import Logger from "../../shared/Logger"; + +const logger = new Logger("sign.ts"); export default async function sign( certificate: string, @@ -47,15 +49,17 @@ export default async function sign( userInteractionTimeout: number, lang?: string, ): Promise { + logger.tabId = sender.tab?.id; + + logger.log("Signing requested"); + let nativeAppService: NativeAppService | undefined; let nativeAppStatus: { version: string } | undefined; try { - nativeAppService = new NativeAppService(); + nativeAppService = new NativeAppService(sender.tab?.id); nativeAppStatus = await nativeAppService.connect(); - config.DEBUG && console.log("Sign: connected to native", nativeAppStatus); - const message: NativeSignRequest = { command: "sign", @@ -84,12 +88,17 @@ export default async function sign( ); if (isResponseValid) { + logger.info("Returning success response"); + return { action: Action.SIGN_SUCCESS, ...response }; } else { + logger.info("Signing response is invalid"); + throw new UnknownError("unexpected response from native application"); } } catch (error) { - console.error("Sign:", error); + logger.info("Signing failed"); + logger.error(error); return actionErrorHandler(Action.SIGN_FAILURE, error, libraryVersion, nativeAppStatus?.version); } finally { diff --git a/src/background/actions/status.ts b/src/background/actions/status.ts index f66b147..389052b 100644 --- a/src/background/actions/status.ts +++ b/src/background/actions/status.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -31,16 +31,24 @@ import UnknownError from "@web-eid.js/errors/UnknownError"; import VersionMismatchError from "@web-eid.js/errors/VersionMismatchError"; import { serializeError } from "@web-eid.js/utils/errorSerializer"; +import { MessageSender } from "../../models/Browser/Runtime"; import NativeAppService from "../services/NativeAppService"; import checkCompatibility from "../../shared/utils/checkCompatibility"; -import config from "../../config"; +import { config } from "../../shared/configManager"; + +import Logger from "../../shared/Logger"; + +const logger = new Logger("status.ts"); + +export default async function status(sender: MessageSender, libraryVersion: string): Promise { + logger.tabId = sender.tab?.id; + + logger.log("Status requested"); -export default async function status(libraryVersion: string): Promise { const extensionVersion = config.VERSION; - const nativeAppService = new NativeAppService(); + const nativeAppService = new NativeAppService(sender.tab?.id); try { - const status = await nativeAppService.connect(); const nativeApp = ( @@ -49,6 +57,8 @@ export default async function status(libraryVersion: string): Promise { switch (message.action) { case Action.AUTHENTICATE: @@ -66,6 +68,7 @@ async function onAction(message: ExtensionRequest, sender: MessageSender): Promi case Action.STATUS: return await status( + sender, message.libraryVersion, ); } @@ -77,12 +80,14 @@ async function onTokenSigningAction(message: TokenSigningMessage, sender: Messag switch (message.type) { case "VERSION": { return await TokenSigningAction.status( + sender, message.nonce, ); } case "CERT": { return await TokenSigningAction.getCertificate( + sender, message.nonce, sender.url, message.lang, @@ -92,6 +97,7 @@ async function onTokenSigningAction(message: TokenSigningMessage, sender: Messag case "SIGN": { return await TokenSigningAction.sign( + sender, message.nonce, sender.url, message.cert, @@ -104,10 +110,35 @@ async function onTokenSigningAction(message: TokenSigningMessage, sender: Messag } browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + const logger = new Logger("background.ts"); + + logger.tabId = sender.tab?.id; + if ((message as ExtensionRequest).action) { - onAction(message, sender).then(sendResponse); + logger.devToolsEvent("request", "Extension (content)", "Extension (background)", message); + + onAction(message, sender) + .then((response) => { + logger.devToolsEvent("response", "Extension (content)", "Extension (background)", response); + + return response; + }) + .then(sendResponse); + + } else if (message.devtools) { + logger.devToolsProxy(message, sender); + sendResponse(); } else if ((message as TokenSigningMessage).type) { - onTokenSigningAction(message, sender).then(sendResponse); + logger.devToolsEvent("request", "Extension (content)", "Extension (background)", message); + + onTokenSigningAction(message, sender) + .then((response) => { + logger.devToolsEvent("response", "Extension (content)", "Extension (background)", response); + + return response; + }) + .then(sendResponse); } + return true; }); diff --git a/src/background/services/NativeAppService.ts b/src/background/services/NativeAppService.ts index fd83a15..b3d2094 100644 --- a/src/background/services/NativeAppService.ts +++ b/src/background/services/NativeAppService.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -27,7 +27,12 @@ import libraryConfig from "@web-eid.js/config"; import { NativeRequest } from "@web-eid.js/models/message/NativeRequest"; import { Port } from "../../models/Browser/Runtime"; import calculateJsonSize from "../../shared/utils/calculateJsonSize"; -import config from "../../config"; +import { config } from "../../shared/configManager"; + +import Logger from "../../shared/Logger"; +import isLoopbackAddress from "../../shared/utils/isLoopbackAddress"; + +const logger = new Logger("NativeAppService.ts"); export enum NativeAppState { UNINITIALIZED, @@ -41,7 +46,13 @@ export default class NativeAppService { private port: Port | null = null; + constructor(tabId?: number) { + logger.tabId = tabId; + } + async connect(): Promise<{ version: string }> { + logger.log("Connecting to the native application " + config.NATIVE_APP_NAME); + this.state = NativeAppState.CONNECTING; this.port = browser.runtime.connectNative(config.NATIVE_APP_NAME); @@ -58,6 +69,8 @@ export default class NativeAppService { if (message.version) { this.state = NativeAppState.CONNECTED; + logger.info("Connected to the native application"); + return message; } @@ -72,7 +85,7 @@ export default class NativeAppService { } } catch (error) { if (this.port.error) { - console.error(this.port.error); + logger.error(this.port.error); } if (error instanceof Error) { @@ -86,7 +99,7 @@ export default class NativeAppService { } disconnectListener(): void { - config.DEBUG && console.log("Native app disconnected."); + logger.log("Native app disconnected."); // Accessing lastError when it exists stops chrome from throwing it unnecessarily. chrome?.runtime?.lastError; @@ -95,36 +108,55 @@ export default class NativeAppService { } close(): void { - if (this.state == NativeAppState.DISCONNECTED) return; + logger.log("Closing connection to native application"); + + if (this.state == NativeAppState.DISCONNECTED) { + logger.info("Connection already closed"); + + return; + } this.state = NativeAppState.DISCONNECTED; this.port?.disconnect(); - - config.DEBUG && console.log("Native app port closed by extension."); } 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)); - const messageSize = calculateJsonSize(message); + logger.debug("Calculated message size", messageSize); + if (messageSize > config.NATIVE_MESSAGE_MAX_BYTES) { throw new Error(`native application message exceeded ${config.NATIVE_MESSAGE_MAX_BYTES} bytes`); } + if (config.ALLOW_HTTP_LOCALHOST && message.arguments?.origin) { + const url = new URL(message.arguments.origin); + + if (url.protocol === "http:" && isLoopbackAddress(url.hostname)) { + url.protocol = "https:"; + + message.arguments.origin = url.origin; + logger.warn("Setting ALLOW_HTTP_LOCALHOST enabled, replaced origin with " + message.arguments.origin); + } + } + + logger.log("Sending message to native application"); + logger.devToolsEvent("request", "Extension (background)", "Native app", message); + this.port?.postMessage(message); try { const response = await this.nextMessage(timeout, throwAfterTimeout); + logger.log("Response from native application"); + logger.devToolsEvent("response", "Extension (background)", "Native app", response); + this.close(); return response; } catch (error) { - console.error(error); - this.close(); throw error; @@ -173,9 +205,7 @@ export default class NativeAppService { const onDisconnectListener = (): void => { cleanup?.(); - reject(new NativeUnavailableError( - "a message from native application was expected, but native application closed connection" - )); + reject(new NativeUnavailableError("connection to the native app was closed")); }; cleanup = (): void => { diff --git a/src/config.ts b/src/config.ts index 6284362..b6ae9aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -32,10 +32,29 @@ export default Object.freeze({ */ NATIVE_GRACEFUL_DISCONNECT_TIMEOUT: 2000, // 2 seconds - // Default: false + /** + * Web eID is able to provide Chrome Token Signing backwards compatibility. + * When enabled, in addition to the Web eID library, the Web eID browser extension will process hwcrypto.js events. + * + * In this mode, Web-eID will need to inject the Token Signing page script to websites. + * + * This is a temporary solution. Web eID library should be used instead of hwcrypto.js. + * + * Disabled by default. + * + * @see https://github.com/open-eid/chrome-token-signing + * @see https://github.com/hwcrypto/hwcrypto.js/wiki + */ TOKEN_SIGNING_BACKWARDS_COMPATIBILITY: process.env.TOKEN_SIGNING_BACKWARDS_COMPATIBILITY?.toUpperCase() === "TRUE", TOKEN_SIGNING_USER_INTERACTION_TIMEOUT: 1000 * 60 * 5, // 5 minutes - // Default: false - DEBUG: process.env.DEBUG?.toUpperCase() === "TRUE", + /** + * Should the Web eID extension allow http://localhost. + * + * The extension checks if the browser context is secure and the native application checks for the HTTPS protocol. + * Localhost is considered to be secure context, even when HTTPS is not used. + * + * When this option is enabled, the extension reports localhost URLs to the native application with HTTPS. + */ + ALLOW_HTTP_LOCALHOST: false as boolean, }); diff --git a/src/content/TokenSigning/injectPageScript.ts b/src/content/TokenSigning/injectPageScript.ts index 5c67698..fc2f30a 100644 --- a/src/content/TokenSigning/injectPageScript.ts +++ b/src/content/TokenSigning/injectPageScript.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/content/content.ts b/src/content/content.ts index 5ef03e3..64c40bf 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -28,6 +28,10 @@ import config from "../config"; import injectPageScript from "./TokenSigning/injectPageScript"; import tokenSigningResponse from "../shared/tokenSigningResponse"; +import Logger from "../shared/Logger"; + +const logger = new Logger("content.ts", { isContentScript: true }); + function isWebeidEvent(event: MessageEvent): boolean { return ( event.source === window && @@ -35,6 +39,19 @@ function isWebeidEvent(event: MessageEvent): boolean { ); } +function isWebeidResponseEvent(event: MessageEvent): boolean { + return ( + event.source === window && + event.data?.action?.startsWith?.("web-eid:") && + ( + event.data.action == Action.WARNING || + event.data.action.endsWith("-success") || + event.data.action.endsWith("-failure") || + event.data.action.endsWith("-ack") + ) + ); +} + function isTokenSigningEvent(event: MessageEvent): boolean { return ( event.source === window && @@ -48,13 +65,21 @@ async function send(message: object): Promise { return response; } +async function ack( + action: Action.AUTHENTICATE_ACK | Action.GET_SIGNING_CERTIFICATE_ACK | Action.SIGN_ACK | Action.STATUS_ACK, + origin: string +): Promise { + const message = { action }; + + await logger.devToolsEvent("response", "Website", "Extension (content)", message); + window.postMessage(message, origin); +} + window.addEventListener("message", async (event) => { 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 (isWebeidResponseEvent(event)) return; - config.DEBUG && console.log("Web-eID event: ", JSON.stringify(event)); + logger.devToolsEvent("request", "Website", "Extension (content)", event.data); if (!window.isSecureContext) { const response = { @@ -62,55 +87,60 @@ window.addEventListener("message", async (event) => { error: new ContextInsecureError(), }; - window.postMessage(response, event.origin); + logger.devToolsEvent("response", "Website", "Extension (content)", response); + window.postMessage(response, event.origin); } else { let response; switch (event.data.action) { case Action.STATUS: { - window.postMessage({ action: Action.STATUS_ACK }, event.origin); + await ack(Action.STATUS_ACK, event.origin); response = await send(event.data); break; } case Action.AUTHENTICATE: { - window.postMessage({ action: Action.AUTHENTICATE_ACK }, event.origin); + await ack(Action.AUTHENTICATE_ACK, event.origin); response = await send(event.data); break; } case Action.SIGN: { - window.postMessage({ action: Action.SIGN_ACK }, event.origin); + await ack(Action.SIGN_ACK, event.origin); response = await send(event.data); break; } case Action.GET_SIGNING_CERTIFICATE: { - window.postMessage({ action: Action.GET_SIGNING_CERTIFICATE_ACK }, event.origin); + await ack(Action.GET_SIGNING_CERTIFICATE_ACK, event.origin); response = await send(event.data); break; } } if (response) { + logger.devToolsEvent("response", "Website", "Extension (content)", response); + window.postMessage(response, event.origin); } } } else if (config.TOKEN_SIGNING_BACKWARDS_COMPATIBILITY && isTokenSigningEvent(event)) { - config.DEBUG && console.log("TokenSigning event:", JSON.stringify(event)); + logger.devToolsEvent("request", "Website", "Extension (content)", event.data); if (!window.isSecureContext) { - console.error(new ContextInsecureError()); + logger.error(new ContextInsecureError()); const nonce = event.data.nonce; const response = tokenSigningResponse("technical_error", nonce); + logger.devToolsEvent("response", "Website", "Extension (content)", response); + window.postMessage(response, event.origin); } else { const response = await send(event.data) as { warnings?: [], [key: string]: any } | void; - response?.warnings?.forEach((warning) => console.warn(warning)); + logger.devToolsEvent("response", "Website", "Extension (content)", response); window.postMessage(response, event.origin); } diff --git a/src/models/Browser.ts b/src/models/Browser.ts index 0f72d6f..9f060d5 100644 --- a/src/models/Browser.ts +++ b/src/models/Browser.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -20,6 +20,7 @@ * SOFTWARE. */ +import Devtools from "./Browser/Devtools"; import Permissions from "./Browser/Permissions"; import Runtime from "./Browser/Runtime"; import Tabs from "./Browser/Tabs"; @@ -57,4 +58,16 @@ export default interface Browser { * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs */ tabs: Tabs; + + /** + * Enables extensions to interact with the browser's Developer Tools. + * + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/devtools + */ + devtools: Devtools; + + /** + * + */ + storage: Storage; } diff --git a/src/models/Browser/Devtools.ts b/src/models/Browser/Devtools.ts new file mode 100644 index 0000000..69d996d --- /dev/null +++ b/src/models/Browser/Devtools.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020-2025 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 default interface Devtools { + + /** + * Interact with the window that Developer tools are attached to (inspected window). + * This includes obtaining the tab ID for the inspected page, evaluate the code + * in the context of the inspected window, reload the page, or obtain the list of resources within the page. + */ + inspectedWindow: Window; + + /** + * Obtain information about network requests associated with the window that + * the Developer Tools are attached to (the inspected window). + */ + network: any; + + /** + * Create User Interface panels that will be displayed inside User Agent Developer Tools. + */ + panels: { + create: (title: string, iconPath: string, pagePath: string) => Promise; + }; +} + +export interface ExtensionPanel { + onShown: { + addListener: (listener: () => void) => void; + removeListener: (listener: () => void) => void; + }; + + onHidden: { + addListener: (listener: () => void) => void; + removeListener: (listener: () => void) => void; + }; +} diff --git a/src/models/Browser/ExtensionStorage b/src/models/Browser/ExtensionStorage new file mode 100644 index 0000000..969cd28 --- /dev/null +++ b/src/models/Browser/ExtensionStorage @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2020-2025 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 default interface ExtensionStorage { + + /** + * Represents the local storage area. Items in local storage are local to the machine the extension was installed on. + */ + local: StorageArea; + + /** + * Represents the managed storage area. Items in managed storage are set by the domain administrator and + * are read-only for the extension. Trying to modify this namespace results in an error. + */ + managed: StorageArea; + + /** + * Represents the session storage area. Items in session storage are stored in memory and are not persisted to disk. + */ + session: StorageArea; + + /** + * Represents the sync storage area. Items in sync storage are synced by the browser, + * and are available across all instances of that browser that the user is logged into, across different devices. + */ + sync: StorageArea; +} + +export interface StorageArea { + /** + * Retrieves one or more items from the storage area. + */ + get: (keys: null | string | object | Array) => Promise> + + /** + * Gets the amount of storage space (in bytes) used for one or more items in the storage area. + */ + getBytesInUse: (keys: null | string | Array) => Promise; + /** + * Stores one or more items in the storage area. If the item exists, its value is updated. + */ + set: (data: Record) => Promise; + /** + * Removes one or more items from the storage area. + */ + remove: (keys: string | Array) => Promise; + /** + * Removes all items from the storage area. + */ + clear: () => Promise; +} \ No newline at end of file diff --git a/src/models/Browser/Permissions.ts b/src/models/Browser/Permissions.ts index a670876..f53c7f8 100644 --- a/src/models/Browser/Permissions.ts +++ b/src/models/Browser/Permissions.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/models/Browser/Runtime.ts b/src/models/Browser/Runtime.ts index 9047a9b..1cd8ca8 100644 --- a/src/models/Browser/Runtime.ts +++ b/src/models/Browser/Runtime.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -21,19 +21,6 @@ */ export default interface Runtime { - /** - * Fired when the extension is first installed, when the extension is updated to a new version, and when the browser is - * updated to a new version. - * - * Note that runtime.onInstalled is not the same as management.onInstalled. The runtime.onInstalled event is fired only - * for your extension. The browser.management.onInstalled event is fired for any extensions. - * - * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onInstalled - */ - onInstalled: { - addListener: (callback: OnInstalledCallback) => void; - }; - /** * A string representing the extension ID. * @@ -42,10 +29,6 @@ export default interface Runtime { */ id: string; - onMessage: { - addListener: (callback: OnMessageCallback) => void; - }; - /** * Sends a single message from an extension to a native application. * @@ -120,6 +103,38 @@ export default interface Runtime { * Get the complete manifest.json file, deserialized from JSON to an object. */ getManifest: () => any; + + /** + * Fired when a connection is made with either an extension process or a content script. + * + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onConnect + */ + onConnect: { + addListener: (listener: (port: Port) => void) => void; + removeListener: (listener: () => void) => void; + }; + + /** + * Fired when the extension is first installed, when the extension is updated to a new version, + * and when the browser is updated to a new version. + * + * Note that runtime.onInstalled is not the same as management.onInstalled. The runtime.onInstalled event is + * fired only for your extension. The browser.management.onInstalled event is fired for any extensions. + * + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onInstalled + */ + onInstalled: { + addListener: (callback: OnInstalledCallback) => void; + }; + + /** + * Use this event to listen for messages from another part of your extension. + * + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage + */ + onMessage: { + addListener: (callback: OnMessageCallback) => void; + }; } export interface Port { diff --git a/src/models/Browser/Tabs.ts b/src/models/Browser/Tabs.ts index beab51d..685f632 100644 --- a/src/models/Browser/Tabs.ts +++ b/src/models/Browser/Tabs.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/models/StatusOptions.ts b/src/models/StatusOptions.ts index 0974a69..7350b40 100644 --- a/src/models/StatusOptions.ts +++ b/src/models/StatusOptions.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/models/TokenSigning/TokenSigningMessage.ts b/src/models/TokenSigning/TokenSigningMessage.ts index b25a734..9db08df 100644 --- a/src/models/TokenSigning/TokenSigningMessage.ts +++ b/src/models/TokenSigning/TokenSigningMessage.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/models/TokenSigning/TokenSigningPromise.ts b/src/models/TokenSigning/TokenSigningPromise.ts index 191d4d9..e250144 100644 --- a/src/models/TokenSigning/TokenSigningPromise.ts +++ b/src/models/TokenSigning/TokenSigningPromise.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/models/TokenSigning/TokenSigningResponse.ts b/src/models/TokenSigning/TokenSigningResponse.ts index 6aa6b22..859f058 100644 --- a/src/models/TokenSigning/TokenSigningResponse.ts +++ b/src/models/TokenSigning/TokenSigningResponse.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/models/TokenSigning/TokenSigningType.ts b/src/models/TokenSigning/TokenSigningType.ts index 775d0dc..4c361b0 100644 --- a/src/models/TokenSigning/TokenSigningType.ts +++ b/src/models/TokenSigning/TokenSigningType.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/resources/token-signing-page-script.ts b/src/resources/token-signing-page-script.ts index 66c2cbc..dc09b48 100644 --- a/src/resources/token-signing-page-script.ts +++ b/src/resources/token-signing-page-script.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/ByteArray.ts b/src/shared/ByteArray.ts index c9b137d..b569dd8 100644 --- a/src/shared/ByteArray.ts +++ b/src/shared/ByteArray.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/HwcryptoPatcher.ts b/src/shared/HwcryptoPatcher.ts index 7063241..61cab06 100644 --- a/src/shared/HwcryptoPatcher.ts +++ b/src/shared/HwcryptoPatcher.ts @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2022-2025 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. + */ + interface Hwcrypto { [key: string]: any; diff --git a/src/shared/Logger.ts b/src/shared/Logger.ts new file mode 100644 index 0000000..b889fa1 --- /dev/null +++ b/src/shared/Logger.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2020-2025 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 { serializeError } from "@web-eid.js/utils/errorSerializer"; + +import { MessageSender } from "../models/Browser/Runtime"; +import devToolsBridge from "./devToolsBridge"; +import isBrowserStorageEnabled from "./utils/isBrowserStorageEnabled"; + +type Layer = "Website" | "Extension (content)" | "Extension (background)" | "Native app"; + +interface DevToolsLogMessage { + devtools: "log"; + source: string; + type: "log" | "info" | "warn" | "error"; + message: string; + time: string; +} + +interface DevToolsEventMessage { + devtools: "event"; + type: "request" | "response"; + layer1: Layer; + layer2: Layer; + data: any; + time: string; +} + +export default class Logger { + private module: string; + private isContentScript = false; + + public tabId?: number; + + constructor(module: string, options?: { isContentScript: boolean }) { + this.module = module; + this.isContentScript = options?.isContentScript ?? this.isContentScript; + } + + async log(...args: Array): Promise { + await this.devToolsLog("log", args); + console.log(...args); + } + + async info(...args: Array): Promise { + await this.devToolsLog("info", args); + console.info(...args); + } + + async warn(...args: Array): Promise { + await this.devToolsLog("warn", args); + console.warn(...args); + } + + async error(...args: Array): Promise { + await this.devToolsLog("error", args); + console.error(...args); + } + + async debug(...args: Array): Promise { + await this.devToolsLog("debug", args); + console.debug(...args); + } + + async isDevToolsEnabled(): Promise { + const isOptionalPermissionDevToolsTurnedOn = Boolean(browser.runtime.getManifest().optional_permissions?.includes("devtools")); + + if (isOptionalPermissionDevToolsTurnedOn) { + return true; + } + + const isStorageEnabled = await isBrowserStorageEnabled(); + + if (isStorageEnabled) { + const { devtoolsEnabled } = await browser.storage.local.get(["devtoolsEnabled"]); + + return Boolean(devtoolsEnabled); + } + + return false; + } + + async devToolsLog(type: "event" | "log" | "info" | "warn" | "error" | "debug", rawMessage: Array) { + if (this.isContentScript || await this.isDevToolsEnabled()) { + const time = this.getCurrentTime(); + const source = this.module; + + const message = rawMessage.map((obj) => { + if (obj instanceof Error || obj.stack != null) { + return serializeError(obj); + } else { + return obj; + } + }); + + const devToolsLogMessage = { + devtools: "log", + + source, + type, + message, + time, + }; + + if (this.isContentScript) { + browser.runtime.sendMessage(devToolsLogMessage); + } else { + devToolsBridge.send({ tabId: this.tabId, ...devToolsLogMessage }); + } + } + } + + async devToolsEvent(type: "request" | "response", layer1: Layer, layer2: Layer, data: any) { + if (this.isContentScript || await this.isDevToolsEnabled()) { + const time = this.getCurrentTime(); + + const devToolsEventMessage: DevToolsEventMessage = { + devtools: "event", + + type, + data, + layer1, + layer2, + time, + }; + + if (this.isContentScript) { + browser.runtime.sendMessage(devToolsEventMessage); + } else { + devToolsBridge.send({ tabId: this.tabId, ...devToolsEventMessage }); + } + } + } + + devToolsProxy( + proxyMessage: DevToolsLogMessage | DevToolsEventMessage, + sender: MessageSender, + ) { + if (proxyMessage.devtools == "log") { + const { devtools, source, type, message, time } = proxyMessage; + + devToolsBridge.send({ + tabId: sender.tab?.id, + + devtools, + source, + type, + message, + time, + }); + } else if (proxyMessage.devtools == "event") { + const { devtools, type, data, layer1, layer2, time } = proxyMessage; + + devToolsBridge.send({ + tabId: sender.tab?.id, + + devtools, + type, + data, + layer1, + layer2, + time + }); + } + } + + getCurrentTime() { + const now = new Date(); + const [HH, mm, ss, SSS] = ( + [ + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds(), + ] + .map((num) => num.toString()) + .map((str, i) => str.padStart(i == 3 ? 3 : 2, "0")) + ); + + return `${HH}:${mm}:${ss}.${SSS}`; + } +} diff --git a/src/shared/Mutex.ts b/src/shared/Mutex.ts index 4c4acf6..4d54dcb 100644 --- a/src/shared/Mutex.ts +++ b/src/shared/Mutex.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/TokenSigningPageScript.ts b/src/shared/TokenSigningPageScript.ts index 6744767..66cfb18 100644 --- a/src/shared/TokenSigningPageScript.ts +++ b/src/shared/TokenSigningPageScript.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/__tests__/configManager-test.ts b/src/shared/__tests__/configManager-test.ts new file mode 100644 index 0000000..bd484b9 --- /dev/null +++ b/src/shared/__tests__/configManager-test.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2022-2025 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 {config, setConfigOverride} from "../configManager"; + +const mockStorageLocal = { + set: jest.fn(), + remove: jest.fn() +}; + +const mockBrowser = { + storage: { + local: mockStorageLocal + } +}; + +(global as any).browser = mockBrowser; + +const mockIsBrowserStorageEnabled = jest.fn(); + +jest.mock("../utils/isBrowserStorageEnabled", () => ({ + __esModule: true, + default: () => mockIsBrowserStorageEnabled() +})); + +describe("setConfigOverride", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("when storage is enabled", () => { + beforeEach(() => { + config.ALLOW_HTTP_LOCALHOST = false; + mockIsBrowserStorageEnabled.mockResolvedValue(true); + }); + + it("should set value in config and storage when value is not null", async () => { + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + + await setConfigOverride("ALLOW_HTTP_LOCALHOST", true); + + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(true); + expect(mockStorageLocal.set).toHaveBeenCalledWith({ALLOW_HTTP_LOCALHOST: true}); + expect(mockStorageLocal.remove).not.toHaveBeenCalled(); + }); + + it("should set config value to default and remove key from storage when value is null", async () => { + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + + await setConfigOverride("ALLOW_HTTP_LOCALHOST", null); + + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + expect(mockStorageLocal.remove).toHaveBeenCalledWith(["ALLOW_HTTP_LOCALHOST"]); + expect(mockStorageLocal.set).not.toHaveBeenCalled(); + }); + + it("should set config value to default and remove key from storage when value is equal to default value", async () => { + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + + await setConfigOverride("ALLOW_HTTP_LOCALHOST", false); + + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + expect(mockStorageLocal.remove).toHaveBeenCalledWith(["ALLOW_HTTP_LOCALHOST"]); + expect(mockStorageLocal.set).not.toHaveBeenCalled(); + }); + + it("should not set non overridable config value", async () => { + expect(config.NATIVE_APP_NAME).toEqual("eu.webeid"); + + await setConfigOverride("NATIVE_APP_NAME", null); + + expect(config.NATIVE_APP_NAME).toEqual("eu.webeid"); + expect(mockStorageLocal.remove).not.toHaveBeenCalledWith(["NATIVE_APP_NAME"]); + expect(mockStorageLocal.set).not.toHaveBeenCalled(); + }); + + }); + + describe("when storage is disabled", () => { + beforeEach(() => { + config.ALLOW_HTTP_LOCALHOST = false; + mockIsBrowserStorageEnabled.mockResolvedValue(false); + }); + + it("should set value in config and not call storage methods when storage is disabled", async () => { + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + + await setConfigOverride("ALLOW_HTTP_LOCALHOST", true); + + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(true); + expect(mockStorageLocal.set).not.toHaveBeenCalled(); + expect(mockStorageLocal.remove).not.toHaveBeenCalled(); + }); + + it("should set config value to default and not call storage methods when storage is disabled", async () => { + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + + await setConfigOverride("ALLOW_HTTP_LOCALHOST", null); + + expect(config.ALLOW_HTTP_LOCALHOST).toEqual(false); + expect(mockStorageLocal.set).not.toHaveBeenCalled(); + expect(mockStorageLocal.remove).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + beforeEach(() => { + mockIsBrowserStorageEnabled.mockResolvedValue(true); + }); + + it("should handle storage.set errors", async () => { + const error = new Error("Storage set failed"); + mockStorageLocal.set.mockRejectedValue(error); + + await expect(setConfigOverride("ALLOW_HTTP_LOCALHOST", true)) + .rejects.toThrow("Storage set failed"); + }); + + it("should handle storage.remove errors", async () => { + const error = new Error("Storage remove failed"); + mockStorageLocal.remove.mockRejectedValue(error); + + await expect(setConfigOverride("ALLOW_HTTP_LOCALHOST", null)) + .rejects.toThrow("Storage remove failed"); + }); + + it("should handle isBrowserStorageEnabled errors", async () => { + const error = new Error("Storage check failed"); + mockIsBrowserStorageEnabled.mockRejectedValue(error); + + await expect(setConfigOverride("ALLOW_HTTP_LOCALHOST", true)) + .rejects.toThrow("Storage check failed"); + }); + }); +}); diff --git a/src/shared/actionErrorHandler.ts b/src/shared/actionErrorHandler.ts index c9c2ff0..82086b1 100644 --- a/src/shared/actionErrorHandler.ts +++ b/src/shared/actionErrorHandler.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -27,7 +27,7 @@ import VersionMismatchError from "@web-eid.js/errors/VersionMismatchError"; import { serializeError } from "@web-eid.js/utils/errorSerializer"; import checkCompatibility from "./utils/checkCompatibility"; -import config from "../config"; +import { config } from "../shared/configManager"; export default function actionErrorHandler( action: diff --git a/src/shared/configManager.ts b/src/shared/configManager.ts new file mode 100644 index 0000000..e32d90e --- /dev/null +++ b/src/shared/configManager.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2020-2025 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 defaultConfig from "../config"; +import isBrowserStorageEnabled from "./utils/isBrowserStorageEnabled"; + +type Config = { + -readonly [key in keyof typeof defaultConfig]: typeof defaultConfig[key]; +}; + +const config = JSON.parse(JSON.stringify(defaultConfig)) as Config; + +const overrideableConfigKeys: Array = [ + "NATIVE_MESSAGE_MAX_BYTES", + "NATIVE_GRACEFUL_DISCONNECT_TIMEOUT", + "TOKEN_SIGNING_USER_INTERACTION_TIMEOUT", + "ALLOW_HTTP_LOCALHOST" +]; + +async function setConfigOverride(key: K, value: typeof defaultConfig[K] | null) { + if (!overrideableConfigKeys.includes(key)) { + return; + } + setConfigValueOrResetToDefaultOnNull(key, value); + await saveToStorageOrRemoveOnNull(key, value); +} + +async function loadConfigFromStorage() { + const isStorageEnabled = await isBrowserStorageEnabled(); + if (isStorageEnabled) { + try { + const values = await browser.storage.local.get(overrideableConfigKeys); + + for (const key of overrideableConfigKeys) { + if (isValidConfigValue(key, values[key])) { + setConfigValueOrResetToDefaultOnNull(key, values[key]); + } + } + } catch (error) { + console.error("Failed to load configuration from storage:", error); + } + } +} + +function isValidConfigValue(key: K, value: unknown): value is typeof defaultConfig[K] { + return typeof value === typeof defaultConfig[key]; +} + +function setConfigValueOrResetToDefaultOnNull(key: K, value: typeof defaultConfig[K] | null) { + if (value === null) { + config[key] = defaultConfig[key]; + } else { + config[key] = value; + } +} + +async function saveToStorageOrRemoveOnNull(key: K, value: typeof defaultConfig[K] | null) { + const isStorageEnabled = await isBrowserStorageEnabled(); + if (isStorageEnabled) { + if (value === null || value === defaultConfig[key]) { + await browser.storage.local.remove([key]); + } else { + await browser.storage.local.set({ [key]: value }); + } + } +} + +export { + config, + defaultConfig, + loadConfigFromStorage, + setConfigOverride, +}; diff --git a/src/shared/devToolsBridge.ts b/src/shared/devToolsBridge.ts new file mode 100644 index 0000000..d913e35 --- /dev/null +++ b/src/shared/devToolsBridge.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020-2025 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 { config, defaultConfig, loadConfigFromStorage, setConfigOverride } from "./configManager"; +import { Port } from "../models/Browser/Runtime"; +import isBrowserStorageEnabled from "./utils/isBrowserStorageEnabled"; + +class DevToolsBridge extends EventTarget { + + devToolPorts: Array = []; + + constructor() { + super(); + + browser.runtime.onConnect.addListener(async (port: Port) => { + await this.connectToPort(port); + }); + } + + send(message: object, options?: { ignore: Port }) { + this.devToolPorts + .filter((port) => !options?.ignore || port !== options.ignore) + .forEach((port) => port.postMessage(message)); + } + + private async connectToPort(port: Port) { + if (port.name === "webeid-devtools") { + this.devToolPorts.push(port); + } + + port.onMessage.addListener((message) => { + if (message.devtools === "setting-set") { + setConfigOverride(message.key, message.value); + + this.send({ devtools: "settings", config, defaultConfig }, { ignore: port }); + } + }); + + port.onDisconnect.addListener(() => { + this.devToolPorts = this.devToolPorts.filter((connectedPort) => connectedPort !== port); + }); + + await loadConfigFromStorage(); + port.postMessage({ devtools: "settings", config, defaultConfig }); + } + +} + +export default new DevToolsBridge(); diff --git a/src/shared/tokenSigningResponse.ts b/src/shared/tokenSigningResponse.ts index 2f7cbca..057eb19 100644 --- a/src/shared/tokenSigningResponse.ts +++ b/src/shared/tokenSigningResponse.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 @@ -25,7 +25,7 @@ import { TokenSigningResult, } from "../models/TokenSigning/TokenSigningResponse"; -import config from "../config"; +import { config } from "../shared/configManager"; /** * Helper function to compose a token signing response message diff --git a/src/shared/utils/__tests__/version-test.ts b/src/shared/utils/__tests__/version-test.ts index eca8bc5..c40bb5f 100644 --- a/src/shared/utils/__tests__/version-test.ts +++ b/src/shared/utils/__tests__/version-test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Estonian Information System Authority + * Copyright (c) 2022-2025 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 diff --git a/src/shared/utils/calculateJsonSize.ts b/src/shared/utils/calculateJsonSize.ts index f1e849f..8ad0425 100644 --- a/src/shared/utils/calculateJsonSize.ts +++ b/src/shared/utils/calculateJsonSize.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/utils/checkCompatibility.ts b/src/shared/utils/checkCompatibility.ts index fc215b8..61ebfd0 100644 --- a/src/shared/utils/checkCompatibility.ts +++ b/src/shared/utils/checkCompatibility.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/utils/isBrowserStorageEnabled.ts b/src/shared/utils/isBrowserStorageEnabled.ts new file mode 100644 index 0000000..a58f2b2 --- /dev/null +++ b/src/shared/utils/isBrowserStorageEnabled.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020-2025 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. + */ + +/** + * Function to check if saving to browser storage is allowed + * + * @returns true if manifest optional_permissions includes storage and storage permission is given by user + */ +export default async function isBrowserStorageEnabled() { + const isStorageOptional = Boolean(browser.runtime.getManifest().optional_permissions?.includes("storage")); + const hasStoragePermission = await browser.permissions.contains({ permissions: ["storage"] }); + return isStorageOptional && hasStoragePermission; +} diff --git a/src/shared/utils/isLoopbackAddress.ts b/src/shared/utils/isLoopbackAddress.ts new file mode 100644 index 0000000..c5a7657 --- /dev/null +++ b/src/shared/utils/isLoopbackAddress.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020-2025 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. + */ + +/** + * + * @param host hostname or ip address + * + * @returns true if is loopback address + */ + +const ipv4LoopbackAddressPattern = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +/** + * Checks whether host resolves to loopback address + * + * @param host One of IP-literal / IPv4address / reg-name aka hostname + * @returns `true` if host is loopback address + */ +export default function isLoopbackAddress(host: string): boolean { + return ( + host === "localhost" || + ipv4LoopbackAddressPattern.test(host) || + host === "[::1]" || + host === "[0000:0000:0000:0000:0000:0000:0000:0001]" + ); +} diff --git a/src/shared/utils/semver.ts b/src/shared/utils/semver.ts index d09cc1e..c7683fc 100644 --- a/src/shared/utils/semver.ts +++ b/src/shared/utils/semver.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/src/shared/utils/sender.ts b/src/shared/utils/sender.ts index 5c54c27..96efc95 100644 --- a/src/shared/utils/sender.ts +++ b/src/shared/utils/sender.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 Estonian Information System Authority + * Copyright (c) 2020-2025 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 diff --git a/static/chrome/manifest.json b/static/chrome/manifest.json index b6031cc..f94d6c8 100644 --- a/static/chrome/manifest.json +++ b/static/chrome/manifest.json @@ -27,9 +27,16 @@ "background": { "service_worker": "background.js" }, + "options_ui": { + "page": "views/options.html" + }, + "devtools_page": "views/devtools/devtools.html", "permissions": [ "nativeMessaging" ], + "optional_permissions": [ + "storage" + ], "host_permissions": [ "*://*/*" ] diff --git a/static/firefox/manifest.json b/static/firefox/manifest.json index 3521f0a..04d634c 100644 --- a/static/firefox/manifest.json +++ b/static/firefox/manifest.json @@ -31,8 +31,12 @@ "background.js" ] }, + "devtools_page": "views/devtools/devtools.html", "permissions": [ "*://*/*", "nativeMessaging" + ], + "optional_permissions": [ + "devtools" ] } diff --git a/static/firefox/manifest_v3.json b/static/firefox/manifest_v3.json index db11c1e..c3772cb 100644 --- a/static/firefox/manifest_v3.json +++ b/static/firefox/manifest_v3.json @@ -35,9 +35,13 @@ "background.js" ] }, + "devtools_page": "views/devtools/devtools.html", "permissions": [ "nativeMessaging" ], + "optional_permissions": [ + "devtools" + ], "host_permissions": [ "*://*/*" ] diff --git a/static/safari/manifest.json b/static/safari/manifest.json index 553e459..ae09fc1 100644 --- a/static/safari/manifest.json +++ b/static/safari/manifest.json @@ -22,8 +22,16 @@ "background.js" ] }, + "options_ui": { + "page": "views/options.html" + }, + "devtools_page": "views/devtools/devtools.html", "permissions": [ "*://*/*", "nativeMessaging" + ], + "optional_permissions": [ + "devtools", + "storage" ] } diff --git a/static/safari/manifest_v3.json b/static/safari/manifest_v3.json index 4fb37fb..53763df 100644 --- a/static/safari/manifest_v3.json +++ b/static/safari/manifest_v3.json @@ -29,9 +29,13 @@ "background.js" ] }, + "devtools_page": "views/devtools/devtools.html", "permissions": [ "nativeMessaging" ], + "optional_permissions": [ + "devtools" + ], "host_permissions": [ "*://*/*" ] diff --git a/static/views/devtools/devtools.html b/static/views/devtools/devtools.html new file mode 100644 index 0000000..b82bfa0 --- /dev/null +++ b/static/views/devtools/devtools.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/views/devtools/devtools.js b/static/views/devtools/devtools.js new file mode 100644 index 0000000..372c6a1 --- /dev/null +++ b/static/views/devtools/devtools.js @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024-2025 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. + */ + +async function isDevToolsEnabled() { + const isOptionalPermissionDevToolsTurnedOn = Boolean(browser.runtime.getManifest().optional_permissions?.includes("devtools")); + + if (isOptionalPermissionDevToolsTurnedOn) { + return true; + } + + const isStorageOptional = Boolean(browser.runtime.getManifest().optional_permissions?.includes("storage")); + const hasStoragePermission = await browser.permissions.contains({ permissions: ["storage"] }); + + if (isStorageOptional && hasStoragePermission) { + const { devtoolsEnabled } = await browser.storage.local.get(["devtoolsEnabled"]); + + return Boolean(devtoolsEnabled); + } + + return false; +} + +(async () => { + if (await isDevToolsEnabled()) { + browser.devtools.panels.create( + "Web eID", + "/icons/web-eid-icon-128.png", + "/views/devtools/panels/devtools-webeid.html", + ); + } +})(); diff --git a/static/views/devtools/panels/devtools-webeid.html b/static/views/devtools/panels/devtools-webeid.html new file mode 100644 index 0000000..3dea1dc --- /dev/null +++ b/static/views/devtools/panels/devtools-webeid.html @@ -0,0 +1,95 @@ + + + + + + Web-eID Privacy + + + + +
+ + +
+
+ +
+
+
+

+    
+ +
+
+
+ +
+
+ Log levels: + + + + + + +
+
+
+
+ +
+
The settings you change here apply to all pages and persist even after closing DevTools.
+ + +
+
+
+ + + + + + + + + + + + diff --git a/static/views/devtools/panels/devtools-webeid.js b/static/views/devtools/panels/devtools-webeid.js new file mode 100644 index 0000000..1d8d55e --- /dev/null +++ b/static/views/devtools/panels/devtools-webeid.js @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2024-2025 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 events from "./webeid-events.js"; +import log from "./webeid-log.js"; +import settings from "./webeid-settings.js"; + +const backgroundConnection = browser.runtime.connect({ + name: "webeid-devtools", +}); + +const keepAliveInterval = setInterval(() => { + backgroundConnection.postMessage({ devtools: "keep-alive" }); +}, 20000); + +backgroundConnection.onDisconnect.addListener(() => { + clearInterval(keepAliveInterval); + + log.append({ + source: "devtools-webeid.js", + type: "error", + time: new Date().toISOString().match(/T((.)*)Z/)[1], + message: [ + "Web eID DevTools panel disconnected from the extension. Please reopen the browser DevTools to continue." + ], + }); +}); + +backgroundConnection.onMessage.addListener((message) => { + if (!message.tabId || message.tabId === browser.devtools.inspectedWindow.tabId || browser.devtools.inspectedWindow.tabId === -1) { + if (message.devtools === "log") { + log.append(message); + + } else if (message.devtools === "event") { + events.append(message); + + const direction = ( + message.type === "request" + ? "—►" + : message.type === "response" + ? "◄—" + : "?" + ); + + log.append({ + time: message.time, + type: "event", + source: `${message.layer1} ${direction} ${message.layer2}`, + message: [message.data], + }); + } else if (message.devtools === "settings") { + const { config, defaultConfig } = message; + + settings.render(config, defaultConfig, backgroundConnection); + + document.querySelector('header .version').textContent = defaultConfig.VERSION; + } + } +}); diff --git a/static/views/devtools/panels/webeid-events.js b/static/views/devtools/panels/webeid-events.js new file mode 100644 index 0000000..b0d2f58 --- /dev/null +++ b/static/views/devtools/panels/webeid-events.js @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024-2025 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. + */ + +const ui = { + eventTemplate: document.querySelector("#event-template"), + eventList: document.querySelector("section[data-page='events'] #event-list"), + eventDetails: document.querySelector("section[data-page='events'] #event-details"), + clearButton: document.querySelector("section[data-page='events'] #clear-events"), +}; + +function createEventElement(layer1, layer2, type, time, name, data) { + const root = ui.eventTemplate.content.cloneNode(true); + + const el = { + event: root.querySelector(".event"), + input: root.querySelector("input"), + label: root.querySelector("label"), + layer1: root.querySelector(".layer-1"), + layer2: root.querySelector(".layer-2"), + direction: root.querySelector(".direction"), + time: root.querySelector(".time"), + name: root.querySelector(".name"), + }; + + el.label.dataset.indent = ({ + "Extension (content)": 1, + "Extension (background)": 2, + "Native app": 3, + })[layer1] ?? 0; + + el.input.id = "event-" + Math.random().toString(36).substring(2); + el.label.htmlFor = el.input.id; + + el.layer1.textContent = layer1; + el.layer2.textContent = layer2; + el.time.textContent = time; + el.name.textContent = name; + + el.direction.textContent = ( + type === "request" + ? "—►" + : type === "response" + ? "◄—" + : "" + ); + + if (type === "response") { + el.label.dataset.responseType = name.split(/-|:/g).at(-1); + } + + el.direction.dataset.type = type; + + el.input.addEventListener("change", () => { + ui.eventDetails.textContent = JSON.stringify(data, null, " "); + }); + + return el.event; +} + +export default { + append({ layer1, layer2, type, time, data }) { + const name = data.action ?? data.command ?? data.type ?? data.result ?? type; + const eventElement = createEventElement(layer1, layer2, type, time, name, data); + + ui.eventList.appendChild(eventElement); + }, +}; diff --git a/static/views/devtools/panels/webeid-log.js b/static/views/devtools/panels/webeid-log.js new file mode 100644 index 0000000..9ddee91 --- /dev/null +++ b/static/views/devtools/panels/webeid-log.js @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2024-2025 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. + */ + +const ui = { + logEntryTemplate: document.querySelector("#log-entry-template"), + logContainer: document.querySelector("section[data-page='log'] #log-messages"), + clearButton: document.querySelector("section[data-page='log'] #clear-log"), +}; + +function createLogEntryElement(type, time, message, source) { + const root = ui.logEntryTemplate.content.cloneNode(true); + + const el = { + entry: root.querySelector(".entry"), + time: root.querySelector(".time"), + message: root.querySelector(".message"), + source: root.querySelector(".source" ), + }; + + el.entry.classList.add(`type-${type}`); + + el.time.textContent = time; + el.message.textContent = message; + el.source.textContent = source; + + return el.entry; +} + +function stringifyError(error) { + const { + message, + name, + stack, + } = error; + + const prettyStack = ( + stack + .trim() + .split("\n") + .map((part) => "\t" + part) + .join("\n") + ); + + return `${name}: ${message}\n${prettyStack}`; +} + +function stringifyMessageArray(message) { + return ( + message + .map((obj) => { + if (typeof obj == "undefined") { + return "(undefined)"; + } else if (obj == null) { + return "(null)"; + } else if (obj instanceof Error || obj.stack != null) { + return stringifyError(obj); + } else if (obj instanceof Object) { + return JSON.stringify(obj, null, " "); + } else { + return String(obj); + } + }) + .join(", ") + ); +} + +ui.clearButton.addEventListener("click", () => { + [...ui.logContainer.children].forEach((child) => child.remove()); +}); + +export default { + append({ time, type, message, source }) { + const { clientHeight, scrollHeight, scrollTop } = ui.logContainer; + + const isScrolledToBottom = (scrollHeight - scrollTop) === clientHeight; + const messageString = stringifyMessageArray(message); + const logEntryElement = createLogEntryElement(type, time, messageString, source); + + ui.logContainer.appendChild(logEntryElement); + + if (isScrolledToBottom) { + ui.logContainer.scrollTo(0, ui.logContainer.scrollHeight); + } + }, +}; diff --git a/static/views/devtools/panels/webeid-settings.js b/static/views/devtools/panels/webeid-settings.js new file mode 100644 index 0000000..0149eb7 --- /dev/null +++ b/static/views/devtools/panels/webeid-settings.js @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2024-2025 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. + */ + +const includedSettings = [ + "NATIVE_MESSAGE_MAX_BYTES", + "NATIVE_GRACEFUL_DISCONNECT_TIMEOUT", + "TOKEN_SIGNING_USER_INTERACTION_TIMEOUT", + "ALLOW_HTTP_LOCALHOST" +]; + +const ui = { + settingTemplate: document.querySelector("#setting-template"), + settingsTable: document.querySelector("section[data-page='settings'] #settings-list") +} + +export default { + render(config, defaultConfig, backgroundConnection) { + Object.assign(this, { config, defaultConfig, backgroundConnection }); + + const configRows = ( + Object + .entries(config) + .filter(([key]) => this.isVisible(key)) + .map(([key, value]) => this.createSettingRow(key, value)) + ); + + ui.settingsTable.replaceChildren(...configRows); + }, + + isVisible(settingKey) { + return includedSettings.includes(settingKey); + }, + + createSettingRow(key, value) { + const row = ui.settingTemplate.content.cloneNode(true); + + const el = { + key: row.querySelector("th"), + input: row.querySelector("input.value"), + reset: row.querySelector("input.reset"), + }; + + el.key.innerText = key; + + this.updateInput(el.input, value); + this.updateReset(el, key); + + const changeEvent = ( + el.input.type === "checkbox" + ? "change" + : "input" + ); + + el.input.addEventListener(changeEvent, () => { + const value = ({ + "text": el.input.value, + "number": el.input.valueAsNumber, + "checkbox": el.input.checked + })[el.input.type]; + + this.backgroundConnection.postMessage({ devtools: 'setting-set', key, value }); + + this.config[key] = value; + this.updateReset(el, key); + }); + + el.reset.addEventListener("click", () => { + this.config[key] = this.defaultConfig[key]; + el.reset.disabled = true; + + this.backgroundConnection.postMessage({ + devtools: 'setting-set', + key, + value: null, + }); + + this.updateInput(el.input, this.config[key]); + }); + + return row; + }, + + updateInput(input, value) { + switch (typeof value) { + case "number": { + input.type = "number"; + input.value = value; + break; + } + + case "boolean": { + input.type = "checkbox"; + input.checked = value; + break; + } + + default: + input.type = "text"; + input.value = value; + break; + } + }, + + updateReset(el, key) { + el.reset.disabled = ( + this.config[key] === this.defaultConfig[key] + ); + } +} diff --git a/static/views/devtools/style/empty-element-messages.css b/static/views/devtools/style/empty-element-messages.css new file mode 100644 index 0000000..4b44ac1 --- /dev/null +++ b/static/views/devtools/style/empty-element-messages.css @@ -0,0 +1,7 @@ +section[data-page="events"] #event-details:empty::before { + content: 'Select an event to inspect.'; +} + +section[data-page="events"] #event-list:empty::before { + content: 'The Web eID library has not sent any messages yet.'; +} diff --git a/static/views/devtools/style/main.css b/static/views/devtools/style/main.css new file mode 100644 index 0000000..b8517c0 --- /dev/null +++ b/static/views/devtools/style/main.css @@ -0,0 +1,141 @@ +@import url(theme.css); +@import url(reset.css); +@import url(page-events.css); +@import url(page-log.css); +@import url(page-settings.css); +@import url(empty-element-messages.css); + +body { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + + font-family: var(--font-family); + color: var(--color-text-primary); + background: var(--color-background-primary); +} + +input[type="button"] { + margin: 0; + padding: var(--spacing-s); + font-size: var(--font-size); + font-family: var(--font-family); + + border: 1px solid var(--color-button-border); + color: var(--color-text-primary); + background-color: var(--color-background-primary); + + &:hover:not(:disabled) { + background-color: var(--color-background-secondary); + } + + &:active:not(:disabled) { + background-color: var(--color-background-tertiary); + } + + &:disabled { + color: var(--color-button-border); + } +} + +input[type="text"], +input[type="number"] { + margin: 0; + padding: 0 var(--spacing-s); + border-radius: 2px; + + border: 1px solid var(--color-input-border); + color: var(--color-text-primary); + background: var(--color-background-primary); +} + +header { + display: flex; + justify-content: space-between; + + gap: var(--spacing-m); + padding: 0 var(--spacing-m); + + border: 1px solid var(--color-border); + background-color: var(--color-background-secondary); + + nav { + font-size: var(--font-size); + + flex: 0 0 auto; + display: flex; + + input { + display: none; + } + + label { + line-height: 2rem; + padding: 0 var(--spacing-m); + + background-color: var(--color-background-secondary); + border-bottom: 0.125rem solid var(--color-tab-inactive); + transition: border-bottom-color var(--transition-duration) ease; + } + + input:checked + label { + border-bottom: 2px solid var(--color-tab-active); + } + } + + .version { + display: flex; + align-items: center; + + font-family: var(--font-family-version); + font-size: var(--font-size-version); + color: var(--color-version); + } +} + +body:not(:has([data-nav="events"]:checked)) [data-page="events"] { + display: none; +} +body:not(:has([data-nav="log"]:checked)) [data-page="log"] { + display: none; +} +body:not(:has([data-nav="settings"]:checked)) [data-page="settings"] { + display: none; +} + +main { + position: relative; + flex: 1 1 100%; + overflow: hidden; + + font-size: var(--font-size); + + section { + height: 100%; + + border: 1px solid var(--color-border); + } +} + +.toolbar { + display: flex; + padding: var(--spacing-s) var(--spacing-m); + + border: 1px solid var(--color-border); + background-color: var(--color-background-tertiary); + + .group { + display: flex; + align-items: center; + gap: var(--spacing-s); + border-right: 1px solid var(--color-button-border); + padding-right: var(--spacing-m); + margin-right: var(--spacing-m); + + label { + display: flex; + align-items: center; + } + } +} diff --git a/static/views/devtools/style/page-events.css b/static/views/devtools/style/page-events.css new file mode 100644 index 0000000..aebbc50 --- /dev/null +++ b/static/views/devtools/style/page-events.css @@ -0,0 +1,115 @@ +section[data-page="events"] { + display: flex; + gap: 0.125rem; + + #event-list { + display: flex; + flex-direction: column; + flex: 0 0 40%; + + border: 1px solid var(--color-border); + + overflow-x: hidden; + overflow-y: auto; + + input { + display: none; + } + + input:checked+label { + background-color: var(--color-event-background-active); + box-shadow: inset 0 0 0 1px var(--color-event-border-active); + } + + label { + display: flex; + flex-direction: column; + padding: 0 var(--spacing-m); + border-bottom: 1px solid var(--color-border); + border-left: 0 solid var(--color-background-tertiary); + overflow: hidden; + + &[data-indent="1"] { border-left-width: var(--spacing-m); } + &[data-indent="2"] { border-left-width: calc(var(--spacing-m) * 2); } + &[data-indent="3"] { border-left-width: calc(var(--spacing-m) * 3); } + + &[data-response-type="ack"] { background-color: var(--color-event-ack); } + &[data-response-type="success"] { background-color: var(--color-event-success); } + &[data-response-type="failure"] { background-color: var(--color-event-failure); } + &[data-response-type="warning"] { background-color: var(--color-event-warning); } + + > div { + display: flex; + align-items: center; + width: 100%; + height: 2rem; + + &:first-child { + justify-content: flex-start; + + color: color-mix(in srgb, currentcolor 75%, transparent); + border-bottom: 1px dotted var(--color-border); + + .layer-1, .layer-2 { + overflow: hidden; + text-overflow: ellipsis; + } + + .layer-2 { + text-align: right; + } + + .direction { + display: flex; + flex: 0 0 2rem; + justify-content: center; + + &[data-type="request"] { + margin-right: auto; + } + + &[data-type="response"] { + margin-left: auto; + } + } + } + + &:last-child { + justify-content: space-between; + } + } + + .time { + height: 1.5rem; + display: flex; + align-items: center; + padding: 0 var(--spacing-s); + border-radius: 0.125rem; + + font-family: var(--font-family-timestamp); + font-size: var(--font-size-timestamp); + background-color: var(--color-background-tertiary); + } + } + } + + #event-details { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--color-border); + + &:not(:empty) { + padding: var(--spacing-m); + } + } + + #event-list:empty::before, + #event-details:empty::before { + display: block; + padding: var(--spacing-m); + + color: var(--color-text-note); + font-family: var(--font-family); + font-size: var(--font-size); + } +} diff --git a/static/views/devtools/style/page-log.css b/static/views/devtools/style/page-log.css new file mode 100644 index 0000000..a6e8a33 --- /dev/null +++ b/static/views/devtools/style/page-log.css @@ -0,0 +1,65 @@ +section[data-page="log"] { + display: flex; + flex-direction: column; + overflow: hidden; + + #log-messages { + display: flex; + flex-direction: column; + overflow: auto; + + .entry { + display: flex; + padding: var(--spacing-m); + border: 1px dotted var(--color-border); + + border-left: var(--spacing-m) solid color-mix(in srgb, var(--color-log-level-default) 50%, transparent); + + &.type-info { + border-left-color: color-mix(in srgb, var(--color-log-level-info) 50%, transparent); + background-color: color-mix(in srgb, var(--color-log-level-info) 5%, transparent); + } + + &.type-warn { + border-left-color: color-mix(in srgb, var(--color-log-level-warn) 50%, transparent); + background-color: color-mix(in srgb, var(--color-log-level-warn) 5%, transparent); + } + + &.type-error { + border-left-color: color-mix(in srgb, var(--color-log-level-error) 50%, transparent); + background-color: color-mix(in srgb, var(--color-log-level-error) 5%, transparent); + } + + &.type-event { + border-left-color: color-mix(in srgb, var(--color-log-level-event) 50%, transparent); + background-color: color-mix(in srgb, var(--color-log-level-event) 5%, transparent); + } + + .time { + flex: 0 0 6rem; + color: var(--color-log-timestamp); + } + + .message { + flex: 1 1 100%; + white-space: pre-wrap; + } + + .source { + flex: 0 0 auto; + margin-left: var(--spacing-l); + + color: var(--color-text-note); + } + } + } + + +} + +body:not(:has([data-logfilter="log"]:checked)) #log-messages .entry.type-log { display: none; } +body:not(:has([data-logfilter="warn"]:checked)) #log-messages .entry.type-warn { display: none; } +body:not(:has([data-logfilter="error"]:checked)) #log-messages .entry.type-error { display: none; } +body:not(:has([data-logfilter="info"]:checked)) #log-messages .entry.type-info { display: none; } +body:not(:has([data-logfilter="debug"]:checked)) #log-messages .entry.type-debug { display: none; } +body:not(:has([data-logfilter="event"]:checked)) #log-messages .entry.type-event { display: none; } diff --git a/static/views/devtools/style/page-settings.css b/static/views/devtools/style/page-settings.css new file mode 100644 index 0000000..a4f2407 --- /dev/null +++ b/static/views/devtools/style/page-settings.css @@ -0,0 +1,36 @@ +section[data-page="settings"] { + overflow: auto; + + .warning { + padding: var(--spacing-s); + background-color: color-mix(in srgb, var(--color-log-level-warn) 5%, transparent); + } + + table { + width: 100%; + + tr:first-child { + td, th { + border-top: 1px solid var(--color-border); + } + } + + td, th { + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-xs) var(--spacing-s); + } + + th { + width: 1px; + text-align: left; + } + + td:last-child { + text-align: right; + } + } + + input:invalid { + background-color: var(--color-input-invalid); + } +} \ No newline at end of file diff --git a/static/views/devtools/style/reset.css b/static/views/devtools/style/reset.css new file mode 100644 index 0000000..7e24161 --- /dev/null +++ b/static/views/devtools/style/reset.css @@ -0,0 +1,11 @@ +* { + box-sizing: border-box; +} + +label { + user-select: none; +} + +body, pre { + margin: 0; +} \ No newline at end of file diff --git a/static/views/devtools/style/theme.css b/static/views/devtools/style/theme.css new file mode 100644 index 0000000..7186311 --- /dev/null +++ b/static/views/devtools/style/theme.css @@ -0,0 +1,65 @@ +:root { + --spacing-xs: 0.125rem; + --spacing-s: 0.25rem; + --spacing-m: 0.5rem; + --spacing-l: 1rem; + + --color-text-primary: #181818; + --color-text-note: #31313199; + --color-background-primary: #ffffff; + --color-background-secondary: #f3f3f3; + --color-background-tertiary: #eaeaea; + --color-border: #e8e8e8; + + --color-tab-background: var(--color-background-secondary); + --color-tab-background-hover: var(--color-background-secondary); + --color-tab-inactive: transparent; + --color-tab-active: #1155cc; + + --color-button-border: #d3d3d3; + + --color-input-invalid: #ff000050; + --color-input-border: #31313199; + + --color-event-border-active: color-mix(in srgb, var(--color-tab-active) 50%, var(--color-background-primary)); + --color-event-background-active: color-mix(in srgb, var(--color-tab-active) 5%, var(--color-background-primary)); + + --event-highlight-strength: 5%; + --color-event-ack: color-mix(in srgb, teal var(--event-highlight-strength), var(--color-background-primary)); + --color-event-success: color-mix(in srgb, lime var(--event-highlight-strength), var(--color-background-primary)); + --color-event-failure: color-mix(in srgb, red var(--event-highlight-strength), var(--color-background-primary)); + --color-event-warning: color-mix(in srgb, orange var(--event-highlight-strength), var(--color-background-primary)); + + --color-log-timestamp: #666; + --color-log-level-default: black; + --color-log-level-info: blue; + --color-log-level-warn: orange; + --color-log-level-error: red; + --color-log-level-event: yellow; + + --font-family: sans-serif; + --font-size: 0.75rem; + + --font-family-timestamp: monospace; + --font-size-timestamp: 0.625rem; + + --font-family-version: monospace; + --font-size-version: 0.625rem; + --color-version: #666; + + --transition-duration: 0.2s; +} + +@media (prefers-color-scheme: dark) { + :root { + --event-highlight-strength: 10%; + --color-text-primary: #e7e7e7; + --color-text-note: #cecece99; + --color-background-primary: #334; + --color-background-secondary: #445; + --color-background-tertiary: #556; + --color-border: #171717; + --color-button-border: #5a5a5a; + --color-version: #aaa; + } +} diff --git a/static/views/installed.js b/static/views/installed.js index 0234335..2a43531 100644 --- a/static/views/installed.js +++ b/static/views/installed.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2024 Estonian Information System Authority + * Copyright (c) 2023-2025 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 diff --git a/static/views/options.html b/static/views/options.html new file mode 100644 index 0000000..9cbf7ae --- /dev/null +++ b/static/views/options.html @@ -0,0 +1,85 @@ + + + + + + + + + + + +
+
+ + +
+
The extension needs storage permissions to persist the settings.
+
For the "Web eID" tab to appear in the browser's DevTools, you will need to close an existing DevTools and reopen it.
+
+ + + diff --git a/static/views/options.js b/static/views/options.js new file mode 100644 index 0000000..14d5052 --- /dev/null +++ b/static/views/options.js @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024-2025 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. + */ + +const ui = { + devtools: document.querySelector("#devtools"), + storageNotAllowed: document.querySelector(".warning.storage-not-allowed"), + openDevToolsAgain: document.querySelector(".warning.open-devtools-again"), + +}; + +(async () => { + if (await browser.permissions.contains({ permissions: ["storage"] })) { + const { devtoolsEnabled } = await browser.storage.local.get(["devtoolsEnabled"]); + + ui.devtools.checked = Boolean(devtoolsEnabled); + } +})(); + +ui.devtools.addEventListener("change", async () => { + const hasStoragePermission = await browser.permissions.request({ + permissions: ["storage"] + }); + + if (!hasStoragePermission) { + ui.storageNotAllowed.style.display = 'block'; + } else { + ui.storageNotAllowed.style.display = 'none'; + + browser.storage.local.set({ devtoolsEnabled: ui.devtools.checked }); + + ui.openDevToolsAgain.style.display = ( + ui.devtools.checked + ? 'block' + : 'none' + ); + } +});