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 @@ + + + + + + + + + + + + SelectPilot + Deterministic local transformation layer for selected text + + Turn selected text into + structured local output. + Action briefs, rewrites, summaries, and extraction artifacts + through local Ollama, not cloud inference on the core path. + + + Local-first + + + + + ENVIRONMENT TRUTH + + SELECTION + + Turn the selected text into + a practical plan. + + STRUCTURED RESULT + + { + "summary": "Create an action-ready brief", + "actions": ["Ship MVP", "Record demo"] + + 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 @@ + + + + + + + + + + + + SelectPilot + LOCAL TRANSFORMATION LAYER + + Selected text + to structured output + + + Local-only + + + RESULT + { + "summary": "Action brief", + "actions": ["Ship MVP"], + "risk": "Scope drift" + Privacy proof visible in-panel + 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"})