diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c4226ab..e01de27 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,6 +64,9 @@ jobs:
- name: Build
run: pnpm build
+ - name: Validate Chrome Web Store assets
+ run: pnpm validate:store
+
- name: Smoke direct server entrypoint
run: python3 server/nano_server.py --help
diff --git a/.gitignore b/.gitignore
index 91f43eb..2da88e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,5 +6,6 @@ npm-debug.log*
test-results/
playwright-report/
reports/code_admission_evidence.json
+dist/
/usr/local/var/log/chromeai/
/usr/local/var/run/chromeai/
diff --git a/README.md b/README.md
index 4e2e8de..78451ea 100644
--- a/README.md
+++ b/README.md
@@ -202,6 +202,17 @@ Current CI enforces:
- typecheck (`pnpm typecheck`)
- build (`pnpm build`)
- baseline tests (`pnpm test`)
+- Chrome Web Store asset dimensions (`pnpm validate:store`)
+
+## Chrome Web Store Release
+
+Store listing copy, privacy language, and the release checklist live in:
+
+- [`docs/CHROME_WEB_STORE_SUBMISSION.md`](docs/CHROME_WEB_STORE_SUBMISSION.md)
+- [`docs/CHROME_WEB_STORE_RELEASE_CHECKLIST.md`](docs/CHROME_WEB_STORE_RELEASE_CHECKLIST.md)
+- [`docs/PRIVACY_POLICY.md`](docs/PRIVACY_POLICY.md)
+
+`pnpm package:store` builds and audits the upload ZIP. It intentionally fails closed until production entitlement signature verification is configured; no unsigned commercial package is release-eligible.
## Compute Distribution
diff --git a/assets/marketing/README.md b/assets/marketing/README.md
index d62df50..3eb058e 100644
--- a/assets/marketing/README.md
+++ b/assets/marketing/README.md
@@ -6,6 +6,8 @@ Primary launch visuals:
- `selectpilot-screenshot-extract.svg`
- `selectpilot-screenshot-runtime.svg`
- `selectpilot-screenshot-privacy.svg`
+- `selectpilot-small-promo.svg` / `.png` (`440x280`)
+- `selectpilot-marquee.svg` / `.png` (`1400x560`)
Recommended use:
@@ -13,6 +15,10 @@ Recommended use:
- `screenshot-extract`: primary product screenshot
- `screenshot-runtime`: onboarding/runtime proof screenshot
- `screenshot-privacy`: privacy boundary screenshot
+- `small-promo`: Chrome Web Store small promotional tile
+- `marquee`: Chrome Web Store marquee promotional tile
+
+Run `pnpm validate:store` to verify every required raster dimension. Store graphics are listing assets and are not included in the extension upload ZIP.
These visuals follow the same MUE direction as the product:
diff --git a/assets/marketing/selectpilot-marquee.png b/assets/marketing/selectpilot-marquee.png
new file mode 100644
index 0000000..7df04c4
Binary files /dev/null and b/assets/marketing/selectpilot-marquee.png differ
diff --git a/assets/marketing/selectpilot-marquee.svg b/assets/marketing/selectpilot-marquee.svg
new file mode 100644
index 0000000..3d8a3de
--- /dev/null
+++ b/assets/marketing/selectpilot-marquee.svg
@@ -0,0 +1,39 @@
+
diff --git a/assets/marketing/selectpilot-small-promo.png b/assets/marketing/selectpilot-small-promo.png
new file mode 100644
index 0000000..0562eb6
Binary files /dev/null and b/assets/marketing/selectpilot-small-promo.png differ
diff --git a/assets/marketing/selectpilot-small-promo.svg b/assets/marketing/selectpilot-small-promo.svg
new file mode 100644
index 0000000..4821888
--- /dev/null
+++ b/assets/marketing/selectpilot-small-promo.svg
@@ -0,0 +1,28 @@
+
diff --git a/background/entitlement-service.ts b/background/entitlement-service.ts
index 1a3de4a..0fcfe66 100644
--- a/background/entitlement-service.ts
+++ b/background/entitlement-service.ts
@@ -41,7 +41,10 @@ export type CachedEntitlement = {
const OFFLINE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
-const PUBLIC_KEY_HEX = '';
+const ENTITLEMENT_PUBLIC_KEYS: Readonly> = {
+ __SELECTPILOT_ENTITLEMENT_KEY_ID__: '__SELECTPILOT_ENTITLEMENT_PUBLIC_KEY_HEX__',
+};
+const SIGNATURE_ALGORITHM = 'Ed25519';
let cachedFeatureMap: Record | null = null;
@@ -92,12 +95,18 @@ function canonicalizeEntitlement(payload: EntitlementPayload): string {
});
}
-async function verifySignature(payload: EntitlementPayload, signature: string): Promise {
- if (!PUBLIC_KEY_HEX) return false;
+export async function verifyEntitlementSignature(
+ payload: EntitlementPayload,
+ signature: string,
+ kid: string,
+ publicKeys: Readonly> = ENTITLEMENT_PUBLIC_KEYS,
+): Promise {
+ const publicKeyHex = publicKeys[kid];
+ if (!publicKeyHex || !signature || !kid) return false;
try {
const key = await crypto.subtle.importKey(
'raw',
- bytesToArrayBuffer(hexToBytes(PUBLIC_KEY_HEX)),
+ bytesToArrayBuffer(hexToBytes(publicKeyHex)),
{ name: 'Ed25519' },
false,
['verify']
@@ -135,7 +144,25 @@ async function isFeatureAllowedByTier(feature: string, tier: EntitlementTier): P
function isWithinOfflineGrace(record: LicenseRecord): boolean {
const baseline = record.cachedAt || record.issuedAt;
- return nowMs() <= (baseline + OFFLINE_GRACE_MS);
+ return (!record.expiresAt || nowMs() < record.expiresAt)
+ && nowMs() <= (baseline + OFFLINE_GRACE_MS);
+}
+
+async function isVerifiedCachedEntitlement(record: LicenseRecord, token: string): Promise {
+ if (
+ record.token !== token
+ || record.alg !== SIGNATURE_ALGORITHM
+ || !record.signature
+ || !record.kid
+ || !isWithinOfflineGrace(record)
+ ) return false;
+ return verifyEntitlementSignature({
+ token: record.token,
+ tier: record.tier,
+ features: record.features,
+ issuedAt: record.issuedAt,
+ expiresAt: record.expiresAt ?? null,
+ }, record.signature, record.kid);
}
async function readCachedEntitlement(): Promise {
@@ -162,44 +189,38 @@ async function writeCachedEntitlement(record: CachedEntitlement): Promise
}
async function normalizeRemoteResponse(token: string, response: SignedEntitlementResponse): Promise {
- if (response.entitlement) {
- const entitlement = response.entitlement;
- if (entitlement.token !== token) {
- warn('entitlement', 'token mismatch in signed response');
- return null;
- }
- if (response.signature) {
- if (!PUBLIC_KEY_HEX) {
- warn('entitlement', 'signature returned but PUBLIC_KEY_HEX is not configured; accepting as unsigned MVP');
- } else {
- const valid = await verifySignature(entitlement, response.signature);
- if (!valid) return null;
- }
- }
- return {
- token: entitlement.token,
- tier: entitlement.tier,
- features: entitlement.features,
- issuedAt: entitlement.issuedAt,
- expiresAt: entitlement.expiresAt ?? undefined,
- cachedAt: nowMs(),
- signature: response.signature,
- alg: response.alg,
- kid: response.kid,
- };
+ const entitlement = response.entitlement;
+ if (
+ !entitlement
+ || entitlement.token !== token
+ || !['essential', 'plus', 'pro'].includes(entitlement.tier)
+ || !Number.isInteger(entitlement.issuedAt)
+ || (entitlement.expiresAt != null && !Number.isInteger(entitlement.expiresAt))
+ ) {
+ warn('entitlement', 'missing or token-mismatched signed entitlement');
+ return null;
}
-
- if (response.tier && response.issuedAt) {
- return {
- token,
- tier: response.tier,
- issuedAt: response.issuedAt,
- expiresAt: response.expiresAt,
- cachedAt: nowMs(),
- };
+ if (response.alg !== SIGNATURE_ALGORITHM || !response.signature || !response.kid) {
+ warn('entitlement', 'unsigned or unsupported entitlement response');
+ return null;
}
-
- return null;
+ if (entitlement.expiresAt != null && entitlement.expiresAt <= nowMs()) {
+ warn('entitlement', 'expired entitlement response');
+ return null;
+ }
+ const valid = await verifyEntitlementSignature(entitlement, response.signature, response.kid);
+ if (!valid) return null;
+ return {
+ token: entitlement.token,
+ tier: entitlement.tier,
+ features: entitlement.features,
+ issuedAt: entitlement.issuedAt,
+ expiresAt: entitlement.expiresAt ?? undefined,
+ cachedAt: nowMs(),
+ signature: response.signature,
+ alg: response.alg,
+ kid: response.kid,
+ };
}
async function remoteVerify(token: string): Promise {
@@ -245,7 +266,7 @@ export async function refreshEntitlement(force = false): Promise REFRESH_INTERVAL_MS;
if (!shouldAttemptRemote && cached) {
log('entitlement', 'using cached entitlement within offline grace');
@@ -270,7 +291,7 @@ export async function refreshEntitlement(force = false): Promise !/^[A-Za-z0-9._-]{1,64}$/.test(kid) || !/^[0-9a-f]{64}$/i.test(String(key)))) {
+ throw new Error('Entitlement public key ring must contain valid key IDs and 32-byte Ed25519 public keys');
+ }
+ const target = path.join(projectRoot, 'background/entitlement-service.js');
+ const source = await readFile(target, 'utf8');
+ const configured = source.replace(
+ /const ENTITLEMENT_PUBLIC_KEYS = \{[\s\S]*?\n\};/,
+ `const ENTITLEMENT_PUBLIC_KEYS = ${JSON.stringify(keys)};`,
+ );
+ if (configured === source) throw new Error('Entitlement public key injection marker not found');
+ await writeFile(target, configured);
+}
+
+await injectEntitlementPublicKeys();
+
await build({
entryPoints: [path.join(projectRoot, 'content/content-script.ts')],
outfile: path.join(projectRoot, 'content/content-script.bundle.js'),
diff --git a/scripts/package-chrome-store.mjs b/scripts/package-chrome-store.mjs
new file mode 100644
index 0000000..e9efb97
--- /dev/null
+++ b/scripts/package-chrome-store.mjs
@@ -0,0 +1,117 @@
+#!/usr/bin/env node
+// module_name: scripts_package-chrome-store_mjs
+// spec_ref: "reporting"
+
+import { createHash } from 'node:crypto';
+import { cp, mkdir, readFile, readdir, rm, stat, utimes, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { validateStoreAssets } from './validate-store-assets.mjs';
+
+const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const fixedDate = new Date('2026-01-01T00:00:00Z');
+const runtimeRoots = ['agent', 'api', 'background', 'billing', 'content', 'licensing', 'panel', 'popup', 'pricing', 'shared', 'utils'];
+const runtimeAssets = [
+ 'assets/icon16.png',
+ 'assets/icon32.png',
+ 'assets/icon48.png',
+ 'assets/icon128.png',
+ 'assets/icon256.png',
+ 'assets/icon512.png',
+];
+const allowedExtensions = new Set(['.css', '.html', '.js', '.json', '.png', '.svg']);
+const forbiddenPath = /(^|\/)(?:\.env|tests?|reports?|logs?|node_modules|test-results|playwright-report)(\/|$)|\.(?:map|log|pem|key)$/i;
+const secretPatterns = [
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
+ /(?:api|secret|private)[_-]?key\s*[:=]\s*["'][^"']{12,}["']/i,
+];
+
+async function walk(directory, base = directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const files = [];
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
+ const absolute = path.join(directory, entry.name);
+ if (entry.isDirectory()) files.push(...await walk(absolute, base));
+ if (entry.isFile()) files.push(path.relative(base, absolute));
+ }
+ return files;
+}
+
+export async function collectRuntimeFiles(root = projectRoot) {
+ const files = ['manifest.json', ...runtimeAssets];
+ for (const runtimeRoot of runtimeRoots) {
+ const absoluteRoot = path.join(root, runtimeRoot);
+ for (const nested of await walk(absoluteRoot)) {
+ const relative = path.posix.join(runtimeRoot, nested.split(path.sep).join(path.posix.sep));
+ if (allowedExtensions.has(path.extname(relative)) && !forbiddenPath.test(relative)) files.push(relative);
+ }
+ }
+ return files.sort();
+}
+
+export async function assertReleaseSafe(files, root = projectRoot) {
+ const entitlement = await readFile(path.join(root, 'background/entitlement-service.js'), 'utf8');
+ if (entitlement.includes('__SELECTPILOT_ENTITLEMENT_PUBLIC_KEY_HEX__') || entitlement.includes('__SELECTPILOT_ENTITLEMENT_KEY_ID__')) {
+ throw new Error('Store package blocked: production entitlement signature verification is not configured (SOD-837).');
+ }
+
+ for (const relative of files) {
+ if (forbiddenPath.test(relative)) throw new Error(`Forbidden release path: ${relative}`);
+ const absolute = path.join(root, relative);
+ if (!(await stat(absolute)).isFile()) throw new Error(`Missing release file: ${relative}`);
+ if (!['.png'].includes(path.extname(relative))) {
+ const content = await readFile(absolute, 'utf8');
+ for (const pattern of secretPatterns) {
+ if (pattern.test(content)) throw new Error(`Potential secret in release file: ${relative}`);
+ }
+ }
+ }
+}
+
+async function sha256(filePath) {
+ return createHash('sha256').update(await readFile(filePath)).digest('hex');
+}
+
+export async function packageChromeStore(root = projectRoot) {
+ await validateStoreAssets(root);
+ const files = await collectRuntimeFiles(root);
+ await assertReleaseSafe(files, root);
+
+ const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
+ const outputRoot = path.join(root, 'dist', 'chrome-web-store');
+ const stageRoot = path.join(outputRoot, `selectpilot-${packageJson.version}`);
+ const zipPath = `${stageRoot}.zip`;
+ await rm(outputRoot, { recursive: true, force: true });
+ await mkdir(stageRoot, { recursive: true });
+
+ for (const relative of files) {
+ const destination = path.join(stageRoot, relative);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await cp(path.join(root, relative), destination);
+ await utimes(destination, fixedDate, fixedDate);
+ }
+
+ const zip = spawnSync('zip', ['-X', '-q', zipPath, ...files], {
+ cwd: stageRoot,
+ encoding: 'utf8',
+ env: { ...process.env, TZ: 'UTC' },
+ });
+ if (zip.error || zip.status !== 0) throw new Error(`zip failed: ${zip.error?.message || zip.stderr || zip.status}`);
+
+ const report = {
+ schema_version: 1,
+ version: packageJson.version,
+ artifact: path.basename(zipPath),
+ sha256: await sha256(zipPath),
+ file_count: files.length,
+ files,
+ };
+ await writeFile(`${zipPath}.json`, `${JSON.stringify(report, null, 2)}\n`);
+ return report;
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const report = await packageChromeStore();
+ console.log(`${report.artifact} ${report.sha256}`);
+}
diff --git a/scripts/validate-store-assets.mjs b/scripts/validate-store-assets.mjs
new file mode 100644
index 0000000..321acf5
--- /dev/null
+++ b/scripts/validate-store-assets.mjs
@@ -0,0 +1,48 @@
+#!/usr/bin/env node
+// module_name: scripts_validate-store-assets_mjs
+// spec_ref: "reporting"
+
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+export const REQUIRED_IMAGES = new Map([
+ ['assets/icon128.png', [128, 128]],
+ ['assets/marketing/selectpilot-screenshot-extract.png', [1280, 800]],
+ ['assets/marketing/selectpilot-screenshot-runtime.png', [1280, 800]],
+ ['assets/marketing/selectpilot-screenshot-privacy.png', [1280, 800]],
+ ['assets/marketing/selectpilot-small-promo.png', [440, 280]],
+ ['assets/marketing/selectpilot-marquee.png', [1400, 560]],
+]);
+
+// @spec_ref reporting
+export function readPngDimensions(buffer) {
+ const signature = buffer.subarray(0, 8).toString('hex');
+ if (signature !== '89504e470d0a1a0a' || buffer.subarray(12, 16).toString('ascii') !== 'IHDR') {
+ throw new Error('not a PNG with an IHDR header');
+ }
+ return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)];
+}
+
+export async function validateStoreAssets(root = projectRoot) {
+ const errors = [];
+ for (const [relativePath, expected] of REQUIRED_IMAGES) {
+ try {
+ const actual = readPngDimensions(await readFile(path.join(root, relativePath)));
+ if (actual[0] !== expected[0] || actual[1] !== expected[1]) {
+ errors.push(`${relativePath}: expected ${expected.join('x')}, got ${actual.join('x')}`);
+ }
+ } catch (error) {
+ errors.push(`${relativePath}: ${error.message}`);
+ }
+ }
+ if (errors.length) throw new Error(`Store asset validation failed:\n- ${errors.join('\n- ')}`);
+ return [...REQUIRED_IMAGES.keys()];
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ await validateStoreAssets();
+ console.log(`Validated ${REQUIRED_IMAGES.size} Chrome Web Store images.`);
+}
diff --git a/server/entitlement_signer.py b/server/entitlement_signer.py
new file mode 100644
index 0000000..47f2186
--- /dev/null
+++ b/server/entitlement_signer.py
@@ -0,0 +1,69 @@
+"""module_name: entitlement_signer; spec_ref: "validation_layer"."""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import subprocess
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+
+class SigningError(RuntimeError):
+ """Explicit fail-closed entitlement signing failure."""
+
+
+def canonical_entitlement(entitlement: dict) -> bytes:
+ return json.dumps(
+ {
+ "token": entitlement["token"],
+ "tier": entitlement["tier"],
+ "features": entitlement.get("features") or [],
+ "issuedAt": entitlement["issuedAt"],
+ "expiresAt": entitlement.get("expiresAt"),
+ },
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+
+
+@dataclass(frozen=True)
+class EntitlementSigner:
+ key_file: str
+ key_id: str
+
+ @classmethod
+ def from_environment(cls) -> "EntitlementSigner":
+ return cls(
+ key_file=os.environ.get("SELECTPILOT_ENTITLEMENT_SIGNING_KEY_FILE", ""),
+ key_id=os.environ.get("SELECTPILOT_ENTITLEMENT_SIGNING_KEY_ID", ""),
+ )
+
+ def sign(self, entitlement: dict) -> dict:
+ if not self.key_file or not self.key_id:
+ raise SigningError("entitlement_signer_not_configured")
+ key_path = Path(self.key_file)
+ if not key_path.is_file():
+ raise SigningError("entitlement_signing_key_unavailable")
+ try:
+ with tempfile.NamedTemporaryFile() as payload_file:
+ payload_file.write(canonical_entitlement(entitlement))
+ payload_file.flush()
+ result = subprocess.run(
+ ["openssl", "pkeyutl", "-sign", "-rawin", "-inkey", str(key_path), "-in", payload_file.name],
+ capture_output=True,
+ check=False,
+ timeout=5,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise SigningError("entitlement_signing_failed") from exc
+ if result.returncode != 0 or len(result.stdout) != 64:
+ raise SigningError("entitlement_signing_failed")
+ return {
+ "entitlement": entitlement,
+ "signature": base64.b64encode(result.stdout).decode("ascii"),
+ "alg": "Ed25519",
+ "kid": self.key_id,
+ }
diff --git a/server/monero_payment_server.py b/server/monero_payment_server.py
index cd42f3b..0d5d854 100644
--- a/server/monero_payment_server.py
+++ b/server/monero_payment_server.py
@@ -23,6 +23,7 @@
import requests
from flask import Flask, jsonify, request
+from entitlement_signer import EntitlementSigner, SigningError
app = Flask(__name__)
@@ -33,6 +34,7 @@
CONFIRMATIONS_REQUIRED = int(os.environ.get("CHROMEAI_MONERO_CONFIRMATIONS", "10"))
POLL_INTERVAL_SECONDS = int(os.environ.get("CHROMEAI_MONERO_POLL_SECONDS", "20"))
ORDER_EXPIRY_MS = int(os.environ.get("CHROMEAI_MONERO_ORDER_EXPIRY_MS", str(30 * 60 * 1000)))
+ENTITLEMENT_SIGNER = EntitlementSigner.from_environment()
db_lock = Lock()
@@ -236,13 +238,18 @@ def verify_license():
if not record or record.get("revoked"):
return jsonify({"error": "invalid"}), 401
- return jsonify(
- {
- "tier": record["tier"],
- "issuedAt": record["issuedAt"],
- "expiresAt": record.get("expiresAt"),
- }
- )
+ entitlement = {
+ "token": token,
+ "tier": record["tier"],
+ "features": record.get("features") or [],
+ "issuedAt": record["issuedAt"],
+ "expiresAt": record.get("expiresAt"),
+ }
+ try:
+ return jsonify(ENTITLEMENT_SIGNER.sign(entitlement))
+ except SigningError as exc:
+ app.logger.error("Entitlement signing unavailable: %s", exc)
+ return jsonify({"error": str(exc)}), 503
# ---- ADMIN REVOKE ----
diff --git a/server/nano_server.py b/server/nano_server.py
index b491679..45c43c5 100644
--- a/server/nano_server.py
+++ b/server/nano_server.py
@@ -1308,14 +1308,22 @@ def license_verify(payload: dict) -> dict:
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
raise ValidationError("entitlement_verifier_unavailable", "Local entitlement verifier is unavailable", status=503) from exc
- issued_at = result.get("issuedAt") if isinstance(result, dict) else None
- expires_at = result.get("expiresAt") if isinstance(result, dict) else None
+ entitlement = result.get("entitlement") if isinstance(result, dict) else None
+ issued_at = entitlement.get("issuedAt") if isinstance(entitlement, dict) else None
+ expires_at = entitlement.get("expiresAt") if isinstance(entitlement, dict) else None
if (
not isinstance(result, dict)
- or result.get("tier") not in {"essential", "plus", "pro"}
+ or not isinstance(entitlement, dict)
+ or entitlement.get("token") != token
+ or entitlement.get("tier") not in {"essential", "plus", "pro"}
or not isinstance(issued_at, int)
or isinstance(issued_at, bool)
or (expires_at is not None and (not isinstance(expires_at, int) or isinstance(expires_at, bool)))
+ or result.get("alg") != "Ed25519"
+ or not isinstance(result.get("kid"), str)
+ or not result.get("kid")
+ or not isinstance(result.get("signature"), str)
+ or not result.get("signature")
):
raise ValidationError("invalid_entitlement_response", "Local entitlement verifier returned an invalid contract", status=503)
return result
diff --git a/tests/e2e/extension-first-run-flow.spec.mjs b/tests/e2e/extension-first-run-flow.spec.mjs
index 9e8778f..98cde3f 100644
--- a/tests/e2e/extension-first-run-flow.spec.mjs
+++ b/tests/e2e/extension-first-run-flow.spec.mjs
@@ -1,16 +1,34 @@
// module_name: extension_privacy_integration
// spec_ref: "privacy_and_debug_policy"
import { test, expect, chromium } from '@playwright/test';
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { collectRuntimeFiles } from '../../scripts/package-chrome-store.mjs';
test('real extension preserves privacy from selected text through rendered output', async () => {
const executablePath = process.env.SELECTPILOT_CHROME_EXECUTABLE || chromium.executablePath();
+ const keyId = 'e2e-ephemeral';
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
+ const publicKeyHex = Buffer.from(await crypto.subtle.exportKey('raw', keyPair.publicKey)).toString('hex');
+ const extensionRoot = test.info().outputPath('extension');
+ for (const relative of await collectRuntimeFiles(process.cwd())) {
+ const destination = path.join(extensionRoot, relative);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await cp(path.join(process.cwd(), relative), destination);
+ }
+ const entitlementScript = path.join(extensionRoot, 'background/entitlement-service.js');
+ const entitlementSource = await readFile(entitlementScript, 'utf8');
+ await writeFile(entitlementScript, entitlementSource.replace(
+ /const ENTITLEMENT_PUBLIC_KEYS = \{[\s\S]*?\n\};/,
+ `const ENTITLEMENT_PUBLIC_KEYS = ${JSON.stringify({ [keyId]: publicKeyHex })};`,
+ ));
const context = await chromium.launchPersistentContext(test.info().outputPath('extension-user-data'), {
executablePath,
headless: process.env.SELECTPILOT_HEADED !== '1',
args: [
- `--disable-extensions-except=${process.cwd()}`,
- `--load-extension=${process.cwd()}`,
+ `--disable-extensions-except=${extensionRoot}`,
+ `--load-extension=${extensionRoot}`,
'--disable-web-security',
'--disable-background-networking',
'--disable-component-update',
@@ -74,6 +92,18 @@ test('real extension preserves privacy from selected text through rendered outpu
if (path === '/runtime-meta/health') {
return fulfill({ ok: false, stream_enabled: false, active_streams: 0, event_version: '1' });
}
+ if (path === '/license/verify') {
+ const token = request.postDataJSON().token;
+ const now = Date.now();
+ const entitlement = {
+ token, tier: 'essential', features: ['structured_extraction'], issuedAt: now,
+ expiresAt: now + 86_400_000,
+ };
+ const signature = Buffer.from(await crypto.subtle.sign(
+ 'Ed25519', keyPair.privateKey, new TextEncoder().encode(JSON.stringify(entitlement)),
+ )).toString('base64');
+ return fulfill({ entitlement, signature, alg: 'Ed25519', kid: keyId });
+ }
if (path === '/extract') {
extractRequests.push(request.postDataJSON());
return fulfill({
@@ -107,14 +137,10 @@ test('real extension preserves privacy from selected text through rendered outpu
expect(lockedResponse.error).toBe('Paid license required for deterministic extraction');
expect(extractRequests).toEqual([]);
- await panelPage.evaluate(async () => {
- const storage = await import(globalThis.chrome.runtime.getURL('licensing/license-storage.js'));
- const now = Date.now();
- await storage.saveLicense({
- token: 'sp_e2e_paid_token', tier: 'essential', issuedAt: now,
- expiresAt: now + 86_400_000, cachedAt: now,
- });
- });
+ const attached = await panelPage.evaluate(() => globalThis.chrome.runtime.sendMessage({
+ type: 'license:attach_token', token: 'sp_e2e_paid_token',
+ }));
+ expect(attached).toMatchObject({ token: 'sp_e2e_paid_token', tier: 'essential' });
await panelPage.reload();
await expect(panelPage.locator('#btn-first-run-example')).toBeVisible();
diff --git a/tests/panel/entitlement-signature.test.mjs b/tests/panel/entitlement-signature.test.mjs
new file mode 100644
index 0000000..5dbc532
--- /dev/null
+++ b/tests/panel/entitlement-signature.test.mjs
@@ -0,0 +1,33 @@
+// module_name: tests_panel_entitlement_signature_test_mjs
+// spec_ref: "testing_strategy.integration_tests"
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { verifyEntitlementSignature } from '../../background/entitlement-service.js';
+
+function toHex(bytes) {
+ return Buffer.from(bytes).toString('hex');
+}
+
+test('only a matching Ed25519 key ID and exact payload verify', async () => {
+ const pair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
+ const publicKey = toHex(await crypto.subtle.exportKey('raw', pair.publicKey));
+ const entitlement = {
+ token: 'opaque-token',
+ tier: 'pro',
+ features: ['image_ocr'],
+ issuedAt: 1_700_000_000_000,
+ expiresAt: 4_000_000_000_000,
+ };
+ const canonical = JSON.stringify(entitlement);
+ const signature = Buffer.from(await crypto.subtle.sign(
+ 'Ed25519',
+ pair.privateKey,
+ new TextEncoder().encode(canonical),
+ )).toString('base64');
+ const keyRing = { 'rotation-1': publicKey };
+
+ assert.equal(await verifyEntitlementSignature(entitlement, signature, 'rotation-1', keyRing), true);
+ assert.equal(await verifyEntitlementSignature(entitlement, signature, 'unknown', keyRing), false);
+ assert.equal(await verifyEntitlementSignature({ ...entitlement, tier: 'plus' }, signature, 'rotation-1', keyRing), false);
+});
diff --git a/tests/panel/store-package.test.mjs b/tests/panel/store-package.test.mjs
new file mode 100644
index 0000000..b55a213
--- /dev/null
+++ b/tests/panel/store-package.test.mjs
@@ -0,0 +1,54 @@
+// module_name: tests_panel_store-package_test_mjs
+// spec_ref: "testing_strategy.integration_tests"
+
+import assert from 'node:assert/strict';
+import { execFileSync, spawnSync } from 'node:child_process';
+import test from 'node:test';
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { collectRuntimeFiles, assertReleaseSafe } from '../../scripts/package-chrome-store.mjs';
+import { REQUIRED_IMAGES, readPngDimensions, validateStoreAssets } from '../../scripts/validate-store-assets.mjs';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+
+test('Chrome Web Store image dimensions match the submission contract', async () => {
+ const files = await validateStoreAssets(root);
+ assert.deepEqual(files, [...REQUIRED_IMAGES.keys()]);
+ const icon = await readFile(path.join(root, 'assets/icon128.png'));
+ assert.deepEqual(readPngDimensions(icon), [128, 128]);
+});
+
+test('runtime inventory excludes source, tests, reports, and transient files', async () => {
+ const files = await collectRuntimeFiles(root);
+ assert.ok(files.includes('manifest.json'));
+ assert.ok(files.includes('background/background.js'));
+ assert.ok(files.includes('content/content-script.bundle.js'));
+ assert.ok(files.includes('assets/icon128.png'));
+ assert.ok(!files.some((file) => file.startsWith('assets/marketing/')));
+ assert.ok(files.every((file) => !file.endsWith('.ts')));
+ assert.ok(files.every((file) => !/(^|\/)(tests|reports|node_modules)(\/|$)/.test(file)));
+});
+
+test('store packaging fails closed while entitlement verification is unsigned', async () => {
+ const files = await collectRuntimeFiles(root);
+ await assert.rejects(
+ assertReleaseSafe(files, root),
+ /production entitlement signature verification is not configured/
+ );
+});
+
+test('relative CLI paths execute validation and fail-closed packaging', () => {
+ const validation = execFileSync(process.execPath, ['./scripts/validate-store-assets.mjs'], {
+ cwd: root,
+ encoding: 'utf8',
+ });
+ assert.match(validation, /Validated 6 Chrome Web Store images/);
+
+ const packaging = spawnSync(process.execPath, ['./scripts/package-chrome-store.mjs'], {
+ cwd: root,
+ encoding: 'utf8',
+ });
+ assert.equal(packaging.status, 1);
+ assert.match(packaging.stderr, /production entitlement signature verification is not configured/);
+});
diff --git a/tests/server/test_entitlement_signer.py b/tests/server/test_entitlement_signer.py
new file mode 100644
index 0000000..b6a0767
--- /dev/null
+++ b/tests/server/test_entitlement_signer.py
@@ -0,0 +1,58 @@
+"""module_name: entitlement_signer_tests; spec_ref: "testing_strategy.integration_tests"."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+SERVER_DIR = ROOT / "server"
+if str(SERVER_DIR) not in sys.path:
+ sys.path.insert(0, str(SERVER_DIR))
+
+from entitlement_signer import EntitlementSigner, SigningError, canonical_entitlement # noqa: E402
+
+
+class EntitlementSignerTests(unittest.TestCase):
+ entitlement = {
+ "token": "opaque-token",
+ "tier": "pro",
+ "features": ["image_ocr"],
+ "issuedAt": 1_700_000_000_000,
+ "expiresAt": 1_700_086_400_000,
+ }
+
+ def test_unconfigured_signer_fails_closed(self) -> None:
+ with self.assertRaises(SigningError) as ctx:
+ EntitlementSigner("", "").sign(self.entitlement)
+ self.assertEqual(str(ctx.exception), "entitlement_signer_not_configured")
+
+ def test_ephemeral_ed25519_identity_signs_exact_canonical_payload(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ key_file = Path(directory) / "signer.pem"
+ public_file = Path(directory) / "signer-public.pem"
+ subprocess.run(["openssl", "genpkey", "-algorithm", "Ed25519", "-out", key_file], check=True)
+ subprocess.run(["openssl", "pkey", "-in", key_file, "-pubout", "-out", public_file], check=True)
+
+ signed = EntitlementSigner(str(key_file), "test-rotation-1").sign(self.entitlement)
+ signature_file = Path(directory) / "signature.bin"
+ payload_file = Path(directory) / "payload.json"
+ import base64
+ signature_file.write_bytes(base64.b64decode(signed["signature"]))
+ payload_file.write_bytes(canonical_entitlement(self.entitlement))
+ verified = subprocess.run(
+ ["openssl", "pkeyutl", "-verify", "-rawin", "-pubin", "-inkey", public_file,
+ "-sigfile", signature_file, "-in", payload_file],
+ capture_output=True,
+ check=False,
+ )
+ self.assertEqual(verified.returncode, 0)
+ self.assertEqual(signed["alg"], "Ed25519")
+ self.assertEqual(signed["kid"], "test-rotation-1")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/server/test_entitlement_verification.py b/tests/server/test_entitlement_verification.py
index 21bd2fa..d2f6d48 100644
--- a/tests/server/test_entitlement_verification.py
+++ b/tests/server/test_entitlement_verification.py
@@ -47,10 +47,13 @@ def test_invalid_authority_token_remains_unauthorized(self) -> None:
self.assertEqual(ctx.exception.status, 401)
def test_verified_local_authority_response_is_returned(self) -> None:
- response = _Response(b'{"tier":"pro","issuedAt":1700000000000,"expiresAt":null}')
+ response = _Response(
+ b'{"entitlement":{"token":"opaque-token","tier":"pro","features":[],"issuedAt":1700000000000,'
+ b'"expiresAt":null},"signature":"c2lnbmF0dXJl","alg":"Ed25519","kid":"rotation-1"}'
+ )
with patch("nano_server.urlopen", return_value=response):
result = license_verify({"token": "opaque-token"})
- self.assertEqual(result["tier"], "pro")
+ self.assertEqual(result["entitlement"]["tier"], "pro")
def test_non_loopback_verifier_configuration_is_rejected(self) -> None:
with patch.dict(os.environ, {"SELECTPILOT_BILLING_VERIFY_URL": "https://example.com/license/verify"}):
@@ -59,7 +62,10 @@ def test_non_loopback_verifier_configuration_is_rejected(self) -> None:
self.assertEqual(ctx.exception.code, "invalid_entitlement_verifier")
def test_malformed_authority_response_is_rejected(self) -> None:
- response = _Response(b'{"tier":"pro","issuedAt":"not-a-timestamp"}')
+ response = _Response(
+ b'{"entitlement":{"token":"opaque-token","tier":"pro","issuedAt":"not-a-timestamp"},'
+ b'"signature":"bad","alg":"Ed25519","kid":"rotation-1"}'
+ )
with patch("nano_server.urlopen", return_value=response):
with self.assertRaises(ValidationError) as ctx:
license_verify({"token": "opaque-token"})