161 lines
6.5 KiB
Python
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
|