app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Release signing through Azure Trusted Signing on the signing host (tools/sign_pe.py, the altium-bridge path); release.sh uses it
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()