#!/usr/bin/env python3 """Build and verify a signed Chrome MV3 extension release.""" import argparse import base64 import hashlib import json import os from pathlib import Path import re import shutil import tempfile import xml.etree.ElementTree as ET import zipfile from crx3 import creator, id_util, key_util, verifier from cryptography.hazmat.primitives.asymmetric import rsa DOWNLOAD_BASE = "https://git.komelt.dev/komelt/history-collector/releases/download" VERSION_RE = re.compile(r"^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){0,3}$") TAG_RE = re.compile( r"^v((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*))?)$" ) def chrome_version(value, label): if not isinstance(value, str) or not VERSION_RE.fullmatch(value): raise ValueError(f"{label} must contain 1 to 4 numeric Chrome version components") components = value.split(".") if any(int(component) > 65535 for component in components): raise ValueError(f"{label} contains a component greater than 65535") return value def release_version(tag): match = TAG_RE.fullmatch(tag) if not match: raise ValueError("tag must match vX.Y.Z or vX.Y.Z.N with numeric components") return chrome_version(match.group(1), "tag version") def read_manifest(path): try: manifest = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise ValueError(f"cannot read manifest: {error}") from error if not isinstance(manifest, dict): raise ValueError("manifest must be a JSON object") return manifest def write_deterministic_zip(source, destination): with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(path for path in source.rglob("*") if path.is_file()): relative = path.relative_to(source).as_posix() info = zipfile.ZipInfo(relative, (1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o100644 << 16 archive.writestr(info, path.read_bytes()) def write_updates_xml(path, extension_id, version, tag): namespace = "http://www.google.com/update2/response" ET.register_namespace("", namespace) root = ET.Element(f"{{{namespace}}}gupdate", {"protocol": "2.0"}) app = ET.SubElement(root, f"{{{namespace}}}app", {"appid": extension_id}) ET.SubElement( app, f"{{{namespace}}}updatecheck", { "codebase": f"{DOWNLOAD_BASE}/{tag}/history-collector-{version}.crx", "version": version, }, ) ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) def sha256(path): digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--tag", required=True) parser.add_argument("--private-key", required=True, type=Path) parser.add_argument("--extension-dir", type=Path, default=Path("extension")) parser.add_argument("--output-dir", type=Path, required=True) args = parser.parse_args() version = release_version(args.tag) extension_dir = args.extension_dir.resolve() manifest_path = extension_dir / "manifest.json" manifest = read_manifest(manifest_path) manifest_version = chrome_version(manifest.get("version"), "manifest version") if manifest_version != version: raise ValueError(f"tag version {version} does not match manifest version {manifest_version}") if not extension_dir.is_dir(): raise ValueError(f"extension directory does not exist: {extension_dir}") private_key_path = args.private_key.resolve() try: private_key_inside_extension = ( os.path.commonpath((str(extension_dir), str(private_key_path))) == str(extension_dir) ) except ValueError: private_key_inside_extension = False if private_key_inside_extension: raise ValueError("private key must not be inside the extension directory") try: private_key = key_util.load_private_key_from_pem(private_key_path.read_bytes()) except (OSError, ValueError, TypeError) as error: raise ValueError(f"cannot load RSA private key: {error}") from error if not isinstance(private_key, rsa.RSAPrivateKey): raise ValueError("private key must be RSA") public_key_der = key_util.get_public_key_data(private_key.public_key()) extension_id = id_util.convert_hex_crx_id_to_alphabet( id_util.calc_crx_id_by_public_key(public_key_der).hex() ) args.output_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="history-collector-extension-") as temporary: staging = Path(temporary) / "extension" shutil.copytree(extension_dir, staging) staged_manifest = read_manifest(staging / "manifest.json") staged_manifest["version"] = version staged_manifest["key"] = base64.b64encode(public_key_der).decode("ascii") (staging / "manifest.json").write_text( json.dumps(staged_manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) zip_path = args.output_dir / f"history-collector-{version}.zip" crx_path = args.output_dir / f"history-collector-{version}.crx" updates_path = args.output_dir / "updates.xml" extension_id_path = args.output_dir / "extension-id.txt" checksums_path = args.output_dir / "checksums.txt" write_deterministic_zip(staging, zip_path) creator.create_crx_file(str(zip_path), str(private_key_path), str(crx_path)) result, header = verifier.verify(str(crx_path)) if result != verifier.VerifierResult.OK_FULL or header is None or header.crx_id != extension_id: raise ValueError("generated CRX3 failed verification or has the wrong extension ID") write_updates_xml(updates_path, extension_id, version, args.tag) extension_id_path.write_text(extension_id + "\n", encoding="ascii") checksum_paths = [zip_path, crx_path, updates_path, extension_id_path] checksums_path.write_text( "".join(f"{sha256(path)} {path.name}\n" for path in checksum_paths), encoding="ascii" ) print(f"Built and verified {args.tag} (extension ID {extension_id})") if __name__ == "__main__": try: main() except (OSError, ValueError) as error: raise SystemExit(f"build failed: {error}") from error