Manifest Parser & CRX Loader
This document describes how Helium loads Chrome extensions from CRX files and unpacked directories, parses their manifests, resolves permissions, and registers them for execution.
CRX File Format
Section titled “CRX File Format”Chrome extensions are distributed as .crx files, which are ZIP archives with a binary header containing cryptographic signatures.
CRX3 Binary Format
Section titled “CRX3 Binary Format”[4 bytes] Magic number: "Cr24" (0x43723234)[4 bytes] CRX format version: 3 (little-endian uint32)[4 bytes] Header length in bytes (little-endian uint32)[N bytes] Header (Protocol Buffer, CrxFileHeader message)[M bytes] ZIP archive containing extension filesCRX3 Header (Protocol Buffer)
Section titled “CRX3 Header (Protocol Buffer)”message CrxFileHeader { // SHA256 hash of the "signed data" portion repeated AsymmetricKeyProof sha256_with_rsa = 2; repeated AsymmetricKeyProof sha256_with_ecdsa = 3; // The signed data itself optional bytes signed_header_data = 10000;}
message AsymmetricKeyProof { optional bytes public_key = 1; optional bytes signature = 2;}CRXUnpacker Implementation
Section titled “CRXUnpacker Implementation”class CRXUnpacker { /** * Unpack a CRX3 file into its constituent parts. * * @param buffer - ArrayBuffer containing the CRX file * @returns Unpacked extension data */ static unpack(buffer: ArrayBuffer): UnpackedExtension { const view = new DataView(buffer);
// 1. Verify magic number const magic = String.fromCharCode( view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3) ); if (magic !== 'Cr24') { throw new Error('Not a CRX file: invalid magic number'); }
// 2. Read version const version = view.getUint32(4, true); if (version !== 3) { throw new Error(`Unsupported CRX version: ${version}`); }
// 3. Read header length const headerLength = view.getUint32(8, true);
// 4. Extract header (for signature verification, if needed) const headerBytes = new Uint8Array(buffer, 12, headerLength);
// 5. Extract ZIP payload (everything after the header) const zipOffset = 12 + headerLength; const zipBytes = new Uint8Array(buffer, zipOffset);
// 6. Extract public key from header for extension ID derivation const publicKey = this.extractPublicKey(headerBytes);
// 7. Derive extension ID from public key (SHA-256 hash, first 16 bytes, hex-encoded with a-p alphabet) const extensionId = this.deriveExtensionId(publicKey);
return { extensionId, publicKey, zipBytes, }; }
/** * Derive a Chrome extension ID from a public key. * Chrome uses the first 128 bits of SHA-256(public_key), * encoded as lowercase hex but using the alphabet a-p instead of 0-9a-f. */ static async deriveExtensionId(publicKey: Uint8Array): Promise<string> { const hash = await crypto.subtle.digest('SHA-256', publicKey); const hashBytes = new Uint8Array(hash).slice(0, 16); return Array.from(hashBytes) .map(b => { const hi = (b >> 4) & 0xf; const lo = b & 0xf; return String.fromCharCode(97 + hi) + String.fromCharCode(97 + lo); }) .join(''); }}Unpacked Extension Support
Section titled “Unpacked Extension Support”For development, extensions can also be loaded from a plain directory (or a JavaScript object representing the file tree):
interface UnpackedExtensionInput { files: Map<string, Uint8Array | string>; // path → content}This is useful for:
- Extensions bundled directly into the host application
- Development/testing without CRX packaging
- Extensions loaded from a URL (fetch all files individually)
Manifest Parsing
Section titled “Manifest Parsing”ManifestParser
Section titled “ManifestParser”interface ParsedManifest { // Identity manifest_version: 2 | 3; name: string; version: string; description?: string; default_locale?: string;
// Permissions permissions: string[]; optional_permissions?: string[]; host_permissions?: string[]; // MV3 only optional_host_permissions?: string[]; // MV3 only
// Background background?: { // MV2 scripts?: string[]; page?: string; persistent?: boolean;
// MV3 service_worker?: string; type?: 'module'; };
// Content scripts content_scripts?: ContentScriptDeclaration[];
// Action / Browser Action action?: ActionManifest; // MV3 browser_action?: ActionManifest; // MV2 page_action?: ActionManifest; // MV2
// Extension pages options_page?: string; // MV2 (legacy) options_ui?: { page: string; open_in_tab?: boolean }; chrome_url_overrides?: { newtab?: string; bookmarks?: string; history?: string; }; devtools_page?: string; side_panel?: { default_path: string }; // MV3
// Web-accessible resources web_accessible_resources?: WebAccessibleResource[];
// Content Security Policy content_security_policy?: string | { // string in MV2, object in MV3 extension_pages?: string; sandbox?: string; };
// Icons icons?: Record<string, string>;
// Other update_url?: string; minimum_chrome_version?: string; key?: string; // Base64-encoded public key externally_connectable?: { ids?: string[]; matches?: string[]; accepts_tls_channel_id?: boolean; };
// DeclarativeNetRequest (MV3) declarative_net_request?: { rule_resources: Array<{ id: string; enabled: boolean; path: string; }>; };
// Commands commands?: Record<string, { suggested_key?: Record<string, string>; description?: string; global?: boolean; }>;}
interface ContentScriptDeclaration { matches: string[]; exclude_matches?: string[]; include_globs?: string[]; exclude_globs?: string[]; js?: string[]; css?: string[]; run_at?: 'document_start' | 'document_end' | 'document_idle'; all_frames?: boolean; match_about_blank?: boolean; match_origin_as_fallback?: boolean; world?: 'ISOLATED' | 'MAIN';}
interface ActionManifest { default_icon?: string | Record<string, string>; default_title?: string; default_popup?: string;}
// MV2: string[] of file paths// MV3: object[] with resource/matchestype WebAccessibleResource = | string // MV2 | { resources: string[]; matches: string[]; extension_ids?: string[] }; // MV3Parsing Logic
Section titled “Parsing Logic”class ManifestParser { static parse(manifestJson: string): ParsedManifest { const raw = JSON.parse(manifestJson);
// 1. Validate required fields this.requireField(raw, 'manifest_version', [2, 3]); this.requireField(raw, 'name', 'string'); this.requireField(raw, 'version', 'string');
// 2. Validate manifest_version-specific fields if (raw.manifest_version === 3) { // MV3: background must use service_worker, not scripts/page if (raw.background?.scripts || raw.background?.page) { throw new ManifestError( 'MV3 extensions must use background.service_worker, not background.scripts or background.page' ); } // MV3: host_permissions must be separate if (raw.permissions?.some(p => this.isHostPermission(p))) { console.warn('MV3: host permissions should be in host_permissions, not permissions'); } // MV3: browser_action is not valid if (raw.browser_action) { throw new ManifestError('MV3 extensions must use "action", not "browser_action"'); } }
if (raw.manifest_version === 2) { // MV2: service_worker not valid if (raw.background?.service_worker) { throw new ManifestError('MV2 extensions must use background.scripts or background.page, not service_worker'); } // MV2: action is not valid if (raw.action) { throw new ManifestError('MV2 extensions must use "browser_action", not "action"'); } }
// 3. Normalize content_scripts if (raw.content_scripts) { for (const cs of raw.content_scripts) { cs.run_at = cs.run_at || 'document_idle'; cs.all_frames = cs.all_frames || false; cs.match_about_blank = cs.match_about_blank || false; cs.world = cs.world || 'ISOLATED'; } }
// 4. Normalize web_accessible_resources if (raw.web_accessible_resources && raw.manifest_version === 2) { // MV2 uses string[], convert to MV3-style objects for uniform handling raw._normalizedWAR = raw.web_accessible_resources.map(path => ({ resources: [path], matches: ['<all_urls>'], })); } else if (raw.web_accessible_resources && raw.manifest_version === 3) { raw._normalizedWAR = raw.web_accessible_resources; }
return raw as ParsedManifest; }
private static isHostPermission(perm: string): boolean { return perm.includes('://') || perm === '<all_urls>' || perm.startsWith('*://'); }}Permission Resolution
Section titled “Permission Resolution”PermissionResolver
Section titled “PermissionResolver”Takes a parsed manifest and produces a set of capability flags used at runtime for permission enforcement.
interface ResolvedPermissions { // API namespace permissions (e.g., "tabs", "bookmarks", "storage") apiPermissions: Set<string>;
// Host permissions as match patterns hostPermissions: MatchPatternSet;
// Optional permissions that can be requested at runtime optionalApiPermissions: Set<string>; optionalHostPermissions: MatchPatternSet;
// Granted optional permissions (starts empty, populated via chrome.permissions.request) grantedOptionalApi: Set<string>; grantedOptionalHosts: MatchPatternSet;
// Special flags hasAllUrls: boolean; hasActiveTab: boolean; hasUnlimitedStorage: boolean;}
class PermissionResolver { static resolve(manifest: ParsedManifest): ResolvedPermissions { const apiPerms = new Set<string>(); const hostPerms = new MatchPatternSet();
// Separate API permissions from host permissions for (const perm of manifest.permissions || []) { if (this.isHostPermission(perm)) { hostPerms.add(perm); } else { apiPerms.add(perm); } }
// MV3: host_permissions array for (const perm of manifest.host_permissions || []) { hostPerms.add(perm); }
// Implicit permissions // - storage is always available (no permission needed) // - runtime is always available // - i18n is always available // - extension is always available apiPerms.add('storage'); apiPerms.add('runtime'); apiPerms.add('i18n'); apiPerms.add('extension');
return { apiPermissions: apiPerms, hostPermissions: hostPerms, optionalApiPermissions: new Set(manifest.optional_permissions || []), optionalHostPermissions: new MatchPatternSet(manifest.optional_host_permissions || []), grantedOptionalApi: new Set(), grantedOptionalHosts: new MatchPatternSet(), hasAllUrls: hostPerms.has('<all_urls>'), hasActiveTab: apiPerms.has('activeTab'), hasUnlimitedStorage: apiPerms.has('unlimitedStorage'), }; }}Match Pattern Evaluation
Section titled “Match Pattern Evaluation”Chrome’s match patterns follow the format: <scheme>://<host>/<path>
class MatchPatternSet { private patterns: MatchPattern[] = [];
add(patternStr: string): void { this.patterns.push(MatchPattern.parse(patternStr)); }
matches(url: string): boolean { return this.patterns.some(p => p.matches(url)); }}
class MatchPattern { scheme: string; // "http", "https", "*", "file", "ftp" host: string; // "*.example.com", "example.com", "*" path: string; // "/*", "/foo/*", "/bar/baz"
static parse(pattern: string): MatchPattern { if (pattern === '<all_urls>') { return { scheme: '*', host: '*', path: '/*' }; }
// Pattern format: <scheme>://<host><path> const match = pattern.match(/^(\*|https?|file|ftp):\/\/(\*|(?:\*\.)?[^/]+)(\/.*)?$/); if (!match) { throw new Error(`Invalid match pattern: ${pattern}`); }
return { scheme: match[1], host: match[2], path: match[3] || '/*', }; }
matches(url: string): boolean { const parsed = new URL(url);
// Scheme match if (this.scheme !== '*') { if (parsed.protocol !== this.scheme + ':') return false; } else { if (!['http:', 'https:'].includes(parsed.protocol)) return false; }
// Host match if (this.host !== '*') { if (this.host.startsWith('*.')) { const domain = this.host.slice(2); if (parsed.hostname !== domain && !parsed.hostname.endsWith('.' + domain)) { return false; } } else { if (parsed.hostname !== this.host) return false; } }
// Path match (simple glob: * matches anything) const pathPattern = this.path .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex chars .replace(/\*/g, '.*'); // * → .* const pathRegex = new RegExp(`^${pathPattern}$`); return pathRegex.test(parsed.pathname); }}Content Script Registration
Section titled “Content Script Registration”After manifest parsing, content scripts need to be registered with the Reflux injection plugin so they get injected into matching pages.
interface RegisteredContentScript { extensionId: string; matches: MatchPatternSet; excludeMatches: MatchPatternSet; includeGlobs: GlobSet; excludeGlobs: GlobSet; js: string[]; // Paths relative to extension root css: string[]; runAt: 'document_start' | 'document_end' | 'document_idle'; allFrames: boolean; matchAboutBlank: boolean; world: 'ISOLATED' | 'MAIN';}The Reflux injection plugin (Layer 5) queries this registry on every HTML response to determine which content scripts to inject.
Extension ID Generation
Section titled “Extension ID Generation”For unpacked extensions (no CRX public key), IDs are generated deterministically:
async function generateExtensionId(extensionName: string): Promise<string> { // Use a deterministic hash of the extension name + a fixed salt // This ensures the same extension always gets the same ID const encoder = new TextEncoder(); const data = encoder.encode('helium-ext:' + extensionName); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashBytes = new Uint8Array(hashBuffer).slice(0, 16);
// Chrome's a-p alphabet encoding return Array.from(hashBytes) .map(b => String.fromCharCode(97 + (b >> 4)) + String.fromCharCode(97 + (b & 0xf))) .join('');}For CRX-packaged extensions, the ID is derived from the public key in the CRX header (see CRXUnpacker.deriveExtensionId).
Virtual Filesystem
Section titled “Virtual Filesystem”Extension files are stored in a virtual filesystem backed by IndexedDB or OPFS with atomic write guarantees:
interface ExtensionFileSystem { /** Write a file to the extension's staging area (not live) */ stageFile(extensionId: string, path: string, content: Uint8Array): Promise<void>;
/** Atomically swap staging area → live (single IndexedDB transaction) */ commitStaging(extensionId: string, expectedFileCount: number): Promise<void>;
/** Delete the staging area (cleanup on failure) */ abortStaging(extensionId: string): Promise<void>;
/** Read a file from the extension's live storage */ readFile(extensionId: string, path: string): Promise<Uint8Array | null>;
/** Check if a file exists in live storage */ exists(extensionId: string, path: string): Promise<boolean>;
/** List all files for an extension in live storage */ listFiles(extensionId: string): Promise<string[]>;
/** Delete all files for an extension (live + staging) */ deleteAll(extensionId: string): Promise<void>;}IndexedDB schema:
Database: "helium-extensions"
Object store: "files" Key: [extensionId, path] (compound key) Value: { extensionId: string, path: string, content: Uint8Array, mimeType: string, size: number, }
Object store: "staging" Key: [extensionId, path] (compound key) Value: { extensionId: string, path: string, content: Uint8Array, mimeType: string, size: number, }
Object store: "metadata" Key: extensionId Value: { extensionId: string, manifest: ParsedManifest, permissions: ResolvedPermissions, enabled: boolean, installDate: number, updateDate: number, version: string, }Atomic Commit Implementation
Section titled “Atomic Commit Implementation”class AtomicExtensionFS implements ExtensionFileSystem { async commitStaging(extensionId: string, expectedFileCount: number): Promise<void> { // 1. Validate staging area completeness const stagedFiles = await this.getStagedFiles(extensionId); if (stagedFiles.length !== expectedFileCount) { await this.abortStaging(extensionId); throw new Error( `Staging validation failed: expected ${expectedFileCount} files, ` + `got ${stagedFiles.length}. Staging area cleaned up.` ); }
// 2. Atomic swap in a SINGLE IndexedDB transaction const tx = this.db.transaction(['files', 'staging'], 'readwrite'); const filesStore = tx.objectStore('files'); const stagingStore = tx.objectStore('staging');
// Delete old live files for this extension const oldFiles = await filesStore.index('extensionId').getAll(extensionId); for (const file of oldFiles) { filesStore.delete([extensionId, file.path]); }
// Move staged files to live for (const file of stagedFiles) { filesStore.put(file); stagingStore.delete([extensionId, file.path]); }
await tx.complete; // Either ALL succeed or ALL roll back }
async abortStaging(extensionId: string): Promise<void> { const tx = this.db.transaction('staging', 'readwrite'); const staged = await tx.store.index('extensionId').getAll(extensionId); for (const file of staged) { tx.store.delete([extensionId, file.path]); } await tx.complete; }}Full Load Sequence (Atomic Staging-Then-Swap)
Section titled “Full Load Sequence (Atomic Staging-Then-Swap)”1. CRX/directory input received
2. If CRX: a. CRXUnpacker.unpack(buffer) → { extensionId, publicKey, zipBytes } b. Unzip zipBytes → Map<string, Uint8Array> If directory: a. Read all files → Map<string, Uint8Array> b. Generate extensionId from name or key
3. Read manifest.json from file map → ManifestParser.parse(manifestJson) → ParsedManifest
4. PermissionResolver.resolve(manifest) → ResolvedPermissions
5. Stage all files to staging area (NOT live): for (const [path, content] of files) { await fs.stageFile(extensionId, path, content); }
NOTE: If the process crashes during staging, the live area is untouched. On next startup, orphaned staging entries are cleaned up.
6. Validate and atomically commit staging → live: await fs.commitStaging(extensionId, files.size);
- If validation fails (file count mismatch), staging is aborted - If the IndexedDB transaction fails, nothing changes - For UPDATES: old live files are deleted and new files installed in the SAME transaction — either the update fully applies or the old version remains completely intact
7. Write metadata to "metadata" store: await metadata.put({ extensionId, manifest, permissions, enabled: true, installDate: Date.now(), });
8. Register content scripts with Reflux injection plugin: for (const cs of manifest.content_scripts) { refluxInjectionPlugin.registerContentScript({ extensionId, ...normalizeContentScript(cs), }); }
9. Register declarativeNetRequest rules (MV3): if (manifest.declarative_net_request) { for (const ruleResource of manifest.declarative_net_request.rule_resources) { const rulesJson = await fs.readFile(extensionId, ruleResource.path); declarativeNetRequest.addStaticRules(extensionId, ruleResource.id, JSON.parse(rulesJson)); } }
10. Create background execution context (Layer 2): if (manifest.background) { executionContextManager.createBackground(extensionId, manifest); }
11. Fire chrome.runtime.onInstalled to the new extension's background: emit to extensionId: runtime.onInstalled({ reason: isUpdate ? 'update' : 'install', id: extensionId, previousVersion: isUpdate ? oldVersion : undefined, });
12. Fire chrome.management.onInstalled to all other extensions: broadcastToAll: management.onInstalled({ id: extensionId, name: manifest.name, ... });Orphaned Staging Cleanup (Startup)
Section titled “Orphaned Staging Cleanup (Startup)”On application startup, before loading any extensions, clean up any staging areas left by failed installations:
async function cleanupOrphanedStaging(): Promise<void> { const tx = db.transaction('staging', 'readwrite'); const allStaged = await tx.store.getAll(); const orphanedIds = new Set(allStaged.map(f => f.extensionId));
for (const extId of orphanedIds) { console.warn(`[Helium] Cleaning up orphaned staging area for extension ${extId}`); for (const file of allStaged.filter(f => f.extensionId === extId)) { tx.store.delete([extId, file.path]); } } await tx.complete;}