Skip to content

feat(extension): browser extension package, demo harness, and CI (CEP-02) - #2244

Open
remicolin wants to merge 36 commits into
devfrom
feat/browser-extension-v1
Open

feat(extension): browser extension package, demo harness, and CI (CEP-02)#2244
remicolin wants to merge 36 commits into
devfrom
feat/browser-extension-v1

Conversation

@remicolin

@remicolin remicolin commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Linear: SELF-3691 (CEP-02). Specs: PR #2243 (specs/projects/sdk/workstreams/chrome-extension/).

Ports the validated spike prototype onto dev as the production baseline. The spike branch (feat/chrome-extension-spike) stays reference-only and never merges; this PR is the reviewed port.

What's in

  • packages/chrome-extension (new, ~1.5k LOC): MV3 shell hosting the webview-app build unmodified. Bridge-host implements the NativeTransport handler set; vault custody is passkey-first (WebAuthn PRF wraps a random key, Touch ID unlock) with password fallback and a lost-credential reset; account link via QR handshake + E2E-encrypted transfer with SAS emoji verification (hello handshake shows the 4 emojis on both devices BEFORE the phone's send button fires anything); site shim + content-script routing for the hosted-page trigger.
  • packages/extension-demo (new): internal test harness for the postMessage contract - real SelfBackendVerifier backend with a MOCK_DOCS registry flag. Not partner-facing (RP integration is Enterprise-only via the hosted verification page, per spec).
  • mobile-sdk-alpha: shared SAS module (utils/sas, 64-emoji table + sha256 derivation) with a known-vector test pinning the protocol; new export entry.
  • webview-app: home screen honors selectedDocumentId; Manage Documents lists the real catalog with tap-to-select for the active proving document.
  • app: dev-gated "Link browser extension" screen (settings dev menu). The production link flow is CEP-08; this keeps the flow demoable from dev.
  • CI: chrome-extension-ci.yml builds the full chain (common -> sdk -> bridge -> webview-app -> extension) and gates on two puppeteer harnesses against Chrome for Testing: boot-check (fresh install gates to the link page + QR renders) and import-check (full transfer over the real staging relayer: envelope relay, SAS match on both sides, custody, lock/unlock roundtrip, wrong-password rejection, reset). Store zip retained as a workflow artifact.

Validation (local, this branch)

  • Full build chain green; extension + app typecheck clean; mobile-sdk-alpha 532 tests pass.
  • boot-check and import-check harnesses green end-to-end against staging.

Notes for review

  • Spike screenshots are not ported; *.png is now gitignored in the package.
  • The gitleaks allowlist entry covers the MV3 manifest key field: it is the extension's PUBLIC key (pins the dev extension id), not a secret. Store builds will strip it (CEP-03).
  • Relayer: works against today's staging; self-infra PR Refactor generateCircuitInputsRegister with ecdsa circuit inputs #166 (session-relay robustness) removes the client-side ordering constraints and lets the two-room hello collapse later (CEP-01).
  • LOC is above the usual band because it ports a complete, already-QA'd package plus its harnesses in one reviewable unit; splitting would leave non-functional intermediate states.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Chrome extension “Link Self app” flow with QR-based encrypted transfer, step-by-step confirmation using SAS emojis, and proof success/error handling.
    • Added extension unlock support (password + passkey/Touch ID), plus locked-vault behavior and “Lock Self”.
    • Added new dev screen + shortcut for linking.
    • Added an extension demo app for end-to-end verification.
  • Bug Fixes
    • Improved document management by loading and persisting the selected document catalog.
  • CI / Tests
    • Added Chrome Extension CI with Puppeteer harnesses covering boot, link, import, relayer transfer, and disclosure wiring.
  • Documentation
    • Added README for the extension demo.

…-02, SELF-3691)

Ports the validated spike prototype onto dev as the production baseline
(spike branch feat/chrome-extension-spike remains reference-only):

- packages/chrome-extension: MV3 shell hosting the webview-app build
  (bridge-host NativeTransport, vault custody with passkey-first PRF +
  password fallback + reset, link/unlock pages, SAS emoji verification,
  site shim + content-script routing)
- packages/extension-demo: internal RP test harness for the postMessage
  contract (real SelfBackendVerifier backend, MOCK_DOCS registry flag)
- mobile-sdk-alpha: shared SAS module (utils/sas) + export
- webview-app: home honors selectedDocumentId; Manage Documents is
  tap-to-select for the active proving document
