Files
history-collector/scripts/build_extension.py
T

161 lines
6.5 KiB
Python

#!/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