microsoft/TypeAgent

Public

mirrored from https://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
b2434511a340ff2dbe2e522e67704565451961ea

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

ts/tools/scripts/getCert.mjs

527lines · modecode

1#!/usr/bin/env node
2// Copyright (c) Microsoft Corporation.
3// Licensed under the MIT License.
4
5// Fetches the TypeAgent dev code-signing cert from Azure Key Vault and
6// installs it into the local Windows certificate store. Companion to
7// getKeys.mjs (string secrets); this script handles the X.509 cert + its
8// auto-generated PFX password.
9//
10// Subcommands:
11// pull — fetch cert from Key Vault, generate/store PFX password,
12// re-encrypt PFX with that password, save to local PFX path.
13// install — pull + Import-PfxCertificate into CurrentUser\My and
14// Import-Certificate into CurrentUser\TrustedPeople. With
15// --trusted-root, also installs the public cert into
16// CurrentUser\Root for self-signed cert trust (one-time UAC
17// prompt).
18// renew — Create a NEW VERSION of the cert in Key Vault with the
19// right policy for code signing (EKU = 1.3.6.1.5.5.7.3.3).
20// Use this once if the existing cert was created without
21// the Code Signing EKU (signtool will reject it otherwise).
22// Old versions stay in vault history.
23// status — show whether cert / password exist in vault, whether PFX is
24// on disk, and whether the cert is in the relevant local stores.
25//
26// Cert/vault names live in getKeys.config.json under the "cert" key. Default
27// cert name: TypeAgent-Development-Certificate; default vault: aisystems.
28
29import fs from "node:fs";
30import os from "node:os";
31import path from "node:path";
32import crypto from "node:crypto";
33import { spawnSync } from "node:child_process";
34import { fileURLToPath } from "node:url";
35import { createRequire } from "node:module";
36import chalk from "chalk";
37import { DefaultAzureCredential } from "@azure/identity";
38import { SecretClient } from "@azure/keyvault-secrets";
39import { CertificateClient } from "@azure/keyvault-certificates";
40import { getAzCliLoggedInInfo } from "./lib/azureUtils.mjs";
41
42const require = createRequire(import.meta.url);
43const config = require("./getKeys.config.json");
44
45if (!config.cert) {
46 console.error(
47 chalk.red("FATAL: getKeys.config.json is missing the 'cert' section."),
48 );
49 process.exit(1);
50}
51
52const __dirname = path.dirname(fileURLToPath(import.meta.url));
53
54// Resolve ~/<rest> to the user's home dir.
55function expandHome(p) {
56 return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
57}
58
59const certVaultName = config.cert.vault;
60const certName = config.cert.name;
61const passwordSecretName = config.cert.passwordSecretName;
62const localPfxPath = path.resolve(expandHome(config.cert.localPfxPath));
63
64let paramTrustedRoot = false;
65let paramVerbose = false;
66
67function vlog(msg) {
68 if (paramVerbose) console.log(chalk.gray(` ${msg}`));
69}
70
71// ---------------------------------------------------------------------------
72// Azure clients
73// ---------------------------------------------------------------------------
74
75async function getCredential() {
76 try {
77 await getAzCliLoggedInInfo();
78 } catch {
79 console.error(
80 chalk.red(
81 "ERROR: Not logged in to Azure CLI. Run 'az login' first.",
82 ),
83 );
84 process.exit(1);
85 }
86 return new DefaultAzureCredential();
87}
88
89function vaultUrl(vault) {
90 return `https://${vault}.vault.azure.net`;
91}
92
93// ---------------------------------------------------------------------------
94// PowerShell helpers — cert manipulation is much cleaner via Windows APIs
95// than wrestling with node-forge for PKCS12 re-encryption. We're Windows-only
96// for this whole flow anyway.
97// ---------------------------------------------------------------------------
98
99function runPowerShell(
100 script,
101 { ignoreExitCode = false, interactive = false } = {},
102) {
103 const args = [
104 "-NoProfile",
105 // Interactive mode preserves the parent's stdin/stdout/stderr so any
106 // OS-level prompt (e.g. trust-this-CA dialog when importing into
107 // Cert:\...\Root) can be answered. Non-interactive blocks them with
108 // "UI is not allowed in this operation." Most ops are fine
109 // non-interactive; trust-store imports are not.
110 ...(interactive ? [] : ["-NonInteractive"]),
111 "-ExecutionPolicy",
112 "Bypass",
113 "-Command",
114 script,
115 ];
116 const result = spawnSync("powershell.exe", args, {
117 encoding: "utf8",
118 stdio: interactive ? "inherit" : "pipe",
119 });
120 if (result.error) throw result.error;
121 if (!ignoreExitCode && result.status !== 0) {
122 throw new Error(
123 `PowerShell command failed (code ${result.status}): ${result.stderr || result.stdout}`,
124 );
125 }
126 return {
127 stdout: (result.stdout ?? "").trim(),
128 stderr: (result.stderr ?? "").trim(),
129 code: result.status,
130 };
131}
132
133// Re-encrypts a PFX with a new password. Reads sourcePath, writes destPath.
134// sourcePassword may be empty (Key Vault returns unprotected PFX).
135function reencryptPfx(sourcePath, sourcePassword, destPath, destPassword) {
136 // Use Get-PfxData + Export-PfxCertificate. Export-PfxCertificate writes
137 // a PFX with the new password; Get-PfxData reads with the old one.
138 const ps = `
139 $srcPwd = ConvertTo-SecureString -String '${escapePsString(sourcePassword)}' -AsPlainText -Force
140 $dstPwd = ConvertTo-SecureString -String '${escapePsString(destPassword)}' -AsPlainText -Force
141 $data = Get-PfxData -FilePath '${escapePsString(sourcePath)}' -Password $srcPwd
142 $cert = $data.EndEntityCertificates[0]
143 $thumb = $cert.Thumbprint
144 # Round-trip via the cert store: import temporarily so we have a key handle,
145 # then export with the new password, then remove.
146 $imported = Import-PfxCertificate -FilePath '${escapePsString(sourcePath)}' -Password $srcPwd -CertStoreLocation Cert:\\CurrentUser\\My -Exportable
147 try {
148 Export-PfxCertificate -Cert $imported -FilePath '${escapePsString(destPath)}' -Password $dstPwd -Force | Out-Null
149 } finally {
150 Remove-Item -Path "Cert:\\CurrentUser\\My\\$thumb" -Force -ErrorAction SilentlyContinue
151 }
152 Write-Output $thumb
153 `;
154 const { stdout } = runPowerShell(ps);
155 return stdout.trim();
156}
157
158function escapePsString(s) {
159 // Single-quoted PowerShell strings: escape single quotes by doubling.
160 return String(s).replace(/'/g, "''");
161}
162
163// ---------------------------------------------------------------------------
164// Pull: fetch cert from KV, generate password if needed, re-encrypt + save.
165// ---------------------------------------------------------------------------
166
167async function pull() {
168 const credential = await getCredential();
169 const secretClient = new SecretClient(vaultUrl(certVaultName), credential);
170 const certClient = new CertificateClient(
171 vaultUrl(certVaultName),
172 credential,
173 );
174
175 // 1. Fetch the cert from Key Vault. Stored certs are also exposed as
176 // secrets — the secret form is the PFX (base64) without password protection.
177 console.log(
178 `Fetching cert ${chalk.cyanBright(certName)} from vault ${chalk.cyanBright(certVaultName)}…`,
179 );
180 let pfxBase64;
181 try {
182 const secret = await secretClient.getSecret(certName);
183 pfxBase64 = secret.value;
184 } catch (e) {
185 console.error(
186 chalk.red(
187 `Failed to fetch cert '${certName}' from vault '${certVaultName}': ${e.message}`,
188 ),
189 );
190 process.exit(1);
191 }
192 if (!pfxBase64) {
193 console.error(
194 chalk.red(
195 `Cert '${certName}' returned an empty value from the vault.`,
196 ),
197 );
198 process.exit(1);
199 }
200 vlog(`fetched ${pfxBase64.length} chars of base64 PFX`);
201
202 // 2. Get-or-create the PFX password secret.
203 let password;
204 try {
205 const passwordSecret = await secretClient.getSecret(passwordSecretName);
206 password = passwordSecret.value;
207 console.log("Reusing existing PFX password secret.");
208 } catch (e) {
209 if (e?.statusCode !== 404) throw e;
210 password = generatePassword();
211 console.log("Creating new PFX password secret in vault.");
212 await secretClient.setSecret(passwordSecretName, password);
213 }
214
215 // 3. Save unprotected PFX to a temp file, then re-encrypt to the local path.
216 await fs.promises.mkdir(path.dirname(localPfxPath), { recursive: true });
217 const tmpPath = path.join(os.tmpdir(), `typeagent-cert-${process.pid}.pfx`);
218 try {
219 await fs.promises.writeFile(tmpPath, Buffer.from(pfxBase64, "base64"));
220 const thumbprint = reencryptPfx(tmpPath, "", localPfxPath, password);
221 console.log(
222 `Wrote password-protected PFX to ${chalk.cyanBright(localPfxPath)}`,
223 );
224 console.log(` Thumbprint: ${chalk.cyanBright(thumbprint)}`);
225 return { thumbprint };
226 } finally {
227 await fs.promises.unlink(tmpPath).catch(() => {});
228 }
229
230 // Note: cert metadata (thumbprint via certClient) is also available, but
231 // we get it as a side-effect of the re-encryption above so don't re-fetch.
232 void certClient;
233}
234
235function generatePassword() {
236 // 32 random URL-safe characters. Plenty for a PFX wrapper key.
237 return crypto.randomBytes(24).toString("base64url");
238}
239
240// ---------------------------------------------------------------------------
241// Renew: create a new version of the cert with the right policy for code
242// signing. Use once when the original cert was created with the wrong EKU
243// (signtool rejects certs without Code Signing 1.3.6.1.5.5.7.3.3).
244// ---------------------------------------------------------------------------
245
246async function renew() {
247 const credential = await getCredential();
248 const certClient = new CertificateClient(
249 vaultUrl(certVaultName),
250 credential,
251 );
252
253 // Self-signed cert with Code Signing EKU. Subject must remain stable
254 // across versions so the existing identity-package manifest (which
255 // pins Publisher="CN=...") keeps working.
256 const subject = "CN=dev.typeagent.microsoft.com";
257 console.log(
258 `Creating new version of ${chalk.cyanBright(certName)} in vault ${chalk.cyanBright(certVaultName)}…`,
259 );
260 console.log(` Subject: ${subject}`);
261 console.log(` EKU: 1.3.6.1.5.5.7.3.3 (Code Signing)`);
262
263 const policy = {
264 issuerName: "Self",
265 subject,
266 keyType: "RSA",
267 keySize: 2048,
268 contentType: "application/x-pkcs12",
269 validityInMonths: 24,
270 // The two flags that fix the EKU: enhancedKeyUsage + (implicitly)
271 // strip the default cert/web auth EKU by setting only what we want.
272 enhancedKeyUsage: ["1.3.6.1.5.5.7.3.3"],
273 keyUsage: ["digitalSignature"],
274 exportable: true,
275 reuseKey: false,
276 };
277
278 const poller = await certClient.beginCreateCertificate(certName, policy);
279 console.log(" (waiting for certificate creation to complete…)");
280 const newCert = await poller.pollUntilDone();
281 console.log(
282 `${chalk.green("Created.")} Thumbprint: ${chalk.cyanBright(
283 Buffer.from(newCert.properties.x509Thumbprint)
284 .toString("hex")
285 .toUpperCase(),
286 )}`,
287 );
288 console.log(
289 chalk.gray(
290 "Re-run `getCert install` to refresh the local PFX + cert stores with the new version.",
291 ),
292 );
293}
294
295// ---------------------------------------------------------------------------
296// Install: pull + import into local cert store.
297// ---------------------------------------------------------------------------
298
299async function install() {
300 const { thumbprint } = await pull();
301
302 const credential = await getCredential();
303 const secretClient = new SecretClient(vaultUrl(certVaultName), credential);
304 const password = (await secretClient.getSecret(passwordSecretName)).value;
305
306 // Cert:\CurrentUser\My — needs the private key (we sign with this), so
307 // import the PFX with its password. -Exportable keeps the key extractable
308 // for signtool flows that prefer a fresh PFX export.
309 console.log("\nImporting into CurrentUser\\My (private key)…");
310 runPowerShell(`
311 $pwd = ConvertTo-SecureString -String '${escapePsString(password)}' -AsPlainText -Force
312 Import-PfxCertificate -FilePath '${escapePsString(localPfxPath)}' -Password $pwd -CertStoreLocation Cert:\\CurrentUser\\My -Exportable | Out-Null
313 `);
314
315 // For trust stores (TrustedPeople and Root), we only need the public
316 // cert. Microsoft's recommended pattern: export a .cer alongside the PFX
317 // and Import-Certificate that. This is more secure (no private key in
318 // trust stores) and avoids extra prompts on trusted-store imports.
319 const cerPath = path.join(
320 path.dirname(localPfxPath),
321 path.basename(localPfxPath, ".pfx") + ".cer",
322 );
323 console.log(`Exporting public cert to ${chalk.cyanBright(cerPath)}…`);
324 runPowerShell(`
325 $cert = Get-Item Cert:\\CurrentUser\\My\\${escapePsString(thumbprint)}
326 Export-Certificate -Cert $cert -FilePath '${escapePsString(cerPath)}' -Type CERT -Force | Out-Null
327 `);
328
329 console.log("Importing into CurrentUser\\TrustedPeople (public)…");
330 runPowerShell(`
331 Import-Certificate -FilePath '${escapePsString(cerPath)}' -CertStoreLocation Cert:\\CurrentUser\\TrustedPeople | Out-Null
332 `);
333
334 if (paramTrustedRoot) {
335 // Importing a self-signed cert into Root triggers a Windows
336 // confirmation prompt ("Are you sure you want to install this
337 // certificate?"). Run interactively so the prompt can be answered;
338 // -NonInteractive throws "UI is not allowed in this operation."
339 console.log(
340 chalk.yellow(
341 "\nImporting into CurrentUser\\Root (Windows will prompt for confirmation; click Yes)…",
342 ),
343 );
344 runPowerShell(
345 `
346 Import-Certificate -FilePath '${escapePsString(cerPath)}' -CertStoreLocation Cert:\\CurrentUser\\Root | Out-Null
347 `,
348 { interactive: true },
349 );
350 }
351
352 console.log(
353 `\n${chalk.green("Done.")} Cert thumbprint: ${chalk.cyanBright(thumbprint)}`,
354 );
355 console.log(
356 `Sign with: ${chalk.gray(`signtool sign /sha1 ${thumbprint} /fd SHA256 <file>`)}`,
357 );
358 if (!paramTrustedRoot) {
359 console.log(
360 chalk.gray(
361 "Note: pass --trusted-root once if you also need TrustedRoot install (required for self-signed MSIX trust).",
362 ),
363 );
364 }
365}
366
367// ---------------------------------------------------------------------------
368// Status: report what's in the vault / on disk / in the local cert stores.
369// ---------------------------------------------------------------------------
370
371async function status() {
372 const credential = await getCredential();
373 const secretClient = new SecretClient(vaultUrl(certVaultName), credential);
374
375 console.log(`Vault: ${chalk.cyanBright(certVaultName)}`);
376
377 let inVault = false;
378 try {
379 await secretClient.getSecret(certName);
380 inVault = true;
381 } catch (e) {
382 if (e?.statusCode !== 404) throw e;
383 }
384 console.log(
385 ` cert '${certName}': ${inVault ? chalk.green("present") : chalk.red("missing")}`,
386 );
387
388 let pwInVault = false;
389 let pfxPassword;
390 try {
391 const secret = await secretClient.getSecret(passwordSecretName);
392 pwInVault = true;
393 pfxPassword = secret.value;
394 } catch (e) {
395 if (e?.statusCode !== 404) throw e;
396 }
397 console.log(
398 ` pwd [password secret]: ${pwInVault ? chalk.green("present") : chalk.yellow("missing (will be generated on next pull)")}`,
399 );
400
401 console.log(`\nLocal: ${chalk.cyanBright(localPfxPath)}`);
402 const onDisk = fs.existsSync(localPfxPath);
403 console.log(
404 ` PFX file: ${onDisk ? chalk.green("present") : chalk.yellow("missing (run 'pull')")}`,
405 );
406
407 // Match by thumbprint, not Subject — the vault key name and the cert's
408 // CN aren't the same thing. Read the thumbprint from the local PFX (or
409 // skip this section if neither the PFX nor the password are available).
410 let thumbprint;
411 if (onDisk && pfxPassword) {
412 try {
413 const ps = `
414 $pwd = ConvertTo-SecureString -String '${escapePsString(pfxPassword)}' -AsPlainText -Force
415 $data = Get-PfxData -FilePath '${escapePsString(localPfxPath)}' -Password $pwd
416 Write-Output $data.EndEntityCertificates[0].Thumbprint
417 `;
418 thumbprint = runPowerShell(ps).stdout.trim();
419 } catch (e) {
420 vlog(`reading PFX thumbprint failed: ${e.message}`);
421 }
422 }
423
424 console.log(`\nCert stores (matching thumbprint):`);
425 if (!thumbprint) {
426 console.log(
427 chalk.gray(
428 " (skipped — need both local PFX and vault password to determine thumbprint)",
429 ),
430 );
431 return;
432 }
433 console.log(` thumbprint: ${chalk.cyanBright(thumbprint)}`);
434 const stores = [
435 ["CurrentUser\\My", "Cert:\\CurrentUser\\My"],
436 ["CurrentUser\\TrustedPeople", "Cert:\\CurrentUser\\TrustedPeople"],
437 ["CurrentUser\\Root", "Cert:\\CurrentUser\\Root"],
438 ];
439 for (const [label, psPath] of stores) {
440 const ps = `
441 if (Test-Path '${psPath}\\${thumbprint}') { Write-Output 'yes' } else { Write-Output 'no' }
442 `;
443 const { stdout } = runPowerShell(ps, { ignoreExitCode: true });
444 const present = stdout.trim() === "yes";
445 console.log(
446 ` ${label}: ${present ? chalk.green("installed") : chalk.gray("not installed")}`,
447 );
448 }
449}
450
451// ---------------------------------------------------------------------------
452// Help + arg parsing
453// ---------------------------------------------------------------------------
454
455function printHelp() {
456 console.log(`
457${chalk.bold("getCert.mjs")} — manage the TypeAgent dev code-signing cert.
458
459Usage:
460 node getCert.mjs <command> [options]
461
462Commands:
463 pull Fetch cert from Key Vault, password-protect locally.
464 install Pull + import into CurrentUser cert stores.
465 renew Create a new cert version with Code Signing EKU
466 (use once if the cert was created with the wrong EKU).
467 status Show vault / local / cert-store state.
468 help Show this help.
469
470Options:
471 --trusted-root (install only) Also install into CurrentUser\\Root —
472 required for self-signed MSIX trust. Triggers a one-
473 time UAC/confirm prompt.
474 -v, --verbose Verbose logging.
475
476Config (ts/tools/scripts/getKeys.config.json under "cert"):
477 vault Key Vault name (default: aisystems)
478 name Cert name in vault (default: TypeAgent-Development-Certificate)
479 passwordSecretName Secret name for the auto-generated PFX password
480 localPfxPath Where to save the password-protected PFX (~ supported)
481`);
482}
483
484const commands = ["pull", "install", "renew", "status", "help"];
485
486(async () => {
487 const command = process.argv[2];
488 if (command === undefined || command === "help" || command === "--help") {
489 printHelp();
490 return;
491 }
492 if (!commands.includes(command)) {
493 console.error(chalk.red(`Unknown command: ${command}`));
494 printHelp();
495 process.exit(1);
496 }
497
498 for (let i = 3; i < process.argv.length; i++) {
499 const arg = process.argv[i];
500 if (arg === "--trusted-root") {
501 paramTrustedRoot = true;
502 } else if (arg === "--verbose" || arg === "-v") {
503 paramVerbose = true;
504 } else {
505 console.error(chalk.red(`Unknown argument: ${arg}`));
506 process.exit(1);
507 }
508 }
509
510 switch (command) {
511 case "pull":
512 await pull();
513 break;
514 case "install":
515 await install();
516 break;
517 case "renew":
518 await renew();
519 break;
520 case "status":
521 await status();
522 break;
523 }
524})().catch((e) => {
525 console.error(chalk.red(`FATAL ERROR: ${e.stack ?? e.message ?? e}`));
526 process.exit(1);
527});
528