- app: dev-gated "Link browser extension" screen (production flow is
  CEP-08; this keeps the flow demoable from dev)
- CI: chrome-extension-ci.yml builds the chain and gates on the
  boot-check + import-check puppeteer harnesses (Chrome for Testing,
  staging relayer end-to-end); store zip retained as artifact
- boot-check now asserts the fresh-install custody gate (link page + QR)
  instead of the pre-custody app boot
- gitleaks allowlist: MV3 manifest "key" is the extension's public key

Spec: specs/projects/sdk/workstreams/chrome-extension/ (PR #2243).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
self-webview-app Ready Ready Preview Aug 4, 2026 2:02pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds a Manifest V3 Chrome extension with encrypted account linking, password and passkey vaults, popup verification bridging, a browser demo, mobile development tooling, persisted document selection, end-to-end harnesses, and GitHub Actions CI.

Changes

Chrome extension transfer and verification

Layer / File(s) Summary
Encrypted transfer and vault lifecycle
packages/chrome-extension/src/*, packages/chrome-extension/pages/*
Adds ECDH/AES-GCM transfer handling, encrypted vault storage, passkey unlock, account linking, and password/reset pages.
Verification bridge and popup lifecycle
packages/chrome-extension/sdk/*, packages/chrome-extension/src/background.ts, packages/chrome-extension/src/bridge-host.ts, packages/chrome-extension/src/content-script.ts, packages/chrome-extension/src/relayer-session.ts, packages/chrome-extension/src/verification-url.ts
Adds page messaging, gated popup verification, lifecycle result reporting, URL serialization, and bridge operations.

Mobile linking and demo integration

Layer / File(s) Summary
Mobile linking and document selection
app/src/navigation/*, app/src/screens/dev/*, packages/mobile-sdk-alpha/*, packages/webview-app/src/screens/home/*, packages/webview-app/tests/screens/home/*
Adds the mobile extension-linking screen and route, deterministic SAS emojis, and persisted document catalog selection.
Demo application and backend
packages/extension-demo/*
Adds a React/Vite demo, backend verification API, configuration, and setup documentation.

Build and validation

Layer / File(s) Summary
Build pipeline and end-to-end harnesses
.github/workflows/*, packages/chrome-extension/harness/*, packages/chrome-extension/scripts/*, packages/chrome-extension/package.json, packages/chrome-extension/manifest.json, pnpm-workspace.yaml, gitleaks-override.toml
Adds extension packaging, Chrome-based smoke and flow harnesses, CI execution, artifact upload, and related package and scanning configuration.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding the browser extension package, demo harness, and CI for CEP-02.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/browser-extension-v1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9fa866c9-a7b9-48c7-97af-889bcce91bc5

📥 Commits

Reviewing files that changed from the base of the PR and between 6ef1d6f and 3dfe327.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (44)
  • .github/workflows/chrome-extension-ci.yml
  • app/src/navigation/devTools.tsx
  • app/src/navigation/types.ts
  • app/src/screens/dev/LinkBrowserExtensionScreen.tsx
  • app/src/screens/dev/sections/DebugShortcutsSection.tsx
  • gitleaks-override.toml
  • packages/chrome-extension/.gitignore
  • packages/chrome-extension/harness/boot-check.mjs
  • packages/chrome-extension/harness/demo-check.mjs
  • packages/chrome-extension/harness/disclose-check.mjs
  • packages/chrome-extension/harness/import-check.mjs
  • packages/chrome-extension/harness/relayer-transfer.mjs
  • packages/chrome-extension/manifest.json
  • packages/chrome-extension/package.json
  • packages/chrome-extension/pages/link.html
  • packages/chrome-extension/pages/unlock.html
  • packages/chrome-extension/scripts/build.mjs
  • packages/chrome-extension/sdk/shim.ts
  • packages/chrome-extension/src/background.ts
  • packages/chrome-extension/src/bridge-host.ts
  • packages/chrome-extension/src/content-script.ts
  • packages/chrome-extension/src/crypto.ts
  • packages/chrome-extension/src/link.ts
  • packages/chrome-extension/src/passkey.ts
  • packages/chrome-extension/src/relayer-session.ts
  • packages/chrome-extension/src/unlock.ts
  • packages/chrome-extension/src/vault.ts
  • packages/chrome-extension/src/verification-url.ts
  • packages/chrome-extension/tsconfig.json
  • packages/extension-demo/README.md
  • packages/extension-demo/backend/server.mjs
  • packages/extension-demo/index.html
  • packages/extension-demo/package.json
  • packages/extension-demo/src/App.tsx
  • packages/extension-demo/src/main.tsx
  • packages/extension-demo/tsconfig.json
  • packages/extension-demo/vite.config.ts
  • packages/mobile-sdk-alpha/package.json
  • packages/mobile-sdk-alpha/src/utils/sas.ts
  • packages/mobile-sdk-alpha/tests/sas.test.ts
  • packages/mobile-sdk-alpha/tsup.config.ts
  • packages/webview-app/src/screens/home/HomeScreen.tsx
  • packages/webview-app/src/screens/home/ManageDocumentsScreen.tsx
  • pnpm-workspace.yaml

Comment thread app/src/screens/dev/LinkBrowserExtensionScreen.tsx
Comment thread packages/chrome-extension/harness/boot-check.mjs
Comment thread packages/chrome-extension/package.json
Comment on lines +26 to +32
if (!existsSync(join(webviewDist, 'index.html'))) {
console.error('webview-app dist not found. Build it first:');
console.error(
' pnpm --filter @selfxyz/common build && pnpm --filter @selfxyz/mobile-sdk-alpha build:ts-only && pnpm --filter @selfxyz/webview-bridge build && pnpm --filter @selfxyz/webview-app build',
);
process.exit(1);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Breaks Workspace CI: pnpm run build exits 1 when webview-app/dist is absent.

The workspace-type-check job runs this package's build without the manual chain, and the logs show exactly this branch firing ("webview-app dist not found") followed by exit code 1. The prerequisite needs to be enforced by the dependency graph (e.g. a @selfxyz/webview-app workspace dependency plus its build in dependsOn/pnpm --filter ... build prior to this script) or built here, rather than left as a documented manual step.

Source: Pipeline failures

Comment thread packages/chrome-extension/src/background.ts Outdated
Comment on lines +35 to +45
let lastVerification = null;

function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
res.end(JSON.stringify(body));
}

const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/api/last-verification') {
return json(res, 200, lastVerification);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not publish a cross-user verification record.

This global record is returned with Access-Control-Allow-Origin: *, so any origin can read a prior verification's timestamp and potentially nationality. It also allows a later flow to display stale confirmation. Key results by a per-session nonce, authorize the matching read, and omit unneeded disclosure fields.

Also applies to: 62-67

Comment on lines +47 to +50
if (req.method === 'POST' && req.url === '/api/verify') {
let raw = '';
req.on('data', chunk => (raw += chunk));
req.on('end', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the incoming proof body.

This public endpoint concatenates unlimited request chunks. A malicious chunked POST can exhaust Node process memory; reject oversized bodies before appending and return 413.

@@ -0,0 +1,155 @@
import { useCallback, useEffect, useMemo, useState } from 'react';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In TypeScript, can a module that imports only named exports from reactsafely referenceReact.CSSPropertieswithout importingReactorCSSProperties? Cite official TypeScript or React type documentation.

💡 Result:

Yes, a module that imports only named exports from 'react' can safely reference CSSProperties without importing the full React namespace as a default or namespace import. The type CSSProperties is exported by the @types/react package. You can access it directly by importing it as a named export from 'react' [1][2][3]: import { CSSProperties } from 'react'; // You can now use CSSProperties directly const style: CSSProperties = { color: 'red', }; The requirement to reference it via the React namespace (e.g., React.CSSProperties) is a legacy pattern from older versions of React and TypeScript (pre-React 17 and pre-JSX transform) when the React namespace needed to be in scope for JSX or global type resolution [4][5]. In modern TypeScript/React projects, named imports are the recommended and standard approach [5]. Official React documentation on TypeScript confirms that CSSProperties is used to describe style objects [6], and it is available as a named export just like other React hooks and types (e.g., useState, FC) [7][5]. Top results: [6][4][7][5]

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== repo files matching App.tsx =="
fd -a 'App\.tsx$' . | sed 's#^\./##'

echo "== target App.tsx imports and style usage =="
if [ -f packages/extension-demo/src/App.tsx ]; then
  sed -n '1,170p' packages/extension-demo/src/App.tsx | cat -n
else
  echo "packages/extension-demo/src/App.tsx not found"
fi

echo "== tsconfig relevant settings =="
fd -a 'tsconfig.*json$' packages/extension-demo . | sed 's#^\./##' | while read -r f; do
  echo "-- $f --"
  sed -n '1,220p' "$f"
done

echo "== react type package version references =="
fd -a 'package.json$' packages/extension-demo . | xargs -r -I{} sh -c 'echo "-- {} --"; sed -n "1,220p" "{}" | grep -Ei "react|`@types/react`|typescript|compilerOptions" || true'

Repository: selfxyz/self

Length of output: 38394


Define CSSProperties directly instead of relying on React types.

packages/extension-demo/tsconfig.json sets "types": ["vite/client"], so the React ambient namespace is not guaranteed to be available and React.CSSProperties may fail tsc --noEmit.

Proposed fix
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { type CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
...
-const styles: Record<string, React.CSSProperties> = {
+const styles: Record<string, CSSProperties> = {

Comment on lines +18 to +22
it('is deterministic for a fixed shared secret', () => {
const secret = new Uint8Array(32).fill(7);
const result = sasEmojis(secret);
expect(result).toEqual([SAS_EMOJIS[1], SAS_EMOJIS[63], SAS_EMOJIS[17], SAS_EMOJIS[62]]);
expect(sasEmojis(secret)).toEqual(result);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the protocol vector independent of the lookup table.

Line 21 reads its expected values from SAS_EMOJIS, so reordering that table still passes despite changing the cross-client protocol output. Assert the literal vector instead.

Proposed fix
-    expect(result).toEqual([SAS_EMOJIS[1], SAS_EMOJIS[63], SAS_EMOJIS[17], SAS_EMOJIS[62]]);
+    expect(result).toEqual(['🐱', '🗺️', '🌲', '🔔']);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('is deterministic for a fixed shared secret', () => {
const secret = new Uint8Array(32).fill(7);
const result = sasEmojis(secret);
expect(result).toEqual([SAS_EMOJIS[1], SAS_EMOJIS[63], SAS_EMOJIS[17], SAS_EMOJIS[62]]);
expect(sasEmojis(secret)).toEqual(result);
it('is deterministic for a fixed shared secret', () => {
const secret = new Uint8Array(32).fill(7);
const result = sasEmojis(secret);
expect(result).toEqual(['🐱', '🗺️', '🌲', '🔔']);
expect(sasEmojis(secret)).toEqual(result);

Comment on lines +48 to +55
const selectDocument = useCallback(
(id: string) => {
haptic.trigger('selection');
analytics.trackEvent('manage_docs_document_selected');
const updated = { ...catalog, selectedDocumentId: id };
setCatalog(updated);
void documents.saveDocumentCatalog(updated as Parameters<typeof documents.saveDocumentCatalog>[0]);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not discard selection persistence failures.

If saveDocumentCatalog() rejects, the UI retains an unsaved selection and the promise is unhandled. Await it, update local state only after success, and show the existing dialogue on failure.

Proposed fix
-    (id: string) => {
+    async (id: string) => {
       haptic.trigger('selection');
       analytics.trackEvent('manage_docs_document_selected');
       const updated = { ...catalog, selectedDocumentId: id };
-      setCatalog(updated);
-      void documents.saveDocumentCatalog(updated as Parameters<typeof documents.saveDocumentCatalog>[0]);
+      try {
+        await documents.saveDocumentCatalog(updated as Parameters<typeof documents.saveDocumentCatalog>[0]);
+        setCatalog(updated);
+      } catch {
+        setDialogue({
+          title: 'Could not select document',
+          description: 'Your selection was not saved. Please try again.',
+        });
+      }

As per coding guidelines, “Always use try-catch for async operations with graceful degradation when native modules fail and comprehensive error boundaries.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const selectDocument = useCallback(
(id: string) => {
haptic.trigger('selection');
analytics.trackEvent('manage_docs_document_selected');
const updated = { ...catalog, selectedDocumentId: id };
setCatalog(updated);
void documents.saveDocumentCatalog(updated as Parameters<typeof documents.saveDocumentCatalog>[0]);
},
const selectDocument = useCallback(
async (id: string) => {
haptic.trigger('selection');
analytics.trackEvent('manage_docs_document_selected');
const updated = { ...catalog, selectedDocumentId: id };
try {
await documents.saveDocumentCatalog(updated as Parameters<typeof documents.saveDocumentCatalog>[0]);
setCatalog(updated);
} catch {
setDialogue({
title: 'Could not select document',
description: 'Your selection was not saved. Please try again.',
});
}
},

Source: Coding guidelines

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3dfe32757e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/chrome-extension/src/unlock.ts Outdated
function nextUrl(): string {
const next = new URLSearchParams(window.location.search).get('next');
// Only ever continue to our own pages.
if (next && /^[a-z0-9./?=&_-]+$/i.test(next) && !next.startsWith('//')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve encoded next targets after unlock

When a locked vault receives a site verification request, background.gatedUrl stores next=encodeURIComponent('index.html?...'); after URLSearchParams decodes it, embedded query values such as appEndpoint still contain %3A%2F%2F, so this whitelist rejects the valid internal target and falls back to index.html. That means the first verification after a browser restart unlocks into the extension home screen instead of the disclosure request, leaving the RP session pending or cancelled. Allow percent-encoded query strings or validate with URL parsing before calling chrome.runtime.getURL.

Useful? React with 👍 / 👎.

Comment on lines +130 to +132
<ShortcutRow
label="Link Browser Extension"
onPress={() => navigation.navigate('LinkBrowserExtension')}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hide account-export shortcut in production

In production builds, a user can enable the hidden Debug menu and this row remains visible because it is outside the neighboring IS_DEV_MODE guards; tapping it opens the dev-only flow that transfers the recovery secret and every document to the browser extension. Gate this shortcut behind IS_DEV_MODE (or otherwise remove it from release builds) so the account-export path fails closed outside development.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

Comment on lines +82 to +84
if (pending && !pending.resolved) {
return { accepted: false, result: failureResult('BUSY', 'Another verification is in progress') };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve pending state before opening popup

If a page issues two requestVerification calls back-to-back, both invocations can pass this check before either awaits vaultState()/chrome.windows.create() and assigns pending, so the BUSY guard is bypassed and two approval popups can open with only the later one tracked; the earlier result can then be lost or misrouted. Set a pending/reserved session before the first await, and clear it on failure, to default-deny overlapping lifecycle requests.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

- name: Install Chrome for Testing
id: cft
run: |
out=$(npx --yes @puppeteer/browsers install chrome@stable --path "$PWD/packages/chrome-extension/chrome")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use pnpm to fetch Chrome for Testing

This CI step invokes npx --yes @puppeteer/browsers, and npx --help confirms it runs commands from local or remote npm packages, so after the pnpm install the workflow can still fetch an unpinned npm CLI outside the lockfile. Use a pnpm-invoked, lockfile-controlled command for the already-declared browser installer instead.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

const messageRef = useRef<Record<string, unknown> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const cleanup = useCallback(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel the transfer when leaving the screen

This cleanup is only called from explicit cancel/fail/success paths; if the user backs out or the screen unmounts while the account transfer is connected or sending, the sockets, timeout, and messageRef stay alive until ack/timeout, so the encrypted account can continue being delivered after the user has left the flow. Return cleanup from an unmount effect so leaving the screen cancels the security-sensitive session.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

id: doc.id,
label: getIdCardProps(doc.documentCategory, doc.mock).title ?? 'Document',
description: selected ? `In use for proofs - ${registration}` : `${registration} - tap to use for proofs`,
onPress: () => selectDocument(doc.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep document details reachable from Manage Documents

Changing the row action to only selectDocument removes the existing path from Settings → Manage Documents → document details; the only remaining non-test call to navigate('/docs/current') is now behind the never-set dialogue callbacks, while the existing support-screen test still expects tapping the Passport row to open /docs/current. Keep selection separate from the row/details action (or open the details/dialog after selecting) so users can still view/manage the document.

Useful? React with 👍 / 👎.

- declare webview-app + webview-bridge as chrome-extension devDependencies
  so turbo orders the build chain (workspace build/types jobs no longer hit
  a missing webview-app dist)
- prettier pass on the ported app dev screen and webview-app screens
- Manage Documents: tapping a document now selects it for proofs AND opens
  the dialogue (path to ID details preserved); WV-14 test updated to the
  new behavior and the async catalog load
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 2 commits July 29, 2026 21:38
…l + OS-lock, fail-closed mid-session)

Session record in chrome.storage.session carries expiresAt + lastActivityAt;
every vault access validates both and self-locks on violation. Manual lock
via the action context menu closes open extension windows and fails any
pending verification; OS session lock triggers the same. A VAULT_LOCKED
error mid-use redirects the app page to unlock with the return path.
Harness covers the idle-expired path.

Spec: SPEC-PRODUCTION.html session & lock policy section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… at-rest keystore, egress CSP

Security review 2026-07-29. Highest-severity finding: the transfer handler
derived its key from whatever senderPublicKey arrived, independent of the
hello key whose SAS the user verified, so anyone who could reach the transfer
room and read the QR could substitute their own keypair and account.

- link.ts pins the SAS-verified hello key: an envelope from a different
  sender key, a transfer with no hello, or conflicting hellos are refused.
  `handled` now latches only after decrypt + validation, so a junk first
  message no longer burns the session.
- transfer protocol v2 (shared module, both ends + harnesses): HKDF-SHA256
  over the ECDH secret salted by session id and bound to a transcript of
  both public keys, with the transcript as GCM additional data, replacing
  the bare x-coordinate as the AES key. SAS widened 4 -> 6 emojis (24 -> 36
  bits) and derived from the transcript, so grinding a preimage against the
  emojis the user compares is not feasible.
- bridge-host crypto keystore writes private JWKs through the vault instead
  of plaintext chrome.storage.local, inheriting at-rest encryption and the
  session gate; reset() now also sweeps cryptoKey:* and passkeyMeta.
- vault: refuse to re-initialize over an existing vault, clamp PBKDF2
  iterations read back from storage, reject password unlock on passkey-mode
  vaults instead of throwing.
- manifest CSP gains default-src 'self' and connect-src limited to Self
  hosts (was unrestricted egress); relay URLs allowlisted on both ends;
  storage.session access level pinned to trusted contexts; background vault
  state now uses the TTL-aware check.
- harness: substituted-sender envelope must be refused (new regression),
  plus v2 crypto throughout.

Spec: SPEC-PRODUCTION.html threat model section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 2 commits July 29, 2026 22:02
…k codes

The link screen could sit on "Waiting for your phone" forever: a relayer
drop after connect, a relayer-side rejection, and code expiry had no UI at
all, and a failed reset left a dead button.

- link codes expire after 5 minutes (spec'd, never implemented): the QR
  dims, a "Get a new code" button appears, and late transfers are refused
  with an explanation
- relayer connect_error, disconnect, and the relayer's own error event each
  get an actionable line plus the regenerate affordance; connect confirms
  the waiting state rather than leaving an ambiguous spinner
- phone side surfaces the same three cases, including a mid-send drop
- unlock: a failed reset reports why and re-enables the button
- harness asserts the scan step always carries status text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…link channel

The QR is a trusted out-of-band channel we were under-using: v2 carried the
receiver public key (confidentiality) but nothing proving the sender had
scanned the code, so sender authentication rested on a human comparing 6
emojis. Both session ids reach the relayer, so they authenticate nothing.

v3 adds a 32-byte linkSecret to the QR, never transmitted through the
relayer, mixed in as the HKDF salt for the envelope key and the SAS. Only a
device that physically scanned the code can derive a key that authenticates:
an attacker in relayer position fails the AEAD tag regardless of which keys
they inject, with no user action involved. This is the pattern mainstream
device-linking flows use.

The emoji step is demoted, not removed: it still covers the case linkSecret
cannot (a QR observed via shoulder-surf, screen share, or remote support) and
remains where the user authorizes the send.

The phone refuses a pre-v3 link code with copy telling the user to update the
extension, rather than silently falling back to weaker authentication.

Harness now proves both attacks dead: substituted sender key (hello pin) and
off-path envelope from an attacker who never saw the QR (linkSecret).

Spec: SPEC-PRODUCTION.html link channel authentication.
remicolin and others added 9 commits July 29, 2026 22:16
- P1 security: the "Link Browser Extension" debug row sat outside the
  IS_DEV_MODE guards, so a production user who enabled the hidden debug menu
  could reach the account-export flow. Now dev-gated like its neighbours.
- P1 correctness: the unlock `next=` charset regex rejected percent-encoded
  embed queries, so the first verification after a browser restart unlocked
  into the home screen instead of the disclosure request and left the RP
  session hanging. Replaced with a path allowlist plus query re-serialization.
- P1 tooling: CI fetched Chrome for Testing through `npx --yes`, outside the
  lockfile. Now invoked via pnpm against the declared puppeteer dependency.
- P2: two back-to-back verification requests could both pass the BUSY check
  before either awaited, opening two popups with only the later tracked. The
  pending slot is now reserved synchronously and released on failure.
- P2: the phone transfer survived screen unmount (sockets, ack timer, and the
  pending payload stayed alive), so the account could still be delivered after
  the user backed out. Cleanup now runs on unmount.

Codex's ManageDocuments finding was already resolved: selection opens the
dialogue that carries the details path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dency)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…F-3706)

Chrome terminates an idle MV3 worker after ~30s, which is guaranteed to happen
while a user reads the consent screen or waits 10-30s on proving. Pending
session state lived in module globals with a setTimeout, so after that
termination the popup's result was silently dropped (the RP page hung until the
shim's own timeout) and the BUSY, TIMEOUT and window-closed guards stopped
working, allowing concurrent sessions.

- pending session and the home window id move to chrome.storage.session; the
  5-minute timeout becomes a chrome.alarms alarm. Both survive restarts.
- startup reconciliation settles a session whose window closed while the
  worker was asleep.
- lock closes extension windows by enumeration rather than cached ids, and
  pages now self-evict: bridge-host watches the session key and navigates to
  unlock, because a page that already read the vault holds decrypted documents
  in memory and clearing the key alone does not remove them.
- results carry the requesting origin and are dropped if the tab navigated
  away, so one origin cannot receive another's result.
- new permissions: alarms, tabs.

Harness: lock now evicts the open page (asserted), and a pending session
survives a forced worker restart (asserted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unlock throttle (CEP-04)

Security review: 600k PBKDF2 with an 8-character minimum is GPU-feasible
offline against a stolen storage dump, and nothing throttled online guessing
against a live browser.

- password floor raised 8 -> 12 characters, enforced in the vault (not just
  the form) so no caller can create a weaker vault
- live strength readout on the custody step (length + character classes)
- unlock throttles after 3 failures with exponential backoff capped at 5
  minutes, held in chrome.storage.session and cleared on success; the unlock
  screen tells the user how long to wait instead of silently rejecting
- harness asserts the throttle engages

Remaining in CEP-04: Argon2id via wasm and scoping the passkey rpId to a
dedicated host (both need decisions outside the code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (CEP-14, CEP-05)

- the phone refuses to link an account with no registered document: a vault
  that cannot prove anything would leave the extension in an empty state the
  user cannot resolve from the browser
- the payload carries linkedAt, persisted as self_linked_at, so the browser
  can show how old its copy is (transfer is a copy; the phone can add
  documents afterwards)
- unknown envelope versions are refused rather than best-effort parsed: a
  partially understood account is worse than none

The version refusal is not yet asserted in the harness. It needs a second
concurrent session, and the deployed relayer drops a message that arrives
before its peer joins without buffering it, so the test races the extension's
own join. self-infra PR #166 adds that buffering and makes the case
deterministic; the harness carries a note where the test belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…de (CEP-13)

The manifest had no icons at all, which the store hard-rejects, and still
called itself "Self (Spike)".

- icons at 16/32/48/128 rasterized from the Self mark by
  scripts/make-icons.mjs (headless Chrome, no new dependency) onto the brand
  ground with the store's padding ratio; committed because the store cannot
  accept a build without them
- name becomes "Self: Verify Your Identity" and the manifest description is
  the 132-char store summary, carrying the search terms "Self" alone will
  never rank for
- `pnpm build --store` strips the pinned dev `key` (which fixes the extension
  id for local loads and the harnesses, and must not ship), stamps a monotonic
  YYYY.MM.DD.N version, and produces the upload zip

Still needs design and legal for CEP-13: screenshots, the promo tile, the
detailed description, and the privacy policy URL plus practices disclosures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…traps (CEP-11)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…legible

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 3 commits July 31, 2026 14:44
…l check parses it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fer protocol v3

Both senders still spoke the v1 protocol (no hello room, no linkSecret
binding, no AAD) and were refused by the extension since 22c6bb0.
They are not CI gates, so the rot was silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…app settings (CEP-14)

New custody bridge domain (lock/reset) advertised via a custodyControls
capability that only the extension host sets; the capability defaults
false everywhere else so the phone app never renders the section.
Reset keeps the unlock-page two-press confirm; lock reuses the
background lockNow eviction. Import harness drives both from a real
popup window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 2 commits August 4, 2026 10:02
…QA round)

Phone (LinkBrowserExtensionScreen): design-system Title/Description/
PrimaryButton/SecondaryButton, emoji row that cannot wrap, actions
pinned to the bottom safe area, copy cut to one line per step, and a
dev-menu demo shortcut that stages the confirm step with fixed emojis
and no live channel.

Extension: toolbar click now opens an anchored action popup
(default_popup) instead of a detached window; dead home-window
machinery removed; popup size floored at 400x600 across index, link,
and unlock pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The row stays for every other host; gating is by the getConfig platform
string, now exposed as hostPlatform on the operating-mode context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… of hardcoded mock data

Loads the selected document from the catalog, extracts fields via the
SDK's getDocumentAttributes (now exported from the browser entry - the
root '.' export resolves to browser.js under the browser condition, so
it was unreachable from webview-app), formats MRZ names/dates, and
falls back to blank fields instead of fabricated ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 3 commits August 4, 2026 10:36
…isibility)

Derives the points address from the transferred mnemonic exactly like
the phone (account index 1 of m/44'/60'/0'/0) and reads the points API,
so a linked browser shows the same balance. Known-vector unit test pins
the derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same mark on both surfaces; small sizes zoom into the center since the
app icon's built-in margins make the 16px toolbar mark illegible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Label-level marker until euclid's DocumentItem selected state ships
(storybook PR #192).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 4 commits August 4, 2026 11:01
SAS_LENGTH is 6; the four-emoji fixture came from stale spike notes and
under-tested the row width the layout must fit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
28pt glyphs with 12px gaps keep the row under 300px, inside a 375pt
screen's content width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The popup sizes itself to the document, so wide content blew it up to
the 800px cap with dead space around the app column. A ctx=popup marker
on default_popup rides every custody redirect (gate, eviction, unlock
next, reset) and pins the frame; verification windows and tabs are
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ators, tap-to-select)

Mirrors the phone app: tapping a row selects it in place with the green
radio indicator (activeDocumentId, shipped in euclid slice 2), nothing
pops on select, unregistered documents explain themselves, and
details/remove moved behind a per-row chevron. Removes the interim
label check mark.

euclid PRs #191/#194 merged upstream, published as 1.4.7 (slice 2's
original PR #192 had merged into its stacked branch instead of main and
was re-landed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 2 commits August 4, 2026 14:11
…parses

Popup opens painted nothing until the 7.6MB bundle finished (no FCP at
all); locked and fresh installs paid the full parse just to bounce to
unlock/link. The build now strips the module tag (bridge-host injects
it after the gate passes, ~10ms, with modulepreload keeping the fetch
warm), paints a pulsing-mark splash at ~32ms, and drops sourcemaps from
dist (59MB -> 37MB). New harness/perf-check.mjs benchmarks popup cold
loads (median warm render 178ms, unchanged; FCP none -> 32ms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev mode, disclosure demo, tunnel flow, and the coming-soon support and
share entries are gated off the extension host (audit P0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…EP-07)

UI-free link-session engine extracted from link.html (sockets, SAS
handshake, envelope decrypt, vault persistence); bridge custody domain
gains state, createLinkSession + link events, completeLink,
unlock/unlockPasskey with cooldowns, enablePasskey, passwordStrength.
Groundwork for euclid-rendered link/unlock screens in webview-app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remicolin and others added 2 commits August 4, 2026 14:34
pnpm recorded the first-party exception locally at install time; without
it committed, Vercel's frozen install fails the supply-chain check on
the freshly published packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ke pages deleted (CEP-07)

The dark hand-rolled link.html/unlock.html pages are gone: custody
gating now routes into /ext/link and /ext/unlock inside webview-app,
rendered by euclid 1.4.8's ExtensionLinkScreen and ExtensionUnlockScreen
(light theme, library tokens, Touch ID auto-prompt, continue-in-a-window
escape hatch for the popover).

Two loops found and fixed while wiring it: the app's locked-vault storage
reads bounced off the gate screens forever (nesting ext_route/next on
every hop), and after an in-app unlock the boot-time gate marker went
stale so lock events were ignored - the check is live now.

Harnesses rewritten against the screens with shared drivers in
harness/ext-ui.mjs; boot, import, disclose, demo and perf all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rd (euclid 1.5.0)

The steps were stacked: the QR stayed while the SAS emojis appeared, and
the emojis persisted into the password step. Now each step does one job -
QR until the phone scans, emojis alone while waiting for the phone's
Send, password choice alone once the account arrives - plus explicit
success and failure states with a drawn status mark and a retry path.

Copy is overridable through euclid's copy prop, so strings change without
a library release; secure fields no longer show the 'Text...' placeholder.

Harnesses assert the sequencing (QR gone on verify, SAS gone on custody)
and the rogue-sender attacks now fire against the custody step where the
session is live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant