From 1b791cc1cf110b44d9b898f6417485879eef9871 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:26:14 +0100 Subject: [PATCH 01/12] Show pairing as verified only after a successful handshake --- options.js | 140 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 122 insertions(+), 18 deletions(-) diff --git a/options.js b/options.js index ee46e02..b847d2c 100644 --- a/options.js +++ b/options.js @@ -7,53 +7,157 @@ const pairingEl = document.getElementById("pairingCode"); const pairingBtn = document.getElementById("savePairing"); const pairingStatusEl = document.getElementById("pairingStatus"); +let pairingStatusRevision = 0; + function showPairingStatus(text) { pairingStatusEl.textContent = text; } +function invalidatePairingStatus() { + pairingStatusRevision += 1; + return pairingStatusRevision; +} + +function isCurrentPairingOperation(revision, code) { + return ( + revision === pairingStatusRevision && + normalizePairingCode(pairingEl.value) === code + ); +} + +async function hasCookiePermission() { + try { + return await chrome.permissions.contains({ permissions: ["cookies"] }); + } catch { + // Permission API unavailable, or the browser refused the query. + return false; + } +} + +/// The background worker runs the same nonce/HMAC handshake used before a +/// paired handoff. A saved or well-formed code alone is never "Verified". +async function verifySavedPairing(code, cookieAccessGranted, revision) { + let reply; + try { + reply = await chrome.runtime.sendMessage({ type: "grab-status" }); + } catch { + // The worker may be restarting or unavailable. + } + + if (!isCurrentPairingOperation(revision, code)) return; + + // Protect against the code being changed from another Options page while + // the handshake was in flight. + let storedCode; + try { + storedCode = normalizePairingCode(await loadPairingCode()); + } catch { + if (isCurrentPairingOperation(revision, code)) { + showPairingStatus("Saved, but verification status is unavailable."); + } + return; + } + + if (!isCurrentPairingOperation(revision, code)) return; + if (storedCode !== code) { + showPairingStatus("Pairing code changed elsewhere; reload before verifying."); + return; + } + + if (reply && reply.state === "verified") { + showPairingStatus( + cookieAccessGranted + ? "Verified — Grab recognizes this code; cookie access is granted." + : "Verified — cookie access is not granted, so handoffs are URL-only.", + ); + } else if (reply && reply.state === "unverified") { + showPairingStatus( + "Saved, but not verified — Grab could not prove this pairing code.", + ); + } else if (reply && reply.state === "down") { + showPairingStatus( + "Saved, but Grab is not reachable; verification is pending.", + ); + } else if (reply && reply.state === "unpaired") { + showPairingStatus( + "Saved, but the background worker found no valid pairing code.", + ); + } else { + showPairingStatus("Saved, but verification status is unavailable."); + } +} + async function loadPairing() { - pairingEl.value = await loadPairingCode(); - showPairingStatus(pairingEl.value ? "Paired" : "Not paired: links only"); + const code = normalizePairingCode(await loadPairingCode()); + pairingEl.value = code; + const revision = invalidatePairingStatus(); + + if (!code) { + showPairingStatus("Not paired: links only"); + return; + } + if (!SHARED_PAIRING_CODE_RE.test(code)) { + showPairingStatus("Saved code has an invalid format; enter it again."); + return; + } + + showPairingStatus("Saved; verifying with Grab…"); + const cookieAccessGranted = await hasCookiePermission(); + await verifySavedPairing(code, cookieAccessGranted, revision); } -/// Pair (or unpair, with an empty field). The click is the user gesture the -/// browser requires to grant the optional cookies permission, which is asked -/// for only when a code is being stored. +/// Save a code (or unpair when empty), request optional cookie access, then +/// ask the background worker to cryptographically verify the saved code. async function savePairing() { + const revision = invalidatePairingStatus(); pairingEl.removeAttribute("aria-invalid"); const code = normalizePairingCode(pairingEl.value); + if (code === "") { await savePairingCode(""); - // Unpairing also gives back the cookies permission granted with the code. + // Always revoke cookie access when unpairing, even if the input is edited + // again while the storage operation is in flight. try { await chrome.permissions.remove({ permissions: ["cookies"] }); } catch { // Permissions API unavailable, or already removed. } - showPairingStatus("Not paired: links only"); + if (isCurrentPairingOperation(revision, "")) { + pairingEl.value = ""; + showPairingStatus("Not paired: links only"); + } return; } - if (!(await savePairingCode(code))) { - pairingEl.setAttribute("aria-invalid", "true"); - showPairingStatus("That isn't a pairing code (64 letters and digits)."); + + if (!SHARED_PAIRING_CODE_RE.test(code) || !(await savePairingCode(code))) { + if (revision === pairingStatusRevision) { + pairingEl.setAttribute("aria-invalid", "true"); + showPairingStatus("That isn't a pairing code (64 hexadecimal characters)."); + } return; } + + if (!isCurrentPairingOperation(revision, code)) return; pairingEl.value = code; - let granted = false; + showPairingStatus("Saved; verifying with Grab…"); + + // The click is the user gesture the browser requires to grant optional + // cookie access. Verification is separate from this permission decision. try { - granted = await chrome.permissions.request({ permissions: ["cookies"] }); + await chrome.permissions.request({ permissions: ["cookies"] }); } catch { - // Permission API unavailable: pair anyway; handoffs stay URL-only. + // The code can still be verified; handoffs remain URL-only without cookies. } - showPairingStatus( - granted - ? "Paired: cookies are shared with Grab" - : "Paired, but cookie access was not granted: links only", - ); + if (!isCurrentPairingOperation(revision, code)) return; + + const cookieAccessGranted = await hasCookiePermission(); + await verifySavedPairing(code, cookieAccessGranted, revision); } pairingEl.addEventListener("input", () => { + invalidatePairingStatus(); pairingEl.removeAttribute("aria-invalid"); + showPairingStatus("Unsaved changes — save to verify or unpair."); }); pairingBtn.addEventListener("click", savePairing); From 894df882973b6a28bfcb5734ba2f2fb59d0d715d Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:26:23 +0100 Subject: [PATCH 02/12] Label pairing action as save and verify --- options.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options.html b/options.html index 87064d2..612191e 100644 --- a/options.html +++ b/options.html @@ -228,7 +228,7 @@

Grab Options

need them. Without one, only links are sent. Leave empty to unpair.

- + From e0dfec0d7d90dc035f5477a1abe5e84dad836b30 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:27:03 +0100 Subject: [PATCH 03/12] Test pairing verification status in Options UI --- test/options.test.js | 180 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/options.test.js diff --git a/test/options.test.js b/test/options.test.js new file mode 100644 index 0000000..4993e39 --- /dev/null +++ b/test/options.test.js @@ -0,0 +1,180 @@ +// Focused tests for the Options page's pairing-status state machine. +// Run with: node --test test/*.test.js +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const OPTIONS_SOURCE = fs.readFileSync( + path.join(__dirname, "..", "options.js"), + "utf8", +); +const VALID_CODE = "ab".repeat(32); + +function makeElement(id) { + return { + id, + value: "", + textContent: "", + attributes: new Map(), + listeners: new Map(), + addEventListener(type, callback) { + this.listeners.set(type, callback); + }, + removeAttribute(name) { + this.attributes.delete(name); + }, + setAttribute(name, value) { + this.attributes.set(name, value); + }, + }; +} + +function makeOptions({ storedCode = VALID_CODE, cookiePermission = true } = {}) { + const elements = new Map(); + const pendingMessages = []; + let savedCode = storedCode; + + const document = { + getElementById(id) { + if (!elements.has(id)) elements.set(id, makeElement(id)); + return elements.get(id); + }, + }; + + const chrome = { + runtime: { + sendMessage(message) { + return new Promise((resolve, reject) => { + pendingMessages.push({ message, resolve, reject }); + }); + }, + }, + permissions: { + request: async () => cookiePermission, + contains: async () => cookiePermission, + remove: async () => true, + }, + }; + + const normalizePairingCode = (raw) => + String(raw || "").replace(/\\s+/g, "").toLowerCase(); + const pairingCodePattern = /^[0-9a-f]{64}$/; + const context = vm.createContext({ + document, + chrome, + SHARED_PAIRING_CODE_RE: pairingCodePattern, + normalizePairingCode, + bindSettingsForm() {}, + async loadPairingCode() { + return savedCode; + }, + async savePairingCode(raw) { + const code = normalizePairingCode(raw); + if (code !== "" && !pairingCodePattern.test(code)) return false; + savedCode = code; + return true; + }, + }); + + vm.runInContext(OPTIONS_SOURCE, context, { filename: "options.js" }); + + return { + elements, + pendingMessages, + get savedCode() { + return savedCode; + }, + }; +} + +async function waitFor(predicate, message) { + for (let i = 0; i < 100; i += 1) { + if (predicate()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + assert.fail(message || "Timed out waiting for Options page state"); +} + +test("saved code stays pending until the background worker verifies it", async () => { + const ui = makeOptions(); + const status = ui.elements.get("pairingStatus"); + + await waitFor(() => ui.pendingMessages.length === 1, "handshake message was not sent"); + assert.equal(ui.pendingMessages[0].message.type, "grab-status"); + assert.equal(status.textContent, "Saved; verifying with Grab…"); + assert.notEqual(status.textContent, "Verified"); + + ui.pendingMessages[0].resolve({ state: "verified" }); + await waitFor(() => status.textContent.startsWith("Verified")); + + assert.equal( + status.textContent, + "Verified — Grab recognizes this code; cookie access is granted.", + ); +}); + +test("a failed proof never labels the code Verified", async () => { + const ui = makeOptions(); + const status = ui.elements.get("pairingStatus"); + + await waitFor(() => ui.pendingMessages.length === 1); + ui.pendingMessages[0].resolve({ state: "unverified" }); + await waitFor(() => status.textContent.startsWith("Saved, but not verified")); + + assert.match(status.textContent, /could not prove this pairing code/i); + assert.equal(status.textContent.startsWith("Verified"), false); +}); + +test("an unreachable Grab remains Saved but not Verified", async () => { + const ui = makeOptions(); + const status = ui.elements.get("pairingStatus"); + + await waitFor(() => ui.pendingMessages.length === 1); + ui.pendingMessages[0].resolve({ state: "down" }); + await waitFor(() => status.textContent.includes("not reachable")); + + assert.equal(status.textContent.startsWith("Verified"), false); +}); + +test("editing the code invalidates a pending successful handshake", async () => { + const ui = makeOptions(); + const codeInput = ui.elements.get("pairingCode"); + const status = ui.elements.get("pairingStatus"); + + await waitFor(() => ui.pendingMessages.length === 1); + codeInput.value = "cd".repeat(32); + codeInput.listeners.get("input")(); + assert.equal(status.textContent, "Unsaved changes — save to verify or unpair."); + + ui.pendingMessages[0].resolve({ state: "verified" }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(status.textContent, "Unsaved changes — save to verify or unpair."); + assert.equal(status.textContent.startsWith("Verified"), false); +}); + +test("saving a new code shows Saved first and then the verification result", async () => { + const ui = makeOptions({ storedCode: "", cookiePermission: false }); + const codeInput = ui.elements.get("pairingCode"); + const saveButton = ui.elements.get("savePairing"); + const status = ui.elements.get("pairingStatus"); + + await waitFor(() => status.textContent === "Not paired: links only"); + codeInput.value = VALID_CODE.toUpperCase(); + codeInput.listeners.get("input")(); + + const savePromise = saveButton.listeners.get("click")(); + await waitFor(() => ui.pendingMessages.length === 1, "saved code was not checked"); + assert.equal(ui.savedCode, VALID_CODE); + assert.equal(status.textContent, "Saved; verifying with Grab…"); + + ui.pendingMessages[0].resolve({ state: "verified" }); + await savePromise; + + assert.equal( + status.textContent, + "Verified — cookie access is not granted, so handoffs are URL-only.", + ); +}); From 213ccc659356acf60dbe3c7b471a8961d558256e Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:27:57 +0100 Subject: [PATCH 04/12] Move compact Grab status into the popup header --- popup.html | 56 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/popup.html b/popup.html index 6afaaa7..6223d5e 100644 --- a/popup.html +++ b/popup.html @@ -11,6 +11,10 @@ --input-bg: #ffffff; --saved: #1a7f37; --accent: #1a73e8; + --status-ok: #188038; + --status-warning: #9a6700; + --status-down: #b3261e; + --status-neutral: #6b7280; } @media (prefers-color-scheme: dark) { :root { @@ -20,6 +24,10 @@ --input-bg: #2d2d2d; --saved: #4caf50; --accent: #8ab4f8; + --status-ok: #4caf50; + --status-warning: #e5c07b; + --status-down: #f28b82; + --status-neutral: #b0b0b0; } } body { @@ -35,7 +43,7 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 10px; margin-bottom: 12px; } h2 { @@ -130,28 +138,36 @@ margin: 12px 0 0; } .status { - font-size: 12px; - padding: 6px 10px; - border-radius: 6px; - margin: 0 0 12px; + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 1 auto; + min-width: 0; + margin: 0 0 0 auto; + color: var(--status-neutral); + font-size: 11px; + line-height: 1.2; + white-space: nowrap; + } + .status::before { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: currentColor; + content: ""; } .status.ok { - background: #e6f4ea; - color: #1a7f37; + color: var(--status-ok); + } + .status.warning { + color: var(--status-warning); } .status.down { - background: #fce8e6; - color: #c5221f; + color: var(--status-down); } - @media (prefers-color-scheme: dark) { - .status.ok { - background: #1a3a24; - color: #4caf50; - } - .status.down { - background: #3a1a1a; - color: #f28b82; - } + .status.neutral { + color: var(--status-neutral); } @media (prefers-reduced-motion: reduce) { #saved { @@ -163,6 +179,7 @@

Grab

+ Checking…
-
Checking Grab…
- +