Security
This document describes Compressr's threat model, trust boundaries, the auth and entitlement
mechanisms, permissions, storage, and known gaps. It is a factual description of what the code
does today (commit 85f7500), not a security certification.
Threat model
Compressr is a browser extension. The extension is an untrusted client from the server's point of view: anyone can unpack, patch, or re-sign a copy of it, intercept its network calls, or run a modified build against the real backend. The design goal is therefore:
- Protect server-side data (billing state, other users' entitlements, install records) from any client, honest or hostile.
- Make casual sharing/tampering of Pro features detectable and revocable, not cryptographically impossible.
- Never rely on the client to self-report payment status for a paid feature.
Accepted residual risk: a determined local attacker who controls their own machine can patch
the extension binary to bypass client-side gating entirely (e.g. hard-code plan: 'pro_plus' in
memory). This is explicitly accepted (ARCHITECTURE.md §9) — the design targets casual sharing
and server-side revocability, not DRM-grade protection against a fully compromised client. No
mitigation in this document changes that.
The complementary promise is audio privacy: tab/page audio, PCM, video frames, DOM/page
content, and browsing history never leave the device under any code path (see PRIVACY.md).
That promise does not depend on trusting the client with secrets — there simply is no code path
that transmits audio, so there is nothing for a hostile client to exfiltrate on that axis.
Trust boundaries
popup / options (React, UI only)
| runtime messaging (src/adapters/runtime)
v
background (service worker / event page) <-- holds Firebase Auth, entitlement cache, desired state
| |
| chrome.tabCapture / offscreen | HTTPS callables (issueLinkCode, redeemLinkCode, mintEntitlement)
v v
offscreen / content script (DSP graph) Cloud Functions (functions/src) <-- admin SDK, Firestore
|
v
autolevel-worklet.js (AudioWorkletProcessor, bundled classic script, no DOM)
hosted page (/link, static, separate origin) -- signs the user in, mints a one-time code
- Popup/options ↔ background: popup and options never hold Firebase Auth themselves; they
ask background for state and issue commands over
src/adapters/runtime/messaging.ts. UI code cannot independently decide it is "Pro." - Background ↔ offscreen/content: background owns desired state and the verified entitlement
token; offscreen (Chromium) and the content script (page-media browsers) own the live
AudioContext/worklet graph and receive gating decisions from background, not from their own guesses. - Background ↔ Cloud Functions: the only network trust boundary that mints or reads privileged
data. All three callables (
issueLinkCode,redeemLinkCode,mintEntitlement) run server-side with the Admin SDK; Firestore rules deny direct client access to the collections they read. - Hosted page: a separate, unbundled static origin (
hosted/) that never imports extension APIs and never receives extension identifiers. It performs normal Firebase web sign-in and hands back a short-lived code — seeAUTH_AND_BILLING.mdanddocs/NATIVE_COMPANION.mdare out of scope for it.
Runtime-message sender validation (SEC-06)
Privileged Cmd and AuthMsg requests are accepted only when sender.id equals this extension's
runtime ID and sender.url has the extension origin returned by browser.runtime.getURL('/').
This intentionally does not depend on the presence of sender.tab: an extension document can
be the action popup without a tab, or popup.html#/pricing, a success route, or options opened in
an ordinary browser tab. Content scripts remain limited to PM_* page-media events and must also
carry this extension's runtime ID; their web-page sender.url cannot satisfy the privileged
extension-page check. Other senders receive no response, and the background logs the rejected
boundary once per worker lifetime.
Auth handoff (ADR-001)
The extension background never runs its own OAuth flow and never imports chrome.identity.
Flow (ARCHITECTURE.md §8, AUTH_AND_BILLING.md §1.1):
- Popup opens a tab:
{HOSTED_LOGIN_URL}/link?challenge=<S256(verifier)>&install_id=<install_id>— PKCE with the S256 method (SHA-256 of a locally generated verifier, base64url-encoded). - The hosted page performs standard Firebase web sign-in (Google/email/Apple).
- Hosted page calls
issueLinkCode({ installId, challenge })(requiresrequest.auth), which writeslinkCodes/{code} = { uid, installId, challengeS256, expiresAt, used: false }. The code is 24 characters, Crockford base32 alphabet, 60 second TTL, and is bound to theinstall_idand PKCE challenge presented at mint. - Extension background calls
redeemLinkCode({ code, verifier, installId })(no auth required — the background isn't signed in yet). The Cloud Function reads and validates the code inside a Firestore transaction: not used, not expired,install_idmatches, andbase64url(SHA-256(verifier)) === challengeS256. It marks the record used inside the same transaction, so a code can never be redeemed twice concurrently — this is fail-closed by construction, not by a follow-up check. - On success, the function returns
admin.auth().createCustomToken(uid, { install_id }). - Background signs in with
signInWithCustomToken, usinginitializeAuth+indexedDBLocalPersistence(firebase/auth/web-extension). Only the background holds Firebase Auth — offscreen never imports Firebase, and popup/options only ask background.
The hosted page itself never sees an extension identifier beyond the opaque install_id string
passed in the URL, and the extension never runs code from the hosted page's origin.
Entitlement token contract
The client (src/core/entitlements/{types,verify,policy}.ts) and the server
(functions/src/lib/entitlement.ts) must agree on this byte-for-byte; a mismatch fails silently
as malformed/invalid-signature, not a crash.
- Algorithm: ES256 (ECDSA P-256 / SHA-256).
- Signature encoding: raw P1363
r‖s(WebCrypto's native ECDSA output), not ASN.1 DER. The Cloud Function signs withcrypto.subtle.signfor this exact reason (Node's legacynode:cryptoECDSA API defaults to DER and must not be used here). - Header — exactly three fields:
{ "alg": "ES256", "typ": "JWT", "kid": "2026-09" }. The client'sparseHeaderrejects any otheralg(includingnone,HS256) ortyp, and requireskidto be a non-empty string. - Payload — exactly nine fields:
sub,plan(free|pro|pro_plus, closed set — an unrecognized value is rejected),features(closed allow-list, unrecognized entries are dropped rather than rejected for forward-compat),install_id,iat,exp,grace_until,jti,kid. All time fields are seconds since epoch, not milliseconds. - Key distribution: the extension pins a
kid → JWKmap incore/entitlements/keys.tsshipping two keys (current + next) at all times, so rotation never has to wait on a store review cycle. A signed JWKS document at{ENTITLEMENT_JWKS_URL}may add keys but must never remove the pinned ones within a release. - Expiry / refresh:
expis 24 hours from mint. Client refresh cadence (independent ofexp): 12h alarm, on SW start, on popup open if cached age > 1h, and on a 403. - Grace period is plan-specific: Pro (one-time/lifetime purchase) gets 30 days of soft
grace past
exp; Pro+ (subscription) gets 3 days. After grace, the client falls back to Free with a "reconnect to restore Pro" badge; a live session is never interrupted mid-stream. - Clock-rollback protection: the client checks both the server-issued
iathigh-water mark and a locally persisted wall-clock high-water mark. If the device clock is more than five minutes earlier than either trusted mark, the token is treated as untrusted — this defeats "roll the clock back to extend a lapsed token" tampering. - Install rate limiting: enforced server-side only, in
mintEntitlement(functions/src/lib/rateLimit.ts): a previously-seeninstall_idcan always re-mint; a newinstall_idis allowed only while fewer than 5 new installs were first seen for thatuidin the trailing 30 days. This makes copying a token/install across many devices detectable and boundable without blocking a legitimate device re-verifying. - Cache:
storage.local, with the token signature re-verified via WebCrypto on every read; separately mutable cached claims are never used as the authorization source.
Pro gating enforcement point
Pro features are gated in background/runtime code, not in the UI. The client's hasAccess
computed in the UI layer is a hint only (to render an upgrade prompt, etc.); background
applies the verified entitlement gate before it sends DSP controls, and offscreen/content
validate and clamp those controls before applying them. A patched popup that always renders
"Pro" does not, by itself, unlock Pro DSP behavior — the token still has to verify. A locally
patched runtime remains the explicitly accepted residual risk documented below.
Firestore rules (summary)
firestore.rules denies by default; nothing is readable or writable except what is explicitly
opened:
| Path | Client access | Notes |
|---|---|---|
customers/{uid} |
none | Never read/written directly. |
customers/{uid}/checkout_sessions/{id} |
create + read (owner only) | create requires owner auth, exactly price/success_url/cancel_url/mode, a price_… identifier, an approved redirect origin, and mode in ['payment','subscription']. Update/delete always denied — only the "Run Payments with Stripe" extension writes back url/error. |
customers/{uid}/subscriptions/{id} |
none | Read only by mintEntitlement via the admin SDK. |
customers/{uid}/payments/{id} |
none | Same as above. |
users/{uid} |
read/create/update (owner only) | Client profile writes are limited to email, createdAt, and updatedAt; install bookkeeping remains server-only. |
users/{uid}/installs/{installId} |
none | Rate-limit bookkeeping; admin SDK only. |
linkCodes/{code} |
none | Read/written only by issueLinkCode/redeemLinkCode via the admin SDK, which bypasses rules entirely — the explicit false rule exists to fail closed if a future refactor ever routes a client SDK call here. |
Content Security Policy
wxt.config.ts builds the manifest CSP per browser/manifest-version:
- No remote code:
script-src 'self'only (MV2) /extension_pagesscript-src'self'(MV3). Noeval, no injected remote<script>, no CDN-hosted scripts anywhere in the extension pages. object-src 'none'(MV2 form) removes plugin content entirely.connect-srcallow-list:securetoken.googleapis.com,identitytoolkit.googleapis.com,firestore.googleapis.com, plushttps://us-central1-{PROJECT_ID}.cloudfunctions.netwhen a project id is configured, plus a dev-onlyws://{devServerHost}in development mode. Any new host requires an explicit addition here.- The worklet is a bundled classic script, not loaded as a WXT entrypoint/ESM module:
scripts/build-worklet.mjsemitspublic/worklets/autolevel-worklet.jsas an IIFE with noimportstatements, and nowindow/document/self.references (enforced by a static post-build scan that throws the build if any forbidden token is found — seeDEVELOPMENT.md). This keeps the AudioWorkletProcessor's DOM-free scope contract mechanically auditable rather than merely documented. - The hosted login page (
hosted/) ships its own separate, tighter CSP meta tag (seehosted/README.md) scoped to Firebase Auth + the callables it needs; it is not covered bywxt.config.ts.
Permissions, per browser, and why
wxt.config.ts's manifest() function computes permissions per target browser — nothing is
requested "just in case":
| Permission | Chrome (+ Chromium family) | Firefox / Safari | Why |
|---|---|---|---|
storage |
yes | yes | Entitlement cache, settings, presets, site/device profiles — all local. |
alarms |
yes | yes | Periodic entitlement refresh (12h) without keeping the service worker alive continuously. |
tabCapture |
yes | no | Full-tab audio capture via chrome.tabCapture.getMediaStreamId. No documented Firefox/Safari equivalent (FIREFOX.md, SAFARI.md). |
offscreen |
yes | no | Hosts the persistent AudioContext/worklet graph outside the ephemeral service worker (Chromium-only API). |
<all_urls> content script |
no (Chromium uses tab capture as the primary path; content script is a fallback) | yes, for Compatibility Mode | Firefox/Safari have no full-tab capture API, so the content script must be able to discover <audio>/<video> elements on any page to offer PageMediaCapture at all. This is page-media mode — labeled "Compatibility Mode" in the UI, never "Full Tab Processing." |
No oauth2 manifest key and no chrome.identity permission are used anywhere — the PKCE hosted
handoff (above) replaces them on every browser (ADR-001).
Storage inventory
| What | Where | Notes |
|---|---|---|
| Firebase Auth session | IndexedDB (via indexedDBLocalPersistence, background only) |
Never audio. |
| Entitlement token (cached) | storage.local |
Verified ES256 JWT; refreshed per the cadence above. |
| Settings, presets, per-site/per-device profiles | storage.local / storage.sync as appropriate |
User-authored data only; never derived from browsing history (PRIVACY.md). |
Rate-limit bookkeeping (users/{uid}/installs) |
Firestore, server-side | Never exposed to the client (rules deny read). |
| One-time link codes | Firestore (linkCodes/{code}), server-side |
60s TTL, single-use, deleted-in-effect by the used flag; never exposed to the client. |
| Telemetry (opt-in only) | Sent to the configured analytics endpoint (allow-listed event names only, no payloads) | See PRIVACY.md for the exact 10-event allow-list. |
Nothing above is audio. No storage location, client- or server-side, ever holds captured PCM,
tab audio, microphone input, or video frames — see PRIVACY.md for the full never-transmitted
list, which is a structural invariant (src/core/dsp cannot import browser/network APIs), not a
configuration toggle.
Known gaps
These remaining limitations are explicit rather than silently accepted:
S2 — The Cloud Function must sign ES256 using raw P1363
r‖sencoding (NodedsaEncoding: 'ieee-p1363'if usingnode:crypto, orcrypto.subtleas currently implemented) with the header exactly{alg:'ES256', typ:'JWT', kid}. This is implemented infunctions/src/lib/entitlement.tsvia WebCrypto (which natively produces P1363); flagged here as a contract that must not regress to DER if the signing implementation ever changes.S3 —
src/core/entitlements/keys.tscurrently holds placeholder public keys. Production keys and KMS-backed provisioning of the corresponding private key (as theENTITLEMENT_SIGNING_KEYsecret consumed byfunctions/src/index.ts) must happen before shipping.SEC-08 residual phishing risk — the hosted page makes the user explicitly acknowledge the device-bound, never-share warning before a code is issued. A determined victim can still be socially engineered into completing that confirmation and revealing a code; the code is bound to the initiating install and PKCE verifier, which prevents its use on an attacker's install.
SEC-09 residual local-patching risk — offscreen/content runtimes validate and clamp DSP input before applying it, but do not independently verify an entitlement token. A locally patched extension can bypass client-side feature gates; this remains the documented hostile- client risk and is not a backend authorization bypass.
SEC-10 — a copied Firebase session plus copied known install ID can still re-mint as that install indefinitely. Solving it needs active-device/session tracking and an expiry/revocation policy, not a safe local change to the 5-per-30-day limiter.
SEC-13 — CI now gates high/critical dependency findings with
npm audit --audit-level=high. Actions remain pinned to maintained major tags rather than immutable commit SHAs; SHA pinning needs a repository-wide action-update process.SEC-14 —
linkCodes.expiresAtneeds a deployed Firestore TTL policy. Used/mismatched codes are burned, but Firestore TTL configuration is infrastructure state and cannot be enabled by application code alone.SEC-16 — the hosted page still uses the Firebase CDN SDK (pinned version) because it is a static, unbundled deployment. Its CSP is narrowed to the configured Firebase project placeholders; replace every
REPLACE_MEorigin at deployment.SEC-17 — telemetry is opt-in and has a closed event-name allow-list plus URL-shaped prop sanitization. The substring sanitizer should eventually become a strict value allow-list before adding dynamic telemetry properties.
SEC-18 — Firefox/Safari Compatibility Mode exposes the worklet as a web-accessible resource for page-media
AudioWorklet.addModule; this permits extension fingerprinting on those targets.Cross-browser compatibility, capture survivability, and CSP-in-production claims are largely untested pending the E2/E4/E6/E7/E8 experiments — see
BROWSER_COMPATIBILITY.md. A security review of the auth/entitlement code path by an independent reviewer (AGY) was planned but blocked on API quota at the time of this document; treat that review as not yet performed.
Dependency policy
CI runs npm audit --audit-level=high after npm ci; high and critical findings fail the build.
It is a dependency gate, not a guarantee that every transitive issue has a safe automatic upgrade.
Reporting a vulnerability
Contact the support address configured in src/data/info.ts (support_email). This is a
placeholder pending a dedicated security contact/process (e.g. a security@ address and/or a
disclosure policy page) — treat the current contact as provisional.
Dependency audit status (2026-09-07)
Production dependencies: npm audit --omit=dev --audit-level=high → 0 vulnerabilities (blocking in CI). Full audit still reports 4 high, all dev-only and all rooted in web-ext's addons-linter → image-size (DoS in ICNS/JXL/HEIF parsers); web-ext is only used for npm run lint:firefox on trusted local files and never ships. Tracked as an accepted dev-only exception; re-check on each web-ext release. The full audit runs in CI as non-blocking.