diff --git a/extensions/extension/Makefile b/extensions/extension/Makefile index 2aa7e45..9defc32 100644 --- a/extensions/extension/Makefile +++ b/extensions/extension/Makefile @@ -1,5 +1,5 @@ NAME := gnexus-creds-extension -VERSION := 0.1.1 +VERSION := 0.2.0 # Per-file prerequisites: a directory prerequisite (plain "src") never goes # stale when only a file inside it is edited. SRC := manifest.json $(shell find src icons lib -type f) @@ -8,15 +8,23 @@ # UI kit source: the version pinned by the frontend's package-lock.json. KIT := ../../frontend/node_modules/gnexus-ui-kit/dist -.PHONY: all clean test lib chrome firefox +.PHONY: all clean test lib chrome firefox pack all: chrome firefox # Detection logic is tested without a browser: plain node over field-detect.js. # (Not chained to all/zip targets; run `make test` explicitly.) test: + node tools/test-pack-shared.js node tools/test-field-detect.js +# Publish the design-resource pack to ../../pack — decoupled from zip releases +# (not chained to all/): styling and detection-table changes reach clients +# without re-releasing the extension. `node tools/publish-design-pack.js +# --emit-tables` first generates editable snapshots in pack-src/. +pack: + node tools/publish-design-pack.js $(ARGS) + # Materialize lib/ from the frontend's gnexus-ui-kit install. Runs manually # (fresh clones need it before build); it is not chained to `all:` because # a newer kit rebuilds zips with different content than the released ones. diff --git a/extensions/extension/manifest.json b/extensions/extension/manifest.json index 2238c5f..c771e04 100644 --- a/extensions/extension/manifest.json +++ b/extensions/extension/manifest.json @@ -12,7 +12,7 @@ "content_scripts": [ { "matches": [""], - "js": ["src/field-detect.js", "src/content.js"], + "js": ["src/field-detect.js", "src/pack-shared.js", "src/content.js"], "run_at": "document_idle" } ], diff --git a/extensions/extension/pack-src/config.json b/extensions/extension/pack-src/config.json new file mode 100644 index 0000000..9bd18d3 --- /dev/null +++ b/extensions/extension/pack-src/config.json @@ -0,0 +1,11 @@ +{ + "timings": { + "scanDebounceMs": 300, + "cardExitMs": 280 + }, + "strings": { + "use": "Use", + "dismiss": "Dismiss", + "selectAccount": "Select account" + } +} diff --git a/extensions/extension/pack-src/tables.json b/extensions/extension/pack-src/tables.json new file mode 100644 index 0000000..c22d391 --- /dev/null +++ b/extensions/extension/pack-src/tables.json @@ -0,0 +1,255 @@ +{ + "usernameKeywords": [ + [ + "username", + 6 + ], + [ + "user_name", + 6 + ], + [ + "user_id", + 4 + ], + [ + "userid", + 4 + ], + [ + "login", + 5 + ], + [ + "log_in", + 3 + ], + [ + "logon", + 3 + ], + [ + "signin", + 2 + ], + [ + "email", + 5 + ], + [ + "e_mail", + 5 + ], + [ + "mail", + 3 + ], + [ + "correo", + 4 + ], + [ + "courriel", + 4 + ], + [ + "phone", + 4 + ], + [ + "tel", + 2 + ], + [ + "telefon", + 4 + ], + [ + "mobile", + 2 + ], + [ + "account", + 2 + ], + [ + "nick", + 3 + ], + [ + "handle", + 2 + ], + [ + "member", + 2 + ], + [ + "benutzer", + 5 + ], + [ + "benutzername", + 6 + ], + [ + "kennung", + 3 + ], + [ + "логин", + 6 + ], + [ + "пользователь", + 5 + ], + [ + "юзер", + 4 + ], + [ + "почта", + 5 + ], + [ + "телефон", + 4 + ], + [ + "usuario", + 5 + ], + [ + "utilisateur", + 4 + ] + ], + "passwordKeywords": [ + [ + "password", + 8 + ], + [ + "passwd", + 8 + ], + [ + "pwd", + 8 + ], + [ + "pass", + 7 + ], + [ + "pw", + 5 + ], + [ + "passwort", + 8 + ], + [ + "motdepasse", + 8 + ], + [ + "mot_de_passe", + 8 + ], + [ + "contrasena", + 8 + ], + [ + "contrasenia", + 8 + ], + [ + "senha", + 8 + ], + [ + "haslo", + 6 + ], + [ + "geslo", + 6 + ], + [ + "пароль", + 8 + ], + [ + "пассворд", + 6 + ] + ], + "antiKeywords": [ + "cc", + "card", + "cvc", + "cvv", + "security_code", + "search", + "query", + "filter", + "coupon", + "promo", + "newsletter", + "subscribe", + "subject", + "message", + "comment", + "first_name", + "lastname", + "last_name", + "fname", + "lname", + "birth", + "dob", + "zip", + "postal", + "amount", + "price", + "total", + "qty", + "country", + "city", + "street", + "address", + "otp", + "one_time", + "onetime", + "one_time_code", + "verification", + "confirm", + "repeat", + "retype", + "again" + ], + "autocompleteTokens": { + "username": { + "username": 9, + "password": 0 + }, + "current_password": { + "username": 0, + "password": 9 + }, + "new_password": { + "username": 0, + "password": 7 + }, + "email": { + "username": 7, + "password": 0 + }, + "tel": { + "username": 3, + "password": 0 + } + } +} diff --git a/extensions/extension/src/background.js b/extensions/extension/src/background.js index 5636a3d..64308c2 100644 --- a/extensions/extension/src/background.js +++ b/extensions/extension/src/background.js @@ -1,4 +1,5 @@ import { listSecrets, revealSecret, createSecret, getMe, getCategories } from "./api.js"; +import "./pack-shared.js"; const CACHE_TTL_MS = 5 * 60 * 1000; @@ -53,6 +54,104 @@ chrome.alarms.create("refresh-secrets-cache", { periodInMinutes: 15 }); +// --- Design-resource pack sync ---------------------------------------------- +// The pack (CSS + data tables) is fetched by the background worker only — +// unauthenticated (design data is not secret, and content scripts hold no +// token) — validated against pack.json sha256es and stored once in +// chrome.storage.local. Content script/popup only READ stored pack state; +// a page load never blocks on the network here. + +const PACK_REFRESH_ALARM = "refresh-design-pack"; +chrome.alarms.create(PACK_REFRESH_ALARM, { periodInMinutes: 720 }); + +const PACK_KNOWN_FILES = ["content.css", "popup.css", "tables.json", "config.json"]; +const PACK_FILE_LIMIT = 262144; // per-file cap echoed by pack-shared.js +const OWN_VERSION = chrome.runtime.getManifest().version; + +async function setPackStatus(fields) { + try { + await chrome.storage.local.set({ + designPackStatus: { ...fields, lastAttemptAt: Date.now() }, + }); + } catch { + // ignore + } +} + +function isPackSchemaValid(pack) { + if (!pack || typeof pack !== "object") return false; + if (!Number.isInteger(pack.packVersion) || pack.packVersion < 1) return false; + if (typeof pack.minExtensionVersion !== "string") return false; + if (pack.maxExtensionVersion != null && typeof pack.maxExtensionVersion !== "string") return false; + if (!pack.files || typeof pack.files !== "object") return false; + for (const [name, meta] of Object.entries(pack.files)) { + if (!PACK_KNOWN_FILES.includes(name)) return false; + if (!meta || typeof meta.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(meta.sha256)) return false; + if (!Number.isInteger(meta.size) || meta.size < 1 || meta.size > PACK_FILE_LIMIT) return false; + } + return Object.keys(pack.files).length > 0; +} + +async function refreshDesignPack(reason) { + const { baseUrl } = await getSettings(); + const base = String(baseUrl || "").replace(/\/+$/, ""); + if (!base) return; + try { + const response = await fetch(`${base}/api/v1/extension/pack`, { cache: "no-store" }); + if (!response.ok) throw new Error(`pack.json ${response.status}`); + const pack = await response.json(); + if (!isPackSchemaValid(pack)) throw new Error("bad_pack_schema"); + + const stored = (await chrome.storage.local.get("designPack")).designPack; + if (stored && pack.packVersion < stored.packVersion) { + await setPackStatus({ lastError: "", lastReason: "stale_pack_version" }); // rollback ignored + return; + } + // A pack declaring markup newer than this build's markup stays unadopted. + if (!self.GnPack.isCompatible(pack.minExtensionVersion, OWN_VERSION)) { + await setPackStatus({ lastError: "", lastReason: "extension_too_old" }); + return; + } + + const texts = {}; + for (const name of Object.keys(pack.files)) { + const fileResponse = await fetch( + `${base}/api/v1/extension/pack/file/${pack.packVersion}/${name}`, + { cache: "no-store" } + ); + if (!fileResponse.ok) throw new Error(`${name} ${fileResponse.status}`); + const text = await fileResponse.text(); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); + if (hex !== pack.files[name].sha256) throw new Error(`sha_mismatch:${name}`); + texts[name] = text; + } + + await chrome.storage.local.set({ + designPack: { + packVersion: pack.packVersion, + minExtensionVersion: pack.minExtensionVersion, + maxExtensionVersion: pack.maxExtensionVersion ?? null, + publishedAt: pack.publishedAt ?? null, + adoptedAt: Date.now(), + files: pack.files, + texts, + }, + designPackStatus: { lastAttemptAt: Date.now(), lastError: "", lastReason: reason }, + }); + } catch (err) { + await setPackStatus({ lastError: String(err.message || err), lastReason: reason }); + } +} + +// These top-level registrations survive the MV3 service worker sleeping. +chrome.runtime.onInstalled.addListener((details) => { + refreshDesignPack(details.reason === "update" ? "update" : "install"); +}); +chrome.runtime.onStartup.addListener(() => { + refreshDesignPack("startup"); +}); + chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name !== "refresh-secrets-cache") return; try { @@ -158,6 +257,27 @@ sendResponse({ ok: true }); } break; + case "REFRESH_DESIGN_PACK": + { + await refreshDesignPack("manual"); + const { designPack } = await chrome.storage.local.get("designPack"); + sendResponse({ ok: true, data: { packVersion: designPack ? designPack.packVersion : null } }); + } + break; + case "PACK_STATUS": + { + const { designPack, designPackStatus } = await chrome.storage.local.get(["designPack", "designPackStatus"]); + sendResponse({ + ok: true, + data: { + packVersion: designPack ? designPack.packVersion : null, + publishedAt: designPack ? designPack.publishedAt : null, + adoptedAt: designPack ? designPack.adoptedAt : null, + status: designPackStatus || null, + }, + }); + } + break; default: sendResponse({ ok: false, error: "Unknown message type" }); } diff --git a/extensions/extension/src/content.js b/extensions/extension/src/content.js index 1ddfe8f..1d17197 100644 --- a/extensions/extension/src/content.js +++ b/extensions/extension/src/content.js @@ -8,6 +8,17 @@ let autofillShadow = null; let cardStylesPromise = null; +// --- Design-resource pack state --- +// pack-shared.js loads before this file (manifest content_scripts order) and +// exposes GnPack; field-detect.js exposes GnCredsDetect. The stored pack is +// read from chrome.storage.local only — never fetched here, so a page load +// never waits on the network. Bundled defaults are the fallback. +const packState = { + pack: null, + config: GnPack.DEFAULT_CONFIG, + tables: GnCredsDetect.keywordTables, +}; + // --- Messaging helper --- function send(type, payload) { @@ -30,9 +41,11 @@ function debounce(fn, ms) { let timer; + // ms may be a number or a getter function (pack config arrives async). + const delay = typeof ms === "function" ? ms : () => ms; return (...args) => { clearTimeout(timer); - timer = setTimeout(() => fn(...args), ms); + timer = setTimeout(() => fn(...args), delay()); }; } @@ -97,7 +110,7 @@ 'input[type="password"], input[type="text"], input[type="email"], input[type="tel"], input[type="search"], input:not([type])' ); const fields = Array.from(inputs).map(makeFieldDescriptor); - return GnCredsDetect.findLoginTargets(fields); + return GnCredsDetect.findLoginTargets(fields, packState.tables); } // --- Autofill card --- @@ -110,6 +123,10 @@ const CARD_CSS_URL = chrome.runtime.getURL("src/content.css"); function getCardStyles() { + // Pack CSS comes from storage (already fetched by the background worker); + // the bundled content.css is the fallback for the no-pack case. + const packCss = packState.pack && packState.pack.texts["content.css"]; + if (packCss != null) return Promise.resolve(packCss); if (!cardStylesPromise) { cardStylesPromise = fetch(CARD_CSS_URL) .then((r) => (r.ok ? r.text() : "")) @@ -139,7 +156,7 @@ autofillHost = null; autofillShadow = null; } - }, 280); + }, packState.config.timings.cardExitMs); } function getAutofillContainer(shadow) { @@ -175,22 +192,22 @@ card.innerHTML = `
${escapeHtml(secret.title)}
- - + +
`; } else { const rows = secrets.map((s) => `
${escapeHtml(s.title)}
- +
`).join(""); card.innerHTML = ` -
Select account
+
${escapeHtml(packState.config.strings.selectAccount)}
${rows}
- +
`; } @@ -336,7 +353,7 @@ } } -const debouncedScan = debounce(scanPage, 300); +const debouncedScan = debounce(scanPage, () => packState.config.timings.scanDebounceMs); // Observe DOM changes for SPA-like navigation — filter for password inputs const observer = new MutationObserver((mutations) => { @@ -355,13 +372,29 @@ }); observer.observe(document.documentElement, { childList: true, subtree: true }); -// Initial scan -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", scanPage); -} else { +// Initial scan, after the stored design pack (if any) has been applied. +async function init() { + try { + const loaded = await GnPack.load({ tables: GnCredsDetect.keywordTables }); + packState.pack = loaded.pack; + packState.config = loaded.config; + packState.tables = loaded.tables; + if (loaded.pack) { + console.debug(`[gnexus-creds] design pack v${loaded.pack.packVersion} loaded`); + } + } catch (err) { + console.debug("[gnexus-creds] design pack unavailable, using bundled defaults:", err?.message); + } + if (document.readyState === "loading") { + await new Promise((resolve) => + document.addEventListener("DOMContentLoaded", resolve, { once: true }) + ); + } scanPage(); } +init(); + function escapeHtml(text) { if (text == null) return ""; const div = document.createElement("div"); diff --git a/extensions/extension/src/field-detect.js b/extensions/extension/src/field-detect.js index 7bbafed..4c6c71e 100644 --- a/extensions/extension/src/field-detect.js +++ b/extensions/extension/src/field-detect.js @@ -77,6 +77,16 @@ tel: { username: 3, password: 0 }, }; + // Bundled defaults as one snapshot — shared between the scoring functions + // (so a server-published pack can override them via findLoginTargets' + // tables argument, see pack-shared.js) and the pack publisher. + const DEFAULT_TABLES = { + usernameKeywords: USERNAME_KEYWORDS, + passwordKeywords: PASSWORD_KEYWORDS, + antiKeywords: ANTI_KEYWORDS, + autocompleteTokens: AUTOCOMPLETE_TOKENS, + }; + function normalize(text) { return String(text == null ? "" : text) .toLowerCase() @@ -105,12 +115,12 @@ return 0; } - function autocompleteBonus(ac, kind) { - const token = AUTOCOMPLETE_TOKENS[ac]; + function autocompleteBonus(ac, kind, tables) { + const token = tables.autocompleteTokens[ac]; return token ? token[kind] : 0; } - function scoreUsername(field) { + function scoreUsername(field, tables) { let score = 0; switch (field.type) { case "email": score += 5; break; @@ -122,11 +132,11 @@ const ac = normalize(field.autocomplete); const penalty = autocompletePenalty(ac); if (penalty < 0) return penalty; - score += autocompleteBonus(ac, "username"); + score += autocompleteBonus(ac, "username", tables); score += keywordScore(normalize( `${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}` - ), USERNAME_KEYWORDS); - for (const anti of ANTI_KEYWORDS) { + ), tables.usernameKeywords); + for (const anti of tables.antiKeywords) { if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8; // placeholder/labels mention search and coupons even on login pages, // so anti-words only count in name/id @@ -137,7 +147,7 @@ return score; } - function scorePassword(field) { + function scorePassword(field, tables) { let score = 0; if (field.type === "password") score += 12; else if (PASSWORD_TEXT_TYPES.has(field.type)) { /* text/tel with a password hint */ } @@ -145,11 +155,11 @@ const ac = normalize(field.autocomplete); const penalty = autocompletePenalty(ac); if (penalty < 0) return penalty; - score += autocompleteBonus(ac, "password"); + score += autocompleteBonus(ac, "password", tables); score += keywordScore(normalize( `${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}` - ), PASSWORD_KEYWORDS); - for (const anti of ANTI_KEYWORDS) { + ), tables.passwordKeywords); + for (const anti of tables.antiKeywords) { if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8; } if (!field.visible) score -= 50; @@ -161,20 +171,20 @@ // Passwords qualify unconditionally on type="password" (the strongest // signal there is) or, for text/tel fields, on a single strong keyword // (e.g. type="text" with name="pass" — JS-revealed password inputs). - function isPasswordCandidate(field) { - if (field.type === "password") return scorePassword(field) > -1000; + function isPasswordCandidate(field, tables) { + if (field.type === "password") return scorePassword(field, tables) > -1000; if (!PASSWORD_TEXT_TYPES.has(field.type)) return false; - return scorePassword(field) >= 7; + return scorePassword(field, tables) >= 7; } // The username is searched inside the form; for SPA inputs without a //
, walk up to three ancestor levels before giving up. - function findContextFor(field, fields) { + function findContextFor(field, fields, tables) { if (field.formEl) return field.formEl; let current = field.el.parentElement; for (let depth = 0; current && depth < 3; depth++) { const hasUsername = fields.some( - (f) => f !== field && current.contains(f.el) && scoreUsername(f) > 0 + (f) => f !== field && current.contains(f.el) && scoreUsername(f, tables) > 0 ); if (hasUsername) return current; current = current.parentElement; @@ -210,14 +220,15 @@ return fallback || null; } - function findLoginTargets(fields) { + function findLoginTargets(fields, tables) { + tables = tables || DEFAULT_TABLES; const scored = fields.map((field) => ({ ...field, - usernameScore: scoreUsername(field), - passwordScore: scorePassword(field), + usernameScore: scoreUsername(field, tables), + passwordScore: scorePassword(field, tables), })); const candidates = scored - .filter((f) => isPasswordCandidate(f)) + .filter((f) => isPasswordCandidate(f, tables)) .sort((a, b) => b.passwordScore - a.passwordScore || a.index - b.index); const usedContexts = new Set(); // one card per form @@ -225,7 +236,7 @@ const targets = []; for (const password of candidates) { - const context = findContextFor(password, scored); + const context = findContextFor(password, scored, tables); const contextKey = context || password.el; if (usedContexts.has(contextKey)) continue; // merged into the first password of the form usedContexts.add(contextKey); @@ -241,6 +252,7 @@ } return { + keywordTables: DEFAULT_TABLES, scoreUsername, scorePassword, isPasswordCandidate, diff --git a/extensions/extension/src/pack-shared.js b/extensions/extension/src/pack-shared.js new file mode 100644 index 0000000..b11aee9 --- /dev/null +++ b/extensions/extension/src/pack-shared.js @@ -0,0 +1,269 @@ +/** + * 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"]; + + function resolveString(value, fallback) { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 60) return fallback; + return trimmed; + } + + /** + * A pack config object may only carry keys the code already consumes: + * two timings and the three card 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), + }; + 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) { + resolved.strings[key] = resolveString(raw.strings[key], DEFAULT_CONFIG.strings[key]); + } + } + return resolved; + } + + // 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, + resolveTables, + validateStoredPack, + load, + }; +}); \ No newline at end of file diff --git a/extensions/extension/src/popup/popup.html b/extensions/extension/src/popup/popup.html index cbbf274..a879b42 100644 --- a/extensions/extension/src/popup/popup.html +++ b/extensions/extension/src/popup/popup.html @@ -8,6 +8,7 @@ +
diff --git a/extensions/extension/src/popup/popup.js b/extensions/extension/src/popup/popup.js index bc737f3..ca7267c 100644 --- a/extensions/extension/src/popup/popup.js +++ b/extensions/extension/src/popup/popup.js @@ -51,6 +51,7 @@
+
`; return div; }, @@ -157,7 +158,38 @@ }); } +// Pack CSS (fetched by the background worker into chrome.storage.local) +// overrides the bundled popup.css when present — appended after the +// stylesheets so equal-specificity rules win. Returns the active pack. +async function applyPackStyles() { + try { + if (!window.GnPack) return null; + const manifestVersion = chrome.runtime.getManifest().version; + const stored = (await chrome.storage.local.get("designPack")).designPack; + const pack = GnPack.validateStoredPack(stored, manifestVersion); + const css = pack && pack.texts["popup.css"]; + if (css) { + const style = document.createElement("style"); + style.dataset.gnexusCredsCss = "pack"; + style.textContent = css; + document.head.appendChild(style); + } + return pack; + } catch { + return null; + } +} + +// The status line lives in the Settings drawer body; query it on each open — +// the drawer body may not be in the DOM when the popup first loads. +function updatePackStatus(pack) { + const el = document.getElementById("pack-status"); + if (el) el.textContent = pack ? `Design pack v${pack.packVersion}` : ""; +} + async function init() { + const packStatus = await applyPackStyles(); + updatePackStatus(packStatus); try { const settings = await sendMessage("GET_SETTINGS"); baseUrlInput.value = settings.baseUrl || "https://creds.gnexus.space"; @@ -271,7 +303,11 @@ } }); -openSettingsBtn.addEventListener("click", () => settingsDrawer.show()); +openSettingsBtn.addEventListener("click", async () => { + settingsDrawer.show(); + // body() rebuilds on show() — set the status line after that + updatePackStatus(await applyPackStyles()); +}); openSiteBtn.addEventListener("click", () => { const base = baseUrlInput.value.trim() || "https://creds.gnexus.space"; diff --git a/extensions/extension/tools/bundle-background.js b/extensions/extension/tools/bundle-background.js index 996e55e..05bbf19 100644 --- a/extensions/extension/tools/bundle-background.js +++ b/extensions/extension/tools/bundle-background.js @@ -1,8 +1,9 @@ #!/usr/bin/env node /** - * Bundle src/api.js + src/background.js into a single classic (non-module) - * background.js for Firefox MV3, which does not support background.service_worker - * or type:"module" (event-page scripts run as classic scripts sharing one scope). + * Bundle src/api.js, src/pack-shared.js and src/background.js into a single + * classic (non-module) background.js for Firefox MV3, which does not support + * background.service_worker or type:"module" (event-page scripts run as + * classic scripts sharing one scope). * * Usage: node tools/bundle-background.js */ @@ -16,16 +17,18 @@ } const apiPath = path.join(dir, "src", "api.js"); +const packPath = path.join(dir, "src", "pack-shared.js"); const bgPath = path.join(dir, "src", "background.js"); // Strip ES module keywords so the result is valid as a classic script. const api = fs.readFileSync(apiPath, "utf8").replace(/^export /gm, ""); -const bg = fs.readFileSync(bgPath, "utf8").replace( - /^\s*import\s+.*\s+from\s+["']\.\/api\.js["'];\s*\n/m, - "" -); +const pack = fs.readFileSync(packPath, "utf8").replace(/^export /gm, ""); +const bg = fs.readFileSync(bgPath, "utf8") + .replace(/^\s*import\s+.*\s+from\s+["']\.\/api\.js["'];\s*\n/m, "") + .replace(/^\s*import\s+["']\.\/pack-shared\.js["'];\s*\n/m, ""); // api.js function declarations are hoisted into the shared classic scope, // so background.js can reference them without an import. -fs.writeFileSync(bgPath, api.trimEnd() + "\n\n" + bg); -fs.rmSync(apiPath); \ No newline at end of file +fs.writeFileSync(bgPath, api.trimEnd() + "\n\n" + pack.trimEnd() + "\n\n" + bg); +fs.rmSync(apiPath); +fs.rmSync(packPath); \ No newline at end of file diff --git a/extensions/extension/tools/publish-design-pack.js b/extensions/extension/tools/publish-design-pack.js new file mode 100644 index 0000000..0788a91 --- /dev/null +++ b/extensions/extension/tools/publish-design-pack.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Publish the extension design pack into ../../pack (served by the backend at + * /api/v1/extension/pack*). Sources: src/content.css, src/popup/popup.css, + * pack-src/tables.json and pack-src/config.json (optional, hand-edited — + * generate snapshots with --emit-tables / --emit-config). + * + * Content that differs from the currently published pack auto-bumps + * packVersion; identical content is a no-op. Files land in v/ first + * and pack.json is written last (via a .tmp rename), so a client never sees a + * pack.json pointing at files that are not served yet. + * + * Usage: node tools/publish-design-pack.js [--version N] [--min-extension X.Y.Z] + */ +const fs = require("fs"); +const path = require("path"); + +const EXTENSION_DIR = path.resolve(__dirname, ".."); +const PACK_DIR = path.resolve(EXTENSION_DIR, "..", "pack"); +const PACK_JSON = path.join(PACK_DIR, "pack.json"); +const PACK_FILES = ["content.css", "popup.css", "tables.json", "config.json"]; + +function args() { + const opts = {}; + const argv = process.argv.slice(2); + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--version") opts.version = Number(argv[++i]); + else if (argv[i] === "--min-extension") opts.minExtension = argv[++i]; + else if (argv[i] === "--emit-tables") opts.emitTables = true; + else if (argv[i] === "--emit-config") opts.emitConfig = true; + else { + console.error(`unknown argument: ${argv[i]}`); + process.exit(1); + } + } + return opts; +} + +function ownVersion() { + const makefile = fs.readFileSync(path.join(EXTENSION_DIR, "Makefile"), "utf8"); + const match = /^VERSION\s*:?=\s*(.+)$/m.exec(makefile); + return match ? match[1].trim() : "0.0.0"; +} + +function readPackJson() { + try { + return JSON.parse(fs.readFileSync(PACK_JSON, "utf8")); + } catch { + return null; + } +} + +function hashAndSize(text) { + const crypto = require("crypto"); + const bytes = Buffer.from(text, "utf8"); + return { sha256: crypto.createHash("sha256").update(bytes).digest("hex"), size: bytes.length }; +} + +function emitSnapshots() { + const opts = args(); + const srcDir = path.join(EXTENSION_DIR, "pack-src"); + fs.mkdirSync(srcDir, { recursive: true }); + if (opts.emitTables) { + const snapshot = require(path.join(EXTENSION_DIR, "src", "field-detect.js")).keywordTables; + fs.writeFileSync( + path.join(srcDir, "tables.json"), + JSON.stringify(snapshot, null, "\t") + "\n" + ); + console.log("wrote pack-src/tables.json"); + } + if (opts.emitConfig) { + const packShared = require(path.join(EXTENSION_DIR, "src", "pack-shared.js")); + fs.writeFileSync( + path.join(srcDir, "config.json"), + JSON.stringify(packShared.DEFAULT_CONFIG, null, "\t") + "\n" + ); + console.log("wrote pack-src/config.json"); + } + if (opts.emitTables || opts.emitConfig) return; +} + +function publish() { + const opts = args(); + if (opts.emitTables || opts.emitConfig) return emitSnapshots(); + const current = readPackJson(); + const sources = { + "content.css": fs.readFileSync(path.join(EXTENSION_DIR, "src", "content.css"), "utf8"), + "popup.css": fs.readFileSync(path.join(EXTENSION_DIR, "src", "popup", "popup.css"), "utf8"), + }; + for (const name of ["tables.json", "config.json"]) { + const file = path.join(EXTENSION_DIR, "pack-src", name); + if (fs.existsSync(file)) sources[name] = fs.readFileSync(file, "utf8"); + } + + const unchanged = + current !== null && + PACK_FILES.every((name) => { + const dir = path.join(PACK_DIR, `v${current.packVersion}`); + const published = path.join(dir, name); + return sources[name] === undefined + ? !fs.existsSync(published) + : fs.existsSync(published) && fs.readFileSync(published, "utf8") === sources[name]; + }); + if (unchanged) { + console.log(`pack unchanged (v${current.packVersion}) — nothing to publish`); + return; + } + + const version = opts.version || (current ? current.packVersion + 1 : 1); + if (current && version <= current.packVersion) { + console.error(`refusing packVersion ${version}: content changed, must be > ${current.packVersion}`); + process.exit(1); + } + const minExtension = opts.minExtension || ownVersion(); + + const versionDir = path.join(PACK_DIR, `v${version}`); + fs.mkdirSync(versionDir, { recursive: true }); + const files = {}; + for (const [name, text] of Object.entries(sources)) { + fs.writeFileSync(path.join(versionDir, name), text); + const { sha256, size } = hashAndSize(text); + if (size > 262144) { + console.error(`${name} exceeds the 256KB client cap`); + process.exit(1); + } + files[name] = { sha256, size }; + } + + const packJson = { + packVersion: version, + minExtensionVersion: minExtension, + maxExtensionVersion: null, + publishedAt: new Date().toISOString(), + files, + }; + const tmp = PACK_JSON + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(packJson, null, "\t") + "\n"); + fs.renameSync(tmp, PACK_JSON); + for (const dir of fs.readdirSync(PACK_DIR)) { + if (dir === "v" + String(version) || dir === "pack.json") continue; + if (/^v\d+$/.test(dir)) + console.log(`note: older pack dir ${dir} remains on disk (clients read storage, server serves only v${version})`); + } + console.log(`published pack v${version} (minExtensionVersion ${minExtension})`); +} + +publish(); \ No newline at end of file diff --git a/extensions/extension/tools/test-field-detect.js b/extensions/extension/tools/test-field-detect.js index 2fb1400..51ffa21 100644 --- a/extensions/extension/tools/test-field-detect.js +++ b/extensions/extension/tools/test-field-detect.js @@ -5,6 +5,7 @@ */ const assert = require("assert"); const detect = require("../src/field-detect.js"); +const pack = require("../src/pack-shared.js"); let passed = 0; let failed = 0; @@ -230,5 +231,56 @@ check("confirm field loses to password", pairOf(detect.findLoginTargets([user, pass, confirm])), [["n0", "n1"]]); } +// --- 14. server-pack tables: additive keyword turns an unknown site field into a target --- +{ + nextNodeId = 0; + const tables = pack.resolveTables + ? pack.resolveTables({ + usernameKeywords: [["site_word", 9]], + }) + : null; + const user = field(makeNode(), { name: "site_word" }, 0); + const pass = field(makeNode(), { type: "password", name: "password" }, 1); + const root = makeNode(); + user.el.parentElement = root; + pass.el.parentElement = root; + user.formEl = root; + pass.formEl = root; + check("pack keyword matches field", pairOf(detect.findLoginTargets([user, pass], tables)), [["n0", "n1"]]); +} + +// --- 15. pack anti-keyword demotes a previously preferred username ------------ +{ + nextNodeId = 0; + const tables = pack.resolveTables({ antiKeywords: ["portal"] }); + const doomed = field(makeNode(), { name: "portal_login" }, 0); // bundled "login" hit (-8 now) + const rival = field(makeNode(), { name: "member_name" }, 1); // bundled "member" hit + const pass = field(makeNode(), { type: "password", name: "password" }, 2); + const root = makeNode(); + for (const f of [doomed, rival, pass]) { + f.el.parentElement = root; + f.formEl = root; + } + const withoutPack = pairOf(detect.findLoginTargets([doomed, rival, pass])); + const withPack = pairOf(detect.findLoginTargets([doomed, rival, pass], tables)); + check("pack anti-keyword flips the pick", withPack, [["n1", "n2"]]); + check("and without the pack the login hit wins", withoutPack, [["n0", "n2"]]); +} + +// --- 16. bundled defaults untouched by resolveTables ------------------------- +{ + nextNodeId = 0; + const before = JSON.stringify(detect.keywordTables); + pack.resolveTables({ usernameKeywords: [["zz", 5]], unknownKey: { junk: true } }); + check("bundled tables not mutated", JSON.stringify(detect.keywordTables), before); +} + +// --- 17. no tables argument behaves exactly like before ---------------------- +{ + nextNodeId = 0; + const legacy = detect.findLoginTargets([]); + check("no-arg call returns empty", legacy, []); +} + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); \ No newline at end of file diff --git a/extensions/extension/tools/test-pack-shared.js b/extensions/extension/tools/test-pack-shared.js new file mode 100644 index 0000000..dc9d922 --- /dev/null +++ b/extensions/extension/tools/test-pack-shared.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Plain-node test suite for src/pack-shared.js (no DOM, no deps). + * Usage: node tools/test-pack-shared.js + */ +const assert = require("assert"); +const pack = require("../src/pack-shared.js"); +const detect = require("../src/field-detect.js"); + +let passed = 0; +let failed = 0; + +function check(name, actual, expected) { + try { + assert.deepStrictEqual(actual, expected); + passed++; + } catch (e) { + failed++; + console.error(`FAIL ${name}\n expected: ${JSON.stringify(e.expected)}\n actual: ${JSON.stringify(e.actual)}`); + } +} + +// --- resolveConfig: whitelist + clamps -------------------------------------- +{ + const cfg = pack.resolveConfig({ + timings: { scanDebounceMs: 900, cardExitMs: 10, unknown: 1 }, + strings: { use: " Apply ", dismiss: "", selectAccount: "Чей аккаунт?" }, + unknownTop: "junk", + }); + check("timings clamped", cfg.timings, { scanDebounceMs: 900, cardExitMs: 50 }); + check("strings sanitized", cfg.strings, { + use: "Apply", dismiss: "Dismiss", selectAccount: "Чей аккаунт?", + }); + check("defaults on junk config", pack.resolveConfig("not an object").timings, { scanDebounceMs: 300, cardExitMs: 280 }); + check("junk string falls back", pack.resolveConfig({ strings: { use: 42 } }).strings.use, "Use"); + check("oversized string falls back", pack.resolveConfig({ strings: { use: "x".repeat(61) } }).strings.use.length, "Use".length); +} + +// --- resolveTables: additive merge over bundled defaults --------------------- +{ + const bundled = detect.keywordTables; + const merged = pack.resolveTables({ + + usernameKeywords: [["user_name", 9], ["brandlogin", 7], [null, 1], ["x*y", 3], ["brandlogin", 6]], + passwordKeywords: [["пароль2", 8]], + antiKeywords: ["brandotp"], + autocompleteTokens: { brandlogin: { username: 9, password: 0 }, "cc_": { username: 5 } }, + unknownKey: true, + }, detect.keywordTables); + const username = Object.fromEntries(merged.usernameKeywords); + check("existing keyword reweighted", username["user_name"], 9); + check("new keyword added", username["brandlogin"], 6); + check("invalid keyword dropped", username["x*y"], undefined); + check("new russian keyword added", Object.fromEntries(merged.passwordKeywords)["пароль2"], 8); + check("anti set-union", merged.antiKeywords.includes("brandotp") && merged.antiKeywords.includes("cc"), true); + check("token replaced", merged.autocompleteTokens.brandlogin, { username: 9, password: 0 }); + check("token with partial weights completed", merged.autocompleteTokens["cc_"], { username: 5, password: 0 }); + check("bundled password rows preserved", Object.fromEntries(merged.passwordKeywords)["password"], 8); + const snapshot = detect.keywordTables; + pack.resolveTables({ usernameKeywords: [["zz", 1]] }); + check("bundled tables stay frozen-ish", JSON.stringify(detect.keywordTables), JSON.stringify(snapshot)); +} + +// --- isCompatible ------------------------------------------------------------ +check("own newer than pack min", pack.isCompatible("0.1.1", "0.2.0"), true); +check("own equals pack min", pack.isCompatible("0.1.1", "0.1.1"), true); +check("own older than pack min", pack.isCompatible("0.2.0", "0.1.1"), false); +check("partial versions compare", pack.isCompatible("0.2", "0.1.9"), false); +check("junk stays incompatible", pack.isCompatible("latest", "0.2.0"), false); + +// --- validateStoredPack ------------------------------------------------------ +const validStore = { + packVersion: 2, + minExtensionVersion: "0.1.1", + maxExtensionVersion: null, + files: { "content.css": { sha256: "a".repeat(64), size: 100 } }, + texts: { "content.css": ".a{color:red}" }, +}; +check("valid store accepted", (pack.validateStoredPack(validStore, "0.1.1") || {}).packVersion, 2); +check("older extension rejected", pack.validateStoredPack(validStore, "0.1.0"), null); +check("unknown file name rejected", pack.validateStoredPack( + { ...validStore, files: { "evil.js": { sha256: "a".repeat(64) } }, texts: { "evil.js": "alert(1)" } }, + "0.1.1"), null); +check("oversized file rejected", pack.validateStoredPack( + { ...validStore, texts: { "content.css": "a".repeat(300000) } }, + "0.1.1"), null); + +// --- load(): no storage access -> bundled behavior --------------------------- +{ + global.chrome = undefined; + pack.load().then((loaded) => { + check("load without storage returns no pack", loaded.pack, null); + check("load config = defaults", loaded.config, pack.DEFAULT_CONFIG); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); + }); +} \ No newline at end of file diff --git a/extensions/manifest.json b/extensions/manifest.json index 58bb0a3..3d0005f 100644 --- a/extensions/manifest.json +++ b/extensions/manifest.json @@ -1,19 +1,19 @@ { - "version": "0.1.1", - "released_at": "2026-10-01T00:00:00Z", - "notes": "Rebuild on gnexus-ui-kit 1.0 and the new shield keyhole logo.", + "version": "0.2.0", + "released_at": "2026-10-02T00:00:00Z", + "notes": "Design-resource pack: CSS, detection tables and UI config refresh from the server without re-releasing the extension.", "builds": [ { "browser": "chrome", - "filename": "gnexus-creds-extension-chrome-0.1.1.zip", - "sha256": "377589dc7cc26d93acfdc4302ea47ef04f9b15e7fd7d5c18fc2611e4e0ed0a38", - "size": 5047736 + "filename": "gnexus-creds-extension-chrome-0.2.0.zip", + "sha256": "bb0357305abb69458b0c01a22e6a589dec59dd84e472d30ee24004c9f5adbe11", + "size": 5058889 }, { "browser": "firefox", - "filename": "gnexus-creds-extension-firefox-0.1.1.zip", - "sha256": "e8eaef44bbcd687c2670ce23eacdf61d0671beb5b1a9fb621c9ac3144294e10f", - "size": 5047441 + "filename": "gnexus-creds-extension-firefox-0.2.0.zip", + "sha256": "04dc5192356bb9efa3dd6b843abbe6f50b1080f0a81c6e998f56665d50730017", + "size": 5058059 } ] } \ No newline at end of file diff --git a/extensions/pack/pack.json b/extensions/pack/pack.json new file mode 100644 index 0000000..ade95a2 --- /dev/null +++ b/extensions/pack/pack.json @@ -0,0 +1,24 @@ +{ + "packVersion": 13, + "minExtensionVersion": "0.1.1", + "maxExtensionVersion": null, + "publishedAt": "2026-10-02T07:12:50.458Z", + "files": { + "content.css": { + "sha256": "e17800f82cbde350b6fe3c60746136260844ede20a5f1d4fbfd2d4930fcd6005", + "size": 2584 + }, + "popup.css": { + "sha256": "c786fc48ce13c75c7204094acb9429e08dcd397645b707c4593aecb96356d966", + "size": 291 + }, + "tables.json": { + "sha256": "2dad3767cfa96c0ca172ac8cc9304f72fd16a76559ce5a16d976c81e885f8b1c", + "size": 2211 + }, + "config.json": { + "sha256": "89dc827789c9901d96d17842bca85418f19757c589ff4c80c2dde4bc52d38e64", + "size": 160 + } + } +} diff --git a/extensions/pack/v13/config.json b/extensions/pack/v13/config.json new file mode 100644 index 0000000..9bd18d3 --- /dev/null +++ b/extensions/pack/v13/config.json @@ -0,0 +1,11 @@ +{ + "timings": { + "scanDebounceMs": 300, + "cardExitMs": 280 + }, + "strings": { + "use": "Use", + "dismiss": "Dismiss", + "selectAccount": "Select account" + } +} diff --git a/extensions/pack/v13/content.css b/extensions/pack/v13/content.css new file mode 100644 index 0000000..e508290 --- /dev/null +++ b/extensions/pack/v13/content.css @@ -0,0 +1,116 @@ +/* Card styles for the closed Shadow DOM root (content.js injects this file's + text into the shadow root; it is never added to the page stylesheet, so the + host page can neither restyle the card nor see our class names). + Palette and geometry mirror gnexus-ui-kit 1.0 — keep in sync with lib/. */ + +.gnexus-creds-autofill-container { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 2147483647; + display: flex; + flex-direction: column; + gap: 8px; + width: max-content; + max-width: calc(100% - 32px); + pointer-events: none; +} + +.gnexus-creds-autofill-card { + pointer-events: auto; + background: #16161e; + border: 2px solid rgba(192, 202, 245, 0.24); + border-left: 6px solid #7aa2f7; + color: #c0caf5; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 14px; + border-radius: 6px; + box-shadow: 0 14px 36px rgba(22, 22, 30, 0.36); + padding: 15px 18px; + min-width: 300px; + max-width: 460px; + width: 100%; + opacity: 0; + transform: translateY(-12px); + transition: opacity 0.28s ease, transform 0.28s ease; +} + +.gnexus-creds-autofill-card-visible { + opacity: 1; + transform: translateY(0); +} + +.gnexus-creds-autofill-card-exit { + opacity: 0; + transform: translateY(-12px); + transition: opacity 0.25s ease-in, transform 0.25s ease-in; +} + +.gnexus-creds-autofill-title { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #7aa2f7; + line-height: 1.25; + word-break: break-word; + margin: 0 0 12px; + font-size: 14px; +} + +.gnexus-creds-autofill-list { + max-height: 200px; + overflow-y: auto; + margin-bottom: 12px; +} + +.gnexus-creds-autofill-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 0; + border-bottom: 2px solid rgba(192, 202, 245, 0.08); +} + +.gnexus-creds-autofill-row:last-child { + border-bottom: none; +} + +.gnexus-creds-autofill-row-title { + font-size: 13px; + color: #c0caf5; + word-break: break-word; +} + +.gnexus-creds-autofill-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.gnexus-creds-autofill-btn { + background: #16161e; + border: 2px solid rgba(192, 202, 245, 0.24); + border-left: 6px solid #7aa2f7; + color: #7aa2f7; + padding: 8px 16px; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; + line-height: 1; + flex-shrink: 0; +} + +.gnexus-creds-autofill-btn-secondary { + border-left-width: 2px; + color: #a9b1d6; +} + +.gnexus-creds-autofill-btn:hover { + opacity: 0.85; +} \ No newline at end of file diff --git a/extensions/pack/v13/popup.css b/extensions/pack/v13/popup.css new file mode 100644 index 0000000..ecb886d --- /dev/null +++ b/extensions/pack/v13/popup.css @@ -0,0 +1,22 @@ +body { + width: 420px; + min-height: 500px; +} + +.page-header .page-header-content { + display: flex; + flex-direction: row; + align-items: center; +} + +.page-header-logo { + width: 28px; + height: 28px; + margin-right: 8px; +} + +.list-subtitle { + display: flex; + flex-wrap: wrap; + gap: 4px; +} diff --git a/extensions/pack/v13/tables.json b/extensions/pack/v13/tables.json new file mode 100644 index 0000000..c22d391 --- /dev/null +++ b/extensions/pack/v13/tables.json @@ -0,0 +1,255 @@ +{ + "usernameKeywords": [ + [ + "username", + 6 + ], + [ + "user_name", + 6 + ], + [ + "user_id", + 4 + ], + [ + "userid", + 4 + ], + [ + "login", + 5 + ], + [ + "log_in", + 3 + ], + [ + "logon", + 3 + ], + [ + "signin", + 2 + ], + [ + "email", + 5 + ], + [ + "e_mail", + 5 + ], + [ + "mail", + 3 + ], + [ + "correo", + 4 + ], + [ + "courriel", + 4 + ], + [ + "phone", + 4 + ], + [ + "tel", + 2 + ], + [ + "telefon", + 4 + ], + [ + "mobile", + 2 + ], + [ + "account", + 2 + ], + [ + "nick", + 3 + ], + [ + "handle", + 2 + ], + [ + "member", + 2 + ], + [ + "benutzer", + 5 + ], + [ + "benutzername", + 6 + ], + [ + "kennung", + 3 + ], + [ + "логин", + 6 + ], + [ + "пользователь", + 5 + ], + [ + "юзер", + 4 + ], + [ + "почта", + 5 + ], + [ + "телефон", + 4 + ], + [ + "usuario", + 5 + ], + [ + "utilisateur", + 4 + ] + ], + "passwordKeywords": [ + [ + "password", + 8 + ], + [ + "passwd", + 8 + ], + [ + "pwd", + 8 + ], + [ + "pass", + 7 + ], + [ + "pw", + 5 + ], + [ + "passwort", + 8 + ], + [ + "motdepasse", + 8 + ], + [ + "mot_de_passe", + 8 + ], + [ + "contrasena", + 8 + ], + [ + "contrasenia", + 8 + ], + [ + "senha", + 8 + ], + [ + "haslo", + 6 + ], + [ + "geslo", + 6 + ], + [ + "пароль", + 8 + ], + [ + "пассворд", + 6 + ] + ], + "antiKeywords": [ + "cc", + "card", + "cvc", + "cvv", + "security_code", + "search", + "query", + "filter", + "coupon", + "promo", + "newsletter", + "subscribe", + "subject", + "message", + "comment", + "first_name", + "lastname", + "last_name", + "fname", + "lname", + "birth", + "dob", + "zip", + "postal", + "amount", + "price", + "total", + "qty", + "country", + "city", + "street", + "address", + "otp", + "one_time", + "onetime", + "one_time_code", + "verification", + "confirm", + "repeat", + "retype", + "again" + ], + "autocompleteTokens": { + "username": { + "username": 9, + "password": 0 + }, + "current_password": { + "username": 0, + "password": 9 + }, + "new_password": { + "username": 0, + "password": 7 + }, + "email": { + "username": 7, + "password": 0 + }, + "tel": { + "username": 3, + "password": 0 + } + } +} diff --git a/gnexus_creds/api.py b/gnexus_creds/api.py index 5fdeb1f..e3f2df6 100644 --- a/gnexus_creds/api.py +++ b/gnexus_creds/api.py @@ -6,7 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from sqlalchemy import distinct, func, select from sqlalchemy.orm import Session, selectinload @@ -23,6 +23,7 @@ AuditEventRead, ExportResponse, ExtensionBuildRead, + ExtensionPackRead, ExtensionRead, ImportPayload, Page, @@ -624,6 +625,20 @@ released_at=raw.get("released_at"), notes=raw.get("notes"), builds=builds, + pack=_pack_summary(), + ) + + +def _pack_summary() -> ExtensionPackRead | None: + try: + raw = _load_pack_json() + except AppError: + return None + return ExtensionPackRead( + pack_version=raw.get("packVersion", 0), + min_extension_version=raw.get("minExtensionVersion"), + max_extension_version=raw.get("maxExtensionVersion"), + published_at=raw.get("publishedAt"), ) @@ -653,3 +668,55 @@ ) db.commit() return FileResponse(path, filename=filename) + + +def _pack_dir() -> Path: + return _extension_dir() / "pack" + + +def _load_pack_json() -> dict: + path = _pack_dir() / "pack.json" + if not path.is_file(): + raise AppError("pack_not_found", "Design resource pack not found.", status_code=404) + with path.open(encoding="utf-8") as fh: + return json.load(fh) + + +@router.get( + "/extension/pack", + tags=["extension"], + summary="Current design resource pack (CSS + data tables, no secrets)", +) +async def extension_pack() -> JSONResponse: + # Deliberately unauthenticated: the pack is design data, and the content + # script fetches it through the background worker with no token available. + # There is no Actor to attribute, so also no audit row per download. + return JSONResponse( + _load_pack_json(), + headers={"Cache-Control": "no-cache, must-revalidate"}, + ) + + +@router.get( + "/extension/pack/file/{version}/{filename}", + tags=["extension"], + summary="Download one pack file", +) +async def extension_pack_file(version: str, filename: str) -> FileResponse: + raw = _load_pack_json() + pack_version = raw.get("packVersion") + # The server is authoritative for exactly one version: older directories + # stay on disk only as leftovers, never as a served rollback. + if version != str(pack_version): + raise AppError("not_found", "Design pack version not found.", status_code=404) + if filename not in raw.get("files", {}): + raise AppError("not_found", "Design pack file not found.", status_code=404) + path = (_pack_dir() / f"v{pack_version}" / filename).resolve() + if not path.is_file() or not path.is_relative_to(_pack_dir().resolve()): + raise AppError("not_found", "Design pack file not found.", status_code=404) + media_type = "text/css" if filename.endswith(".css") else "application/json" + return FileResponse( + path, + media_type=media_type, + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) diff --git a/gnexus_creds/schemas.py b/gnexus_creds/schemas.py index 236f615..16bb97f 100644 --- a/gnexus_creds/schemas.py +++ b/gnexus_creds/schemas.py @@ -203,8 +203,16 @@ notes: str | None = None +class ExtensionPackRead(BaseModel): + pack_version: int + min_extension_version: str | None = None + max_extension_version: str | None = None + published_at: str | None = None + + class ExtensionRead(BaseModel): version: str released_at: datetime | None = None notes: str | None = None builds: list[ExtensionBuildRead] + pack: ExtensionPackRead | None = None diff --git a/tests/test_extension.py b/tests/test_extension.py index cbe99a5..220916f 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,5 +1,6 @@ """Tests for extension distribution endpoints.""" +import hashlib import json import zipfile from unittest.mock import MagicMock, patch @@ -15,6 +16,10 @@ zf.writestr("manifest.json", "{}") +def _sha256(path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + @pytest.fixture def extension_dir(tmp_path): ext = tmp_path / "extensions" @@ -93,4 +98,110 @@ async def test_extension_info_requires_auth(auth_app, patched_settings): async with AsyncClient(transport=ASGITransport(app=auth_app), base_url="http://test") as client: response = await client.get("/api/v1/extension") - assert response.status_code == 401 \ No newline at end of file + assert response.status_code == 401 + + +def _write_pack(ext, version=1): + pack_dir = ext / "pack" + version_dir = pack_dir / f"v{version}" + version_dir.mkdir(parents=True, exist_ok=True) + files = {} + contents = { + "content.css": ".gnexus-creds-autofill-card { border: 1px solid #aaa }\n", + "config.json": json.dumps({"timings": {"scanDebounceMs": 300, "cardExitMs": 280}}), + } + for name, text in contents.items(): + (version_dir / name).write_text(text) + files[name] = { + "sha256": hashlib.sha256(text.encode()).hexdigest(), + "size": len(text.encode()), + } + pack_json = { + "packVersion": version, + "minExtensionVersion": "0.1.0", + "maxExtensionVersion": None, + "publishedAt": "2026-10-02T00:00:00Z", + "files": files, + } + (pack_dir / "pack.json").write_text(json.dumps(pack_json)) + return pack_json + + +@pytest.mark.anyio +async def test_pack_json_is_public(app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack") + assert response.status_code == 200, response.text + data = response.json() + assert data["packVersion"] == 1 + assert data["minExtensionVersion"] == "0.1.0" + assert set(data["files"]) == {"content.css", "config.json"} + assert response.headers["cache-control"] == "no-cache, must-revalidate" + + +@pytest.mark.anyio +async def test_pack_json_public_without_token(auth_app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=auth_app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack") + assert response.status_code == 200, response.text + + +@pytest.mark.anyio +async def test_pack_file_served_immutable(app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack/file/1/content.css") + assert response.status_code == 200, response.text + assert "immutable" in response.headers["cache-control"] + assert response.text == (extension_dir / "pack/v1/content.css").read_text() + + +@pytest.mark.anyio +async def test_pack_file_wrong_version_returns_404(app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack/file/2/content.css") + assert response.status_code == 404 + + +@pytest.mark.anyio +async def test_pack_file_unknown_filename_returns_404(app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack/file/1/nope.css") + assert response.status_code == 404 + + +@pytest.mark.anyio +async def test_pack_file_traversal_returns_404(app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack/file/1/..%2F..%2Fdist%2Fgnexus-creds-extension-chrome-0.1.0.zip") + assert response.status_code == 404 + + +@pytest.mark.anyio +async def test_pack_missing_returns_404(app, patched_settings): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension/pack") + assert response.status_code == 404 + assert response.json()["error"]["code"] == "pack_not_found" + + +@pytest.mark.anyio +async def test_extension_info_includes_pack(app, patched_settings, extension_dir): + _write_pack(extension_dir) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension") + assert response.status_code == 200, response.text + assert response.json()["pack"]["pack_version"] == 1 + + +@pytest.mark.anyio +async def test_extension_info_pack_null_when_absent(app, patched_settings): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v1/extension") + assert response.status_code == 200, response.text + assert response.json()["pack"] is None \ No newline at end of file