Skip to content

Deploying the browser extension

Status: Operator runbook for the Tavrik browser extension’s MDM-managed deployment per ADR-0040 (architecture) + ADR-0041 (distribution + signing-key lifecycle). M4E.12 ships the structural runbook; M4E.13 close-out captures real screenshots from the first end-to-end MDM exercise.

This runbook walks an operator through deploying the Tavrik browser extension into a managed Chromium / Edge / Firefox fleet via group policy. Three tiers:

  • Tier 1 — per-browser force-install runbooks. Full operational detail.
  • Tier 2 — MDM platform reference. Vendor-doc links + the specific policy fields per platform; operators apply Tier 1 through their MDM of choice.
  • Tier 3 — troubleshooting + diagnosis. Identity-gap mode, recognizer-bundle drift, JWT expiry, Web Store / AMO blocks.

Audience and assumptions

  • Audience: the operator’s IT administrator (or equivalent) responsible for the managed-browser fleet.
  • Assumptions:
    • You have administrative access to your organisation’s MDM (Microsoft Intune, Jamf, Workspace ONE, Google Admin, Kandji, Mosyle, etc.) OR direct registry / plist / configuration file access on the target machines.
    • Your tenant has been provisioned on the Tavrik gateway and you have a tenant tenant_id (UUID) plus per-user user_id (UUID) values for the users you intend to enroll.
    • You can obtain a per-user credential for each enrollment. Two operator-issued paths exist (ADR-0040 amendment (a); pilot blocker B1, shipped 2026-09-02), both on the admin console page /tenants/{tenant_id}/extension (operator or root role) or the admin API directly:
      • MDM enrolment (preferred for managed fleets): POST /v1/tenants/{tenant_id}/extensions/enrollments with {"user_id": "<uuid>"} returns {"jwt", "tenant_id", "user_id", "expires_at"} — the working credential, valid ≤ 24 h, shown once. Place it in the jwt field of the managed config below.
      • Pairing code (self-install / popup path): POST /v1/tenants/{tenant_id}/extensions/pairing-codes with {"user_id": "<uuid>", "lifetime_seconds": 600} returns a short-lived code (≤ 10 min, shown once; single-use since B4: the gateway consumes the code on the first successful exchange and refuses a replay with the same generic 401). The user pastes it into the extension popup; the extension exchanges it at POST /v1/extension/enroll for the working JWT. No managed jwt field is needed on this path.
      • Both require the gateway and gateway-admin to run with the same GATEWAY_EXTENSION_JWT_KEK_REF (the on-prem VM runbook §4; unset ⇒ the admin endpoints and every /v1/extension/* route return 404) and the gateway to be reachable from workstations. Each issuance is audited (admin.extension_pairing_code_issued / admin.extension_enrollment_issued, categorical only — never the secret).
      • Lifetime caveat the fleet must plan for: a working JWT lives ≤ 24 h and refreshes itself only while still valid (/v1/extension/enroll/refresh). A workstation idle longer than that (off over a weekend) drops into identity-gap mode and needs a fresh enrolment or pairing code. A longer-lived enrolment credential is a post-pilot ADR-0040 amendment.

Pre-deployment checklist

Before pushing any policy:

  1. Decide your packaging mode. Per ADR-0041 Decision 1, two modes ship: Chrome Web Store (Mode A; deferred — gated on demand) and operator self-hosted (Mode B; the M4E.12 ship default). Most healthcare / financial / sovereign-deployment buyers should use Mode B.
  2. For Mode B (self-hosted): generate your signing keypair under your own KMS / HSM custody. See § Signing-key generation below.
  3. Author the managed config. Per ADR-0041 Decision 3, the schema lives at extension/src/managed/schema.json. Required fields: tenant_id, user_id, jwt, gateway_url, recognizer_bundle_url. Optional: enrollment_id. Reserved (do not populate in M4E.12 deployments): policy_bundle_url. The jwt value comes from POST /v1/tenants/{tenant_id}/extensions/enrollments (console: /tenants/{tenant_id}/extension → “Issue MDM enrolment”) issued for that user_id — see the assumptions above.
  4. Validate field shapes. UUIDs must be RFC 4122 lowercase. URLs must be HTTPS. JWT must be three base64url-safe segments separated by dots. Schema-invalid managed config falls into identity-gap mode (per § Tier 3 Identity-gap mode diagnosis) — diagnose before pushing to a wide audience.
  5. Allowlist your extension id on the gateway. The gateway answers CORS for /v1/extension/* only for the origins in GATEWAY_EXTENSION_ALLOWED_ORIGINS (ADR-0040 amendment (c)). The Tavrik-signed Chrome id is the default; a re-signed (Mode B) or Firefox build adds chrome-extension://<your id> / moz-extension://<uuid>. Nothing is needed at your edge or in the manifest.
  6. Decide the rollout cadence. Pilot (1–10 users) → cohort (100 users) → full rollout. The 60s alarm cadence means JWT-related issues surface within ~1 minute of activation; bulk rollout once the pilot cohort reports clean.

Signing-key generation

Self-hosted Chrome / Edge variants derive their extension ID from the SHA-256 of the operator’s public key. Rotating the signing key changes the extension ID, which breaks every existing install — see ADR-0041 Decision 4 for the rotation runbook covering routine, compromise-triggered, and operator-elective rotations.

Generate an RSA-2048 (or larger) keypair, store the private key under your existing KMS / HSM custody, and publish the corresponding extension ID in your group policy.

Terminal window
# Example — generate a fresh PEM-formatted RSA-2048 private key.
# Real deployments should route key generation through the operator's
# HSM / KMS rather than openssl on a workstation.
openssl genrsa -out operator-signing.pem 2048
# Compute the extension ID from the public key (Chrome's CRX ID
# derivation: SHA-256 of the SubjectPublicKeyInfo, take the first
# 16 bytes, map each hex nibble to a-p alphabet).
openssl rsa -in operator-signing.pem -pubout -outform DER 2>/dev/null \
| openssl dgst -sha256 -binary \
| head -c 16 \
| xxd -p \
| tr 0-9a-f a-p

Pack the extension into a .crx for self-hosted distribution:

Terminal window
cd extension
pnpm build:chrome
CRX_PRIVATE_KEY_PATH=/secure/path/to/operator-signing.pem \
CRX_OUTPUT_PATH=/srv/updates/tavrik.crx \
pnpm build:chrome-selfhost

Host the .crx at an HTTPS URL reachable by your managed fleet. Author a CRX update XML manifest at a sibling URL declaring the version + .crx URL; this manifest is what Chrome’s update_url field points at.

<?xml version="1.0" encoding="UTF-8"?>
<gupdate xmlns="http://www.google.com/update2/response" protocol="2.0">
<app appid="<your-32-char-extension-id>">
<updatecheck codebase="https://updates.operator.example/tavrik.crx"
version="0.1.0" />
</app>
</gupdate>

Tier 1 — Per-browser force-install runbooks

Chrome Enterprise

Chrome Enterprise enforces extension installation via the ExtensionInstallForcelist policy. The format is the same across Windows / macOS / Linux; only the delivery mechanism differs.

Policy reference: Chrome Enterprise policy list — ExtensionInstallForcelist.

Windows (registry / GPO)

ADMX templates: chrome.admx + chrome.adml from the Chrome Enterprise download bundle. Drop into \\<domain>\sysvol\<domain>\Policies\PolicyDefinitions\.

Registry path:

HKEY_LOCAL_MACHINE\Software\Policies\Google\Chrome\ExtensionInstallForcelist

Add a string value named 1 (or the next sequential index) with content:

<32-char-extension-id>;https://updates.operator.example/tavrik-update.xml

ExtensionSettings controls the managed-storage policy values. Author ExtensionSettings in the registry as a JSON string under the Software\Policies\Google\Chrome key:

{
"<32-char-extension-id>": {
"installation_mode": "force_installed",
"update_url": "https://updates.operator.example/tavrik-update.xml",
"managed_storage": {
"tenant_id": "00000000-0000-0000-0000-000000000001",
"user_id": "00000000-0000-0000-0000-000000000abc",
"jwt": "<jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments>",
"gateway_url": "https://gateway.operator.example",
"recognizer_bundle_url": "https://bundles.operator.example/recognizer-v1.json"
}
}
}

macOS (plist)

Configuration profile path: /Library/Managed Preferences/com.google.Chrome.plist.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ExtensionInstallForcelist</key>
<array>
<string><32-char-extension-id>;https://updates.operator.example/tavrik-update.xml</string>
</array>
<key>ExtensionSettings</key>
<dict>
<key><32-char-extension-id></key>
<dict>
<key>installation_mode</key>
<string>force_installed</string>
<key>update_url</key>
<string>https://updates.operator.example/tavrik-update.xml</string>
<key>managed_storage</key>
<dict>
<key>tenant_id</key>
<string>00000000-0000-0000-0000-000000000001</string>
<key>user_id</key>
<string>00000000-0000-0000-0000-000000000abc</string>
<key>jwt</key>
<string><jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments></string>
<key>gateway_url</key>
<string>https://gateway.operator.example</string>
<key>recognizer_bundle_url</key>
<string>https://bundles.operator.example/recognizer-v1.json</string>
</dict>
</dict>
</dict>
</dict>
</plist>

Push via Jamf / Kandji / Mosyle / Workspace ONE — see Tier 2 for the per-vendor pointers.

Linux (JSON config)

Drop a JSON policy file at /etc/opt/chrome/policies/managed/tavrik.json:

{
"ExtensionInstallForcelist": [
"<32-char-extension-id>;https://updates.operator.example/tavrik-update.xml"
],
"ExtensionSettings": {
"<32-char-extension-id>": {
"installation_mode": "force_installed",
"update_url": "https://updates.operator.example/tavrik-update.xml",
"managed_storage": {
"tenant_id": "00000000-0000-0000-0000-000000000001",
"user_id": "00000000-0000-0000-0000-000000000abc",
"jwt": "<jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments>",
"gateway_url": "https://gateway.operator.example",
"recognizer_bundle_url": "https://bundles.operator.example/recognizer-v1.json"
}
}
}
}

Permissions: chmod 644 tavrik.json; ownership root:root.

Verification — chrome://policy/

After Chrome restart, the extension installs silently. Operators verify via:

chrome://policy/

Look for ExtensionInstallForcelist and ExtensionSettings rows; each should report Status=OK, Source=Platform (or Cloud, if Intune-pushed), and the value as authored. Reload (button at the top) forces a policy re-read without restart.

For managed-storage values, navigate to the extension’s options page (or the popup) — the popup status line reads Managed enrolment active (tenant <first-8-chars-of-tenant-id>…) when the schema validates.

Microsoft Edge

Edge re-uses Chrome’s policy schema verbatim. Substitute the registry / plist / JSON paths:

PlatformPath
WindowsHKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Edge\ExtensionInstallForcelist
macOS/Library/Managed Preferences/com.microsoft.Edge.plist
Linux/etc/opt/edge/policies/managed/tavrik.json

Verification: edge://policy/ (the Edge equivalent of chrome://policy/).

The managed-storage ExtensionSettings JSON shape is identical — Edge accepts the Chrome-target manifest verbatim per ADR-0040 Decision 7. The same dist/chrome/ source bundle (or self-hosted .crx) deploys to both browsers.

Firefox

Firefox does not consume Chrome’s ExtensionInstallForcelist schema. Firefox uses a dedicated policies.json file with the Extensions.Install array.

Policy reference: Mozilla — Enterprise policies via policies.json.

Locations

PlatformPath
Windows<Firefox install dir>\distribution\policies.json (or via GPO ADMX)
macOS/Applications/Firefox.app/Contents/Resources/distribution/policies.json
Linux/etc/firefox/policies/policies.json (or <Firefox install dir>/distribution/policies.json)

Policy content

{
"policies": {
"ExtensionSettings": {
"ai-esb@tavrik.ai": {
"installation_mode": "force_installed",
"install_url": "https://updates.operator.example/tavrik.xpi",
"default_area": "navbar"
}
},
"3rdparty": {
"Extensions": {
"ai-esb@tavrik.ai": {
"tenant_id": "00000000-0000-0000-0000-000000000001",
"user_id": "00000000-0000-0000-0000-000000000abc",
"jwt": "<jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments>",
"gateway_url": "https://gateway.operator.example",
"recognizer_bundle_url": "https://bundles.operator.example/recognizer-v1.json"
}
}
}
}
}

Firefox’s 3rdparty.Extensions.<gecko-id> block is the equivalent of Chrome’s managed_storage — operator-authored values surfaced to the extension via browser.storage.managed. Firefox does NOT consume extension/src/managed/schema.json automatically; the schema is duplicated as the runtime regex set in extension/src/background/identity.ts (per ADR-0041 Decision 3) and operators must apply the same shape by hand.

Signed XPI requirement

Firefox requires force-installed extensions to be signed by Mozilla (AMO) or distributed via the Enterprise channel with a valid gecko.id. Self-hosted XPI distribution requires either:

  1. AMO listing — submit the XPI to addons.mozilla.org for signing; Mozilla returns a signed XPI you host. M4E.12 does not exercise the AMO submission flow (parallel to the deferred Chrome Web Store flow per ADR-0041 Decision 1).
  2. Self-signing via Mozilla’s enterprise Add-on Signing API — operator signs the XPI under their AMO developer account; signed XPI is hosted on the operator’s update server. The update_url field in manifest.firefox.json is unused in Firefox MV3 (the policy’s install_url is what drives installation).

For ESR deployments, ensure your fleet runs Firefox ESR 128 or later — the manifest’s strict_min_version: "128.0" excludes older ESR releases. Operators on legacy ESR can request a build-time override; raise the request in your operator-success channel.

Firefox-specific signing-key advantage

Per ADR-0041 Decision 4, Firefox extension IDs are manifest-declared (gecko.id), not key-derived. Rotating the signing key on Firefox does not change the extension ID — existing installs accept the update without policy churn. This is a Firefox-channel advantage operators should consider when planning rotation cadence.

Verification — about:policies#active

After Firefox restart:

about:policies#active

Confirms the ExtensionSettings and 3rdparty blocks are applied. The extension popup status line should read Managed enrolment active (tenant <first-8-chars-of-tenant-id>…).


Tier 2 — MDM platform reference

This tier links to vendor docs for the most common managed-fleet platforms. Do not transcribe vendor UIs into this runbook — vendor screens change; the link is more durable. Every platform below ultimately authors the registry / plist / JSON / policies.json content from Tier 1; the platform is the delivery mechanism.

Microsoft Intune

  • Intune — configure Chrome Enterprise via custom configuration profile.
  • Policy field name: ExtensionInstallForcelist (under Google Chrome \ Extensions). Apply the Tier 1 Windows shape via Intune’s custom OMA-URI or ADMX-backed configuration profile.
  • For Edge: Intune ships a built-in Edge configuration profile template; use the Allow specific extensions to be installed setting backed by the same <extension-id>;<update-url> syntax.
  • For Firefox: Intune does not ship a first-class Firefox template; deliver policies.json via a custom configuration profile that drops the file at the platform path from Tier 1.

Jamf (macOS)

  • Jamf Pro — deploying Chrome Enterprise via configuration profiles.
  • Policy field name: ExtensionInstallForcelist inside an Application & Custom Settings payload targeting com.google.Chrome (or com.microsoft.Edge, or Firefox via a Custom Settings payload writing to /etc/firefox/policies/policies.json).
  • Apply the Tier 1 macOS plist content as the payload contents.

VMware Workspace ONE

Google Admin (Workspace)

  • Google Workspace — Chrome Browser Cloud Management.
  • Policy field name: Force-installed Apps and Extensions under Chrome Browser ▸ User & Browser Settings.
  • Workspace-managed Chrome installations honour CBCM policies even on personal devices when the user signs in with a managed account; useful for hybrid fleets.

Kandji (macOS)

Mosyle (macOS)


Tier 3 — Troubleshooting + diagnosis

Identity-gap mode diagnosis

The extension enters identity-gap mode in three cases per ADR-0041 Decision 3:

  1. No managed config + no paired identity. The MDM has not yet pushed config and the user has not enrolled via the popup. Popup string: Not enrolled. Paste a pairing code below.
  2. Managed config present but schema-invalid. One or more required fields missing or malformed (non-UUID tenant_id, non-HTTPS gateway_url, two-segment jwt, etc.). Popup string: Managed configuration is invalid. Contact your IT administrator.
  3. Managed config valid but JWT expired. JWT validation is server-side; an expired JWT surfaces as a 401 on the next /v1/extension/evaluate call. The extension does not enter identity-gap mode automatically on JWT expiry — the worker queues observations locally and replays once the JWT refreshes.
  4. Origin not allowlisted on the gateway. Every call fails at the browser with Failed to fetch (popup: Enrol failed: Failed to fetch) and nothing reaches the gateway log — the preflight OPTIONS /v1/extension/* was refused. Check the gateway’s startup line extension CORS allowlist against the id shown on chrome://extensions/; fix GATEWAY_EXTENSION_ALLOWED_ORIGINS.

Step-by-step diagnosis

  1. Open the extension popup. Read the status string. The two cases above map to two different operator actions.

  2. Open chrome://policy/ (Edge: edge://policy/; Firefox: about:policies#active). Confirm the policy blocks are applied at the browser layer.

  3. Inspect chrome.storage.managed from the extension’s service-worker DevTools. From chrome://extensions/, click “service worker” under the Tavrik entry; in the DevTools console run:

    chrome.storage.managed.get(null).then(v => console.log(v));

    Operators see exactly what the browser exposes to the extension. Compare against the Tier 1 schema; mismatches localize the misconfiguration.

  4. Re-author the managed config. Apply the corrected content via the MDM and force a policy refresh (chrome://policy/?reload).

Revoking a device

Shipped 2026-09-02 (ADR-0040 amendment (b), pilot blocker B4). Every working credential has an enrolment row (GET /v1/tenants/{tenant_id}/extensions; console /tenants/{tenant_id}/extension → “Enrolled extensions”) keyed by the credential’s own id, with the user, how it was issued (pairing code, MDM enrolment, refresh), expiry, last activity and the tool last seen.

  1. Find the row — by the pseudonymous user_id you assigned at issuance, or by last activity / tool.
  2. Revoke it — console Revoke (reason required: lost or stolen device, user left, credential compromised, other) or POST /v1/tenants/{tenant_id}/extensions/{enrollment_id}/revoke {"reason": "lost_device"} (operator or root). Audited as admin.extension_revoked with the reason.
  3. What happens next — the gateway refuses that credential on POST /v1/extension/evaluate, POST /v1/extension/enroll/refresh and POST /v1/extension/audit/replay within 60 seconds (401, code extension_revoked; audited as extension.token_revoked). The extension drops into identity-gap mode on the next tick and shows the enrolment hint. A credential refreshed from the revoked one is refused too; revoking never cascades to other devices of the same user — revoke each row. The reverse is also true: a refresh does not revoke its predecessor — the older credential shows as replaced and stays valid until it expires, so for a lost device revoke the replaced row as well as the current one (the console offers Revoke on both).
  4. Re-enrol the user with a new pairing code or MDM enrolment when appropriate.

Revocation is per credential, and credentials live ≤ 24 h, so a revoked row leaves the list once it has also expired.

Recognizer-bundle drift

Per ADR-0040 Decision 6, the extension stamps recognizer_bundle_version on every extension.observed audit event. The gateway compares against its server-side bundle version; a mismatch fires the extension.recognizer_drift audit event.

Diagnosis

  • Query the operator audit dashboard for extension.recognizer_drift events scoped to the affected tenant_id. The event attributes include extension_version + expected_recognizer_bundle_version + observed_recognizer_bundle_version.
  • Drift typically resolves on the next 60s alarm tick (the extension refreshes the bundle on the periodic alarm). Persistent drift across >5 minutes signals a pinned-old-version client; investigate via the user’s chrome://extensions/ for the extension version.

Resolution

  • Push the new extension version via your MDM update channel. The chrome://policy/?reload refresh cycle picks up the new update_url content.
  • For self-hosted deployments, host the new .crx at the same update_url; bump the version in the update XML manifest.

JWT expiry + refresh

Per ADR-0040 Decision 3, JWTs are short-lived (24h max). The extension’s 60s alarm refreshes the JWT when within 5 minutes of expiry via POST /v1/extension/enroll/refresh.

Refresh-failure symptoms:

  • Popup shows Managed enrolment active (tenant <8-chars>…) but tool integrations stop emitting audit events.
  • Gateway audit log shows no extension.observed events for the user despite confirmed prompt activity.
  • Service-worker DevTools console shows policy-client: HTTP 401 lines on the periodic alarm tick.

Resolution path:

  1. Verify the gateway_url field is reachable from the user’s network (some sovereign-deployment fleets restrict HTTPS egress; the extension’s fetch() calls require connectivity to the configured gateway).
  2. Confirm the JWT in chrome.storage.managed is recent. JWTs older than 24h require an MDM re-push of a fresh JWT from POST /v1/tenants/{tenant_id}/extensions/enrollments (console: “Issue MDM enrolment”), or — on the self-install path — a new pairing code from …/extensions/pairing-codes pasted into the popup.
  3. If the fleet is on a restricted egress posture, ensure the gateway URL is on the egress allowlist alongside the recognizer_bundle_url host.

Extension blocked by Web Store / AMO policy

If your operator profile uses the Chrome Web Store path (Mode A — deferred per ADR-0041 Decision 1) and the listing is removed or suspended by Google, the extension stops receiving updates fleet-wide. Mitigation:

  1. Switch to the self-hosted path (Mode B). The same source bundle in dist/chrome/ packs into a self-hosted .crx via pnpm build:chrome-selfhost.
  2. Update group policy to point at the operator’s update_url instead of the CWS update endpoint.
  3. Operators on Firefox (where AMO is the analogous listing channel) follow the same fallback to operator-signed XPI distribution per Tier 1 Firefox.

Self-hosted is the M4E.12 ship default specifically because this scenario does not depend on a third-party listing channel.


Appendix: signing-key rotation runbook

For the three rotation triggers (routine cadence, compromise-triggered, operator-elective), follow the workflow in ADR-0041 Decision 4.

The TL;DR per trigger:

  • Routine (planned, months of notice): generate new keypair, publish new-ID extension alongside old, MDM-push both IDs during a 2-week transition window, retire old after window closes.
  • Compromise (urgent, hours): publish new-ID extension immediately; MDM-push new ID; mark old ID as blocked (not deleted — forensic preservation); record incident in operator’s incident-response trail.
  • Operator-elective (planned, weeks): same staged path as routine; cutover timeline is operator-decided.

Firefox exemption: signing-key rotation does NOT change gecko.id on Firefox; existing Firefox installs accept the new XPI without policy churn. Operators get a free pass on Firefox key rotation.


Glossary

TermMeaning
CRXChrome eXtension. The signed binary format for Chromium-based browsers. CRX v3 is the current format.
MV3Manifest V3 — the current Chrome / Firefox extension manifest schema. Replaces MV2.
CWSChrome Web Store. Google’s first-party extension listing channel.
AMOaddons.mozilla.org. Mozilla’s first-party extension listing channel.
MDMMobile Device Management — the umbrella for managed-endpoint platforms (Intune, Jamf, Workspace ONE, etc.).
GPOGroup Policy Object — Windows’ policy mechanism for Chrome Enterprise / Edge configuration.
ESRExtended Support Release. Firefox’s enterprise channel; Tavrik targets ESR 128 or later.
ExtensionInstallForcelistChrome / Edge’s policy field for force-install extensions.
gecko.idFirefox’s manifest-declared extension ID. Stable across signing-key rotation.
identity-gap modePer ADR-0040 Decision 5: extension refuses to observe when no valid identity is present; popup surfaces enrollment flow or misconfiguration cue.
  • ADR-0040 — Browser extension architecture: the load-bearing privacy + lifecycle decisions.
  • ADR-0041 — Extension distribution + signing-key lifecycle: the architectural source for this runbook.
  • extension/src/managed/schema.json: the canonical managed-config schema authored by operators.
  • extension/scripts/pack-crx.ts: the operator-runnable CRX-pack script invoked by pnpm build:chrome-selfhost.