← Commit history

Release signing through Azure Trusted Signing on the signing host (tools/sign_pe.py, the altium-bridge path); release.sh uses it

Colby Knox ·3955934233 ·16d ago ·parent 4629b5b
2 files changed +167−7
tools/release.sh+12−7
@@ -202,14 +202,19 @@ grep -q "version = \"$VERSION\"" rust/Cargo.toml || die "rust workspace is not a echo "  rust exe $(stat -c%s "$RUST_EXE") bytes @ $VERSION"  # --- 3b. SIGN the exe: never publish an unsigned one -----------------------------------------# Windows 11 Smart App Control blocks unsigned exes ("Part of this app has been blocked"). The-# signing helper is PRIVATE to the maintainer's machine and deliberately not in this public repo;-# a release without it, or whose signature does not verify, stops here. Signing is the LAST-# change to the exe: the zip below takes this exact file.+# Windows 11 Smart App Control blocks unsigned exes ("Part of this app has been blocked").+# tools/sign_pe.py signs through Azure Trusted Signing on the Windows host named by the PRIVATE+# config ($ADOM_SIGNING_DIR, default ~/.adom/signing/altium-bridge: the altium-bridge release's+# path), gates on the host's verdict, pulls the file back and verifies it again here; that config+# is deliberately not in this public repo, and a release without it, or whose signature does not+# verify, stops here. Signing is the LAST change to the exe: the zip below takes this exact file. step "Signing the exe"-SIGNER="${ADOM_SIGN_HELPER:-$HOME/.config/adom-signing/sign-exe.sh}"-[ -x "$SIGNER" ] || die "no signing helper at $SIGNER: an unsigned exe is never published (Smart App Control blocks it)"-"$SIGNER" "$RUST_EXE" "fusion-bridge-signing" || die "signing failed or did not verify: nothing was published"+if [ -n "${ADOM_SIGN_HELPER:-}" ]; then+  "$ADOM_SIGN_HELPER" "$RUST_EXE" "fusion-bridge-signing" || die "signing failed or did not verify: nothing was published"+else+  python3 tools/sign_pe.py "$VERSION" "$RUST_EXE" | tail -1 > /tmp/fusion-signing-report-$VERSION.json \+    || die "signing failed or did not verify: nothing was published (tools/sign_pe.py says why above)"+fi python3 - "$RUST_EXE" <<'SIGCHK' || die "the exe has no Authenticode signature after signing" import struct, sys d = open(sys.argv[1], "rb").read(); pe = struct.unpack_from("<I", d, 0x3c)[0]
tools/sign_pe.pyadded+155
@@ -0,0 +1,155 @@+#!/usr/bin/env python3+"""Authenticode-sign Windows PE files as "Adom Industries, Inc." and replace them IN PLACE with the+signed, verified copies. tools/release.sh calls it for the bridge exe; it never publishes anything.++    python3 tools/sign_pe.py <version> <file.exe> [<file.dll> ...]++The same path the altium-bridge release uses (scripts/release.py sign_pe_files): Azure Trusted+Signing runs on the Windows host the PRIVATE config names, reached through Adom Bridge. That config+($ADOM_SIGNING_DIR, default ~/.adom/signing/altium-bridge: config.json + sign-verify.ps1) is+deliberately not in this public repo; without it this script refuses and nothing is signed.++Per file it gates on what the host reports (Get-AuthenticodeSignature status Valid, the expected+subject, a Microsoft timestamp, signtool verify /pa), pulls the signed file back, checks the bytes+arrived intact and that the signer is "Adom Industries, Inc." when read here, and only then+replaces the local file. Exit 0 only when every file passed; a JSON report goes to stdout's last line.+"""+import base64+import hashlib+import json+import os+import shutil+import struct+import subprocess+import sys+import tempfile+from pathlib import Path++SIGNER_ORG = "Adom Industries, Inc."+THREAD = "fusion-bridge release signing"+++def die(msg):+    sys.exit("REFUSED: " + msg)+++def signing_dir():+    return Path(os.environ.get("ADOM_SIGNING_DIR", Path.home() / ".adom" / "signing" / "altium-bridge"))+++def authenticode_subjects(path):+    """Certificate subjects in a PE's Authenticode signature, read without Windows: the optional+    header's security directory points at a WIN_CERTIFICATE holding PKCS#7 DER. [] = unsigned."""+    b = Path(path).read_bytes()+    pe = struct.unpack_from("<I", b, 0x3C)[0]+    if b[pe:pe + 4] != b"PE\0\0":+        die(f"{path} is not a PE file")+    opt = pe + 24+    magic = struct.unpack_from("<H", b, opt)[0]+    off, size = struct.unpack_from("<II", b, opt + (112 if magic == 0x20B else 96) + 4 * 8)+    if not off or not size:+        return []+    length, _rev, ctype = struct.unpack_from("<IHH", b, off)+    if ctype != 2:+        return []+    with tempfile.NamedTemporaryFile(suffix=".p7b", delete=False) as f:+        f.write(b[off + 8:off + length])+    r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-in", f.name, "-print_certs", "-noout"], capture_output=True, text=True)+    os.unlink(f.name)+    return [l[len("subject="):].strip() for l in r.stdout.splitlines() if l.startswith("subject=")]+++def ab(host, verb, args, timeout=600):+    out = subprocess.run(["adom-bridge", "--target", host, "--ai-thread", THREAD, verb, json.dumps(args)],+                         capture_output=True, text=True, timeout=timeout).stdout+    d = json.loads(out[out.find("{"):]) if "{" in out else {}+    o = d.get("output")+    if isinstance(o, str) and o.strip().startswith("{"):+        try:+            d = json.loads(o)+        except ValueError:+            pass+    return d+++def main():+    if len(sys.argv) < 3:+        sys.exit(__doc__)+    ver, files = sys.argv[1], [Path(p) for p in sys.argv[2:]]+    for f in files:+        if not f.is_file():+            die(f"{f} does not exist")+    sd = signing_dir()+    cfg_p, ps1 = sd / "config.json", sd / "sign-verify.ps1"+    if not cfg_p.exists() or not ps1.exists():+        die(f"private signing config missing at {sd} (config.json + sign-verify.ps1). It is deliberately not in "+            "this public repo; ask the release owner to copy the folder over privately.")+    cfg = json.loads(cfg_p.read_text())+    host = cfg["host"]+    stage_root = cfg["stageDir"]+    if "%LOCALAPPDATA%" in stage_root:+        if not cfg.get("localAppData"):+            die("config.json's stageDir uses %LOCALAPPDATA% but gives no localAppData to expand it with")+        stage_root = stage_root.replace("%LOCALAPPDATA%", cfg["localAppData"])+    stage = stage_root + "\\fusion-build-" + ver.replace(".", "_")   # a dotted folder name reads as a file to send_files+    names = [f.name for f in files]+    if len(set(names)) != len(names):+        die("two files share a name; the staging folder is flat")+    unsigned_sha = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in files}++    work = Path(tempfile.mkdtemp(prefix="fusion-sign-"))+    for f in files:+        shutil.copy2(f, work / f.name)+    shutil.copy2(ps1, work / "sign-verify.ps1")+    print(f"== signing {', '.join(names)} ({ver}) on the signing host ==", file=sys.stderr)+    sent = ab(host, "send_files", {"filePaths": [str(work / n) for n in names] + [str(work / "sign-verify.ps1")], "dest": stage,+                                   "reason": f"stage fusion-bridge {ver} for Authenticode signing"})+    if int(sent.get("filesReceived") or 0) != len(names) + 1:+        die(f"the signing host did not receive the files: {sent.get('error') or sent}")+    calls = "; ".join(+        f"(& '{stage}\\sign-verify.ps1' -Exe '{stage}\\{n}' -Endpoint '{cfg['endpoint']}' -Account '{cfg['account']}' "+        f"-Profile '{cfg['profile']}' -Tenant '{cfg['tenant']}' -Timestamp '{cfg['timestampUrl']}' | Out-String)" for n in names)+    cmd = f"$r = @({calls}); '[' + ($r -join ',') + ']'"+    r = ab(host, "run_script", {"interpreter": "powershell", "scriptB64": base64.b64encode(cmd.encode()).decode(), "timeout": 540,+                                "reason": f"Authenticode-sign fusion-bridge {ver} ({', '.join(names)}) as {SIGNER_ORG}, then Get-AuthenticodeSignature + signtool verify"},+           timeout=900)+    so = r.get("stdout") or ""+    try:+        verdicts = json.loads(so[so.find("["):])+    except ValueError:+        die(f"the signing script gave no verdict: {so[:400]} {(r.get('stderr') or r.get('error') or '')[:400]}")+    if len(verdicts) != len(names):+        die(f"expected {len(names)} verdicts, got {len(verdicts)}")+    report = []+    for f, v in zip(files, verdicts):+        if v.get("error"):+            die(f"the signing host cannot sign ({v['error']}): {v.get('detail', '')}\n  fix: {v.get('fix', '')}")+        checks = {"Status Valid": v.get("status") == "Valid",+                  "signer subject": cfg["expectSubject"] in (v.get("subject") or ""),+                  "timestamped": "Microsoft" in (v.get("timestamper") or ""),+                  "signtool verify /pa": "Successfully verified" in (v.get("signtoolVerify") or "")}+        if not all(checks.values()):+            die(f"signature checks failed for {f.name}: {checks}\n{json.dumps(v, indent=1)}")+        pulled = ab(host, "pull_file", {"filePaths": [f"{stage}\\{f.name}"], "reason": f"pull the signed {f.name} ({ver}) back for packaging"})+        got = pulled.get("files") or []+        if not got:+            die(f"could not pull the signed {f.name} back: {pulled.get('error') or pulled}")+        signed = Path(got[0]["path"])+        sha = hashlib.sha256(signed.read_bytes()).hexdigest()+        if v.get("sha256") and sha != v["sha256"]:+            die(f"{f.name} changed in transit (host {v['sha256']}, here {sha})")+        if sha == unsigned_sha[f.name]:+            die(f"{f.name} came back unchanged: it was not signed")+        subs = authenticode_subjects(signed)+        if not any(f'CN = "{SIGNER_ORG}"' in s or f"CN={SIGNER_ORG}" in s for s in subs):+            die(f"{f.name} is not signed by {SIGNER_ORG} when read here (found {subs or 'no signature'})")+        shutil.copy2(signed, f)        # the signed bytes replace the build, so the zip takes exactly them+        report.append({"file": f.name, "status": v["status"], "subject": v["subject"], "timestamper": v["timestamper"],+                       "signtoolVerify": v["signtoolVerify"].split(":")[0], "unsignedSha256": unsigned_sha[f.name], "sha256": sha})+        print(f"   {f.name}: " + ", ".join(f"{k} ok" for k in checks) + f"; verified again here, sha256 {sha[:12]}", file=sys.stderr)+    shutil.rmtree(work, ignore_errors=True)+    print(json.dumps({"signed": report}))+++if __name__ == "__main__":+    main()