Open History to see the last commit
1// Azure Trusted Signing from a non-Windows host, for electron-builder's
2// `win.signtoolOptions.sign` hook and the CLI archive. jsign reads AZURE_TENANT_ID,
3// AZURE_CLIENT_ID and AZURE_CLIENT_SECRET itself when no --storepass is given.
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as Config from "effect/Config";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schedule from "effect/Schedule";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14
15// Upstream failures worth another attempt: Azure's `InternalError` (seen in
16// release runs on both the certificate chain and the sign call), HTTP 429/5xx,
17// and dropped connections. Credential, permission and input errors never match.
18const TRANSIENT_JSIGN_ERROR =
19 /\bInternalError - |HTTP Error (?:429|5\d\d)\b|java\.net\.(?:SocketTimeoutException|ConnectException)\b|Connection reset\b/;
20const JSIGN_RETRIES = 3;
21const STDERR_TAIL_CHARS = 64 * 1024;
22
23export class JsignFailedError extends Schema.TaggedError<JsignFailedError>()("JsignFailedError", {
24 file: Schema.String,
25 exitCode: Schema.Number,
26 transient: Schema.Boolean,
27}) {
28 override get message() {
29 return `jsign exited with code ${String(this.exitCode)} signing ${this.file}${this.transient ? " (transient Azure Trusted Signing error)" : ""}.`;
30 }
31}
32
33/** Runs jsign, forwarding its stderr as it arrives and returning a tail of it for classification. */
34const runJsign = Effect.fn("runJsign")(function* (jsign: string, args: ReadonlyArray<string>) {
35 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const child = yield* spawner.spawn(
37 ChildProcess.make(jsign, args, { stdout: "inherit", stderr: "pipe" }),
38 );
39 const decoder = new TextDecoder();
40 let stderr = "";
41 const [exitCode] = yield* Effect.all(
42 [
43 child.exitCode.pipe(Effect.map(Number)),
44 Stream.runForEach(child.stderr, (chunk) =>
45 Effect.sync(() => {
46 process.stderr.write(chunk);
47 stderr = (stderr + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
48 }),
49 ),
50 ],
51 { concurrency: "unbounded" },
52 );
53 return { exitCode, stderr };
54});
55
56export const signWithJsign = Effect.fn("signWithJsign")(function* (file: string) {
57 const { jsign, endpoint, account, profile } = yield* Config.all({
58 jsign: Config.String("T3CODE_JSIGN").pipe(Config.withDefault("jsign")),
59 endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT"),
60 account: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"),
61 profile: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"),
62 });
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const args = [
66 "--storetype",
67 "TRUSTEDSIGNING",
68 "--keystore",
69 new URL(endpoint).host,
70 "--alias",
71 `${account}/${profile}`,
72 "--alg",
73 "SHA-256",
74 "--tsaurl",
75 "http://timestamp.acs.microsoft.com",
76 "--tsmode",
77 "RFC3161",
78 // jsign appends a nested signature by default; signtool replaces.
79 "--replace",
80 ];
81
82 let attempt = 0;
83 // jsign rewrites its input in place, so each attempt signs a fresh copy and
84 // only a copy jsign finished with replaces the file (same directory, so the
85 // rename is atomic). A failed attempt never touches the original.
86 const signCopy = Effect.gen(function* () {
87 attempt++;
88 const dir = yield* fs.makeTempDirectoryScoped({
89 directory: path.dirname(file),
90 prefix: ".jsign-",
91 });
92 const copy = path.join(dir, path.basename(file));
93 yield* fs.copyFile(file, copy);
94 const { exitCode, stderr } = yield* runJsign(jsign, [...args, copy]);
95 if (exitCode !== 0) {
96 const transient = TRANSIENT_JSIGN_ERROR.test(stderr);
97 if (transient && attempt <= JSIGN_RETRIES) {
98 yield* Effect.logWarning(
99 `[jsign] Transient Azure Trusted Signing error signing ${file} (attempt ${String(attempt)} of ${String(JSIGN_RETRIES + 1)}); retrying.`,
100 );
101 }
102 return yield* new JsignFailedError({ file, exitCode, transient });
103 }
104 yield* fs.rename(copy, file);
105 }).pipe(Effect.scoped);
106
107 yield* signCopy.pipe(
108 Effect.retry({
109 schedule: Schedule.exponential("5 seconds"),
110 times: JSIGN_RETRIES,
111 while: (error) => error._tag === "JsignFailedError" && error.transient,
112 }),
113 );
114});
115
116export default function sign(configuration: { readonly path: string }): Promise<void> {
117 return signWithJsign(configuration.path).pipe(
118 Effect.scoped,
119 Effect.provide(NodeServices.layer),
120 Effect.runPromise,
121 );
122}