Newer
Older
gnexus-creds / extensions / extension / src / pack-shared.js
/**
 * Design-resource pack: shared resolver for the server-published pack
 * (extensions/pack, served by the backend at /api/v1/extension/pack*).
 *
 * MV3 forbids executing JS from the network in any extension context, so the
 * pack is CSS + DATA only. This module resolves raw pack texts into safe
 * values: a config whitelist with clamps and additive-over-default keyword
 * tables for field-detect.js. The same UMD pattern as field-detect.js lets
 * one file serve the content script, the popup and (via the classic-script
 * bundling in tools/bundle-background.js) the background worker.
 *
 * Trust split: the background worker hashes every file against pack.json
 * (sha256) before storing; this module never re-hashes — a content script may
 * run on a plain-http page where crypto.subtle is unavailable. Storage is
 * per-extension, so a validated pack read back here is the one the SW stored.
 */
(function (root, factory) {
	const api = factory();
	if (typeof module !== "undefined" && module.exports) {
		module.exports = api;
	} else {
		root.GnPack = api;
	}
})(typeof self !== "undefined" ? self : globalThis, function () {
	"use strict";

	// The pack may only ever change data the bundled code already consumes.
	const PACK_FILES = ["content.css", "popup.css", "tables.json", "config.json"];
	const MAX_FILE_CHARS = 262144; // per-file cap; the whole pack is < 10KB today

	const DEFAULT_CONFIG = freezeDeep({
		timings: { scanDebounceMs: 300, cardExitMs: 280 },
		strings: { use: "Use", dismiss: "Dismiss", selectAccount: "Select account" },
	});

	const EMPTY_TABLES = freezeDeep({
		usernameKeywords: [],
		passwordKeywords: [],
		antiKeywords: [],
		autocompleteTokens: {},
	});

	function freezeDeep(value) {
		if (value && typeof value === "object") {
			for (const key of Object.keys(value)) freezeDeep(value[key]);
			Object.freeze(value);
		}
		return value;
	}

	const globalRoot = typeof self !== "undefined" ? self : globalThis;

	// Bundled defaults live in field-detect.js (loaded before this file in the
	// content script); the popup and node tests can pass them explicitly.
	function defaultTables() {
		if (typeof globalRoot.GnCredsDetect !== "undefined" && globalRoot.GnCredsDetect.keywordTables) {
			return globalRoot.GnCredsDetect.keywordTables;
		}
		return EMPTY_TABLES;
	}

	function isPlainObject(value) {
		return value !== null && typeof value === "object" && !Array.isArray(value);
	}

	// "0.1.1" — dotted numeric compare, missing components count as 0. Anything
	// unparseable is treated as incompatible (bundled behavior wins, safe side).
	function isCompatible(minExtensionVersion, ownVersion) {
		if (typeof minExtensionVersion !== "string" || typeof ownVersion !== "string") return false;
		const parse = (text) => text.split(".").map((part) => {
			const digits = /^(\d+)/.exec(part.trim());
			return digits ? Number(digits[1]) : -1;
		});
		const min = parse(minExtensionVersion);
		const own = parse(ownVersion);
		if (min.some((n) => n < 0) || own.some((n) => n < 0)) return false;
		for (let i = 0; i < Math.max(min.length, own.length); i++) {
			const a = min[i] || 0;
			const b = own[i] || 0;
			if (a !== b) return b > a;
		}
		return true;
	}

	function clampInt(value, min, max, fallback) {
		if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
		if (value < min) return min;
		if (value > max) return max;
		return Math.floor(value);
	}

	function clampWeight(value) {
		const num = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : null;
		if (num === null) return null;
		return Math.max(-200, Math.min(200, num));
	}

	// Keywords become regex fragments over the underscore-normalized haystack,
	// so anything beyond the normalize() alphabet can never match — reject it.
	const KEYWORD_RE = /^[a-z0-9а-яё_]{1,64}$/i;
	const TOKEN_RE = /^[a-z_]{1,40}$/;

	function validKeyword(value) {
		return typeof value === "string" && KEYWORD_RE.test(value);
	}

	/**
	 * Merge a raw pack tables object over the bundled tables. Additive only:
	 * keyword entries replace-or-append (deleting a bundled keyword requires an
	 * extension release), anti-keywords are a set-union, autocomplete tokens
	 * replace per key. Unknown keys/entries are ignored; every value is
	 * clamped, so a bad pack can only add signals, never break the detector.
	 */
	function resolveTables(raw, bundledOver) {
		const bundled = bundledOver || defaultTables();
		const merged = {
			usernameKeywords: bundled.usernameKeywords.slice(),
			passwordKeywords: bundled.passwordKeywords.slice(),
			antiKeywords: bundled.antiKeywords.slice(),
			autocompleteTokens: Object.assign({}, bundled.autocompleteTokens),
		};
		if (!isPlainObject(raw)) return merged;

		const replaceOrAdd = (target, entries) => {
			if (!Array.isArray(entries)) return;
			const byKeyword = new Map(target.map(([kw, weight]) => [kw, weight]));
			for (const entry of entries.slice(0, 512)) {
				if (!Array.isArray(entry)) continue;
				const [keyword, weight] = entry;
				if (!validKeyword(keyword)) continue;
				const clamped = clampWeight(weight);
				if (clamped === null) continue;
				byKeyword.set(keyword, clamped);
			}
			target.length = 0;
			for (const [kw, weight] of byKeyword) target.push([kw, weight]);
		};

		replaceOrAdd(merged.usernameKeywords, raw.usernameKeywords);
		replaceOrAdd(merged.passwordKeywords, raw.passwordKeywords);

		if (Array.isArray(raw.antiKeywords)) {
			for (const word of raw.antiKeywords.slice(0, 512)) {
				if (validKeyword(word) && !merged.antiKeywords.includes(word)) {
					merged.antiKeywords.push(word);
				}
			}
		}

		if (isPlainObject(raw.autocompleteTokens)) {
			for (const [token, weights] of Object.entries(raw.autocompleteTokens)) {
				if (!TOKEN_RE.test(token) || !isPlainObject(weights)) continue;
				const username = clampWeight(weights.username);
				const password = clampWeight(weights.password);
				if (username === null && password === null) continue;
				merged.autocompleteTokens[token] = {
					username: username === null ? 0 : username,
					password: password === null ? 0 : password,
				};
			}
		}

		return merged;
	}

	const TIMING_LIMITS = { scanDebounceMs: [30, 2000], cardExitMs: [50, 2000] };
	const STRING_KEYS = [
		"use", "dismiss", "selectAccount",
		// popup UI
		"drawerTitle", "serverUrl", "apiToken", "tokenHint", "save", "openApp",
		"openAppTitle", "refresh", "settingsTitle", "searchPlaceholder",
		"noSecrets", "saveCredentials", "account", "credentialsSaved",
		"enterToken", "settingsSaved", "error", "openOnSite",
		"tokenInvalid", "tokenId",
	];
	const LOCALIZED_LOCALES = ["en", "uk", "ru"];

	// English strings as shipped pre-localization; every other locale in
	// stringsByLocale must cover the same keys (falls back to these).
	const DEFAULT_STRINGS = {
		use: "Use",
		dismiss: "Dismiss",
		selectAccount: "Select account",
		drawerTitle: "Settings",
		serverUrl: "Server URL",
		apiToken: "API token",
		tokenHint: "Create a token with read, reveal, write scopes on the website.",
		save: "Save",
		openApp: "Open gnexus-creds",
		openAppTitle: "Open gnexus-creds",
		refresh: "Refresh",
		settingsTitle: "Settings",
		searchPlaceholder: "Search secrets...",
		noSecrets: "No secrets",
		saveCredentials: "Save credentials for {source}?",
		account: "Account:",
		credentialsSaved: "Credentials saved",
		enterToken: "Enter API token",
		settingsSaved: "Settings saved",
		error: "Error",
		openOnSite: "Open on gnexus-creds",
		tokenInvalid: "The saved API token no longer works — open Settings and paste a new one.",
		tokenId: "Token ID",
	};

	const DEFAULT_STRINGS_BY_LOCALE = {
		en: DEFAULT_STRINGS,
		uk: {},
		ru: {},
	};

	function resolveString(value, fallback) {
		if (typeof value !== "string") return fallback;
		const trimmed = value.trim();
		if (!trimmed || trimmed.length > 200) return fallback;
		return trimmed;
	}

	/**
	 * A pack config object may only carry keys the code already consumes:
	 * two timings, legacy card strings, and per-locale strings. Anything
	 * else is ignored; out-of-range timings and empty/oversized strings
	 * fall back to defaults.
	 */
	function resolveConfig(raw) {
		const resolved = {
			timings: Object.assign({}, DEFAULT_CONFIG.timings),
			strings: Object.assign({}, DEFAULT_CONFIG.strings),
			stringsByLocale: Object.assign({}, DEFAULT_STRINGS_BY_LOCALE),
		};
		if (!isPlainObject(raw)) return resolved;
		if (isPlainObject(raw.timings)) {
			for (const [key, [min, max]] of Object.entries(TIMING_LIMITS)) {
				const value = raw.timings[key];
				if (typeof value === "number" && Number.isFinite(value)) {
					resolved.timings[key] = clampInt(value, min, max, DEFAULT_CONFIG.timings[key]);
				}
			}
		}
		if (isPlainObject(raw.strings)) {
			for (const key of STRING_KEYS) {
				// never materialize keys the pack didn't send — undefined values
				// would leak into consumers that spread this over the defaults
				if (raw.strings[key] === undefined) continue;
				// ultimate fallback is the bundled English default
				const prior = resolved.strings[key] ?? DEFAULT_STRINGS[key];
				resolved.strings[key] = resolveString(raw.strings[key], prior);
			}
		}
		if (isPlainObject(raw.stringsByLocale)) {
			for (const locale of LOCALIZED_LOCALES) {
				const rawStrings = raw.stringsByLocale[locale];
				// per-key fallback is the bundled English default (DEFAULT_STRINGS)
				// — the merge may only override known keys with valid strings.
				const merged = Object.assign({}, DEFAULT_STRINGS_BY_LOCALE[locale]);
				if (isPlainObject(rawStrings)) {
					for (const key of STRING_KEYS) {
						merged[key] = resolveString(rawStrings[key], DEFAULT_STRINGS[key]);
					}
				}
				resolved.stringsByLocale[locale] = merged;
			}
		}
		return resolved;
	}

	/**
	 * Strings for one UI language: the per-locale pack dictionary (already
	 * resolved and clamped) over the legacy/bundled defaults. Unknown or
	 * empty lang falls back to English via DEFAULT_STRINGS.
	 */
	function resolveLocalized(config, lang) {
		const source = config || DEFAULT_CONFIG;
		const localized = source.stringsByLocale instanceof Object ? source.stringsByLocale : {};
		const byLocale = LOCALIZED_LOCALES.includes(lang) ? localized[lang] : null;
		return Object.assign({}, DEFAULT_STRINGS, source.strings || {}, byLocale || {});
	}

	// Shape validation with no clamping here — values were verified against
	// pack.json hashes in the background before this was stored.
	function validateStoredPack(stored, ownVersion) {
		if (!isPlainObject(stored) || !isPlainObject(stored.files) || !isPlainObject(stored.texts)) return null;
		const packVersion = stored.packVersion;
		if (typeof packVersion !== "number" || packVersion < 1) return null;
		// A pack that shipped markup this extension doesn't have yet must not
		// be adopted (covers the storage-survives-extension-update case).
		if (!isCompatible(stored.minExtensionVersion, ownVersion)) return null;
		if (typeof stored.maxExtensionVersion === "string"
			&& ownVersion.localeCompare(stored.maxExtensionVersion, undefined, { numeric: true }) > 0) {
			return null;
		}
		for (const name of Object.keys(stored.files)) {
			if (!PACK_FILES.includes(name) || typeof stored.texts[name] !== "string") return null;
			if (stored.texts[name].length > MAX_FILE_CHARS) return null;
		}
		if (!Object.keys(stored.files).length) return null;
		return stored;
	}

	/**
	 * Read the stored pack and return {pack, config, tables}. Never touches the
	 * network; callers should keep the bundled stylesheet as the fallback for
	 * pack === null. opts.tables overrides the default bundled tables lookup.
	 */
	async function load(opts) {
		let stored = null;
		try {
			stored = await (await chrome.storage.local.get("designPack")).designPack;
		} catch {
			stored = null; // no storage access — bundled behavior
		}
		const pack = validateStoredPack(stored, (globalRoot.chrome?.runtime?.getManifest?.() || { version: "" }).version);
		let config = DEFAULT_CONFIG;
		let tables = (opts && opts.tables) || defaultTables();
		if (pack) {
			const configText = pack.texts["config.json"];
			if (configText) {
				try {
					config = resolveConfig(JSON.parse(configText));
				} catch {
					config = DEFAULT_CONFIG;
				}
			}
			const tablesText = pack.texts["tables.json"];
			if (tablesText) {
				try {
					tables = resolveTables(JSON.parse(tablesText), tables);
				} catch {
					// keep bundled tables
				}
			}
		}
		return { pack, config, tables };
	}

	return {
		DEFAULT_CONFIG,
		EMPTY_TABLES,
		isCompatible,
		resolveConfig,
		resolveLocalized,
		resolveTables,
		validateStoredPack,
		load,
	};
});