engine-kit 0.0.0-stage → 0.2.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kit/sign.mjs ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // engine-kit/sign.mjs — assina um artefato de motor e PROVA a assinatura na hora.
3
+ //
4
+ // node kit/sign.mjs <caminho-do-asset> --engine <motor>
5
+ // node kit/sign.mjs <caminho-do-asset> --key <caminho-da-chave-privada>
6
+ //
7
+ // Gera, ao lado do asset:
8
+ // <asset>.sig — assinatura Ed25519 crua (64 bytes) do SHA-256 do asset
9
+ // <asset>.sha256 — sidecar de integridade (o kit já exigia; gerar aqui evita esquecer)
10
+ //
11
+ // E, antes de sair, prova o que acabou de fazer: a assinatura valida contra a pública derivada
12
+ // E recusa uma cópia adulterada. Um `.sig` que só é testado no consumidor é um `.sig` que você
13
+ // descobre que está quebrado tarde demais.
14
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
15
+ import { createHash } from "node:crypto";
16
+ import { basename } from "node:path";
17
+ import { signBlob, verifyBlob, publicKeyHexFromPrivateHex } from "./signature.mjs";
18
+ import { keyPathFor } from "./keystore.mjs";
19
+
20
+ const die = (msg) => { console.error(`FALHOU: ${msg}`); process.exit(1); };
21
+
22
+ function parseArgs(argv) {
23
+ const out = { asset: null, engine: null, key: null };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ if (argv[i] === "--engine") { out.engine = argv[++i]; }
26
+ else if (argv[i] === "--key") { out.key = argv[++i]; }
27
+ else if (!out.asset) { out.asset = argv[i]; }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const { asset, engine, key } = parseArgs(process.argv.slice(2));
33
+ if (!asset) { die("uso: node kit/sign.mjs <asset> (--engine <motor> | --key <caminho>)"); }
34
+ if (!existsSync(asset)) { die(`asset não encontrado: ${asset}`); }
35
+
36
+ const keyPath = key ?? (engine ? keyPathFor(engine) : null);
37
+ if (!keyPath) { die("informe --engine <motor> ou --key <caminho da chave privada>"); }
38
+ if (!existsSync(keyPath)) {
39
+ die(`chave privada ausente: ${keyPath}\n gere com: node kit/gen-key.mjs ${engine ?? "<motor>"}`);
40
+ }
41
+
42
+ const privateKeyHex = readFileSync(keyPath, "utf8").trim();
43
+ let publicKeyHex;
44
+ try {
45
+ publicKeyHex = publicKeyHexFromPrivateHex(privateKeyHex);
46
+ } catch (e) {
47
+ die(`chave privada ilegível em ${keyPath}: ${e.message}`);
48
+ }
49
+
50
+ const blob = readFileSync(asset);
51
+ const sig = signBlob(blob, privateKeyHex);
52
+
53
+ // PROVA (as duas metades: aceita o legítimo, recusa o adulterado).
54
+ if (!verifyBlob(blob, sig, publicKeyHex).ok) { die("a assinatura recém-gerada NÃO validou — não publique"); }
55
+ if (verifyBlob(Buffer.concat([blob, Buffer.from("x")]), sig, publicKeyHex).ok) {
56
+ die("a verificação ACEITOU um artefato adulterado — não publique");
57
+ }
58
+
59
+ const sha256 = createHash("sha256").update(blob).digest("hex");
60
+ writeFileSync(`${asset}.sig`, sig);
61
+ writeFileSync(`${asset}.sha256`, `${sha256} ${basename(asset)}\n`, "utf8");
62
+
63
+ console.log(`asset : ${asset} (${blob.length} bytes)`);
64
+ console.log(`sha256 : ${sha256}`);
65
+ console.log(`assinatura : ${asset}.sig (${sig.length} bytes crus)`);
66
+ console.log(`chave pública : ${publicKeyHex}`);
67
+ console.log("prova : assinatura VALIDA e artefato adulterado é RECUSADO");
@@ -0,0 +1,106 @@
1
+ // engine-kit/signature.mjs — AUTENTICIDADE: assinatura Ed25519 dos artefatos de motor.
2
+ //
3
+ // Uma implementação, N motores. O SHA256 do kit prova INTEGRIDADE (não corrompeu no caminho);
4
+ // a assinatura prova AUTENTICIDADE (veio de quem tem a chave privada). Sem ela, quem controlar
5
+ // o release serve um artefato trocado e o sha256 sidecar bate — porque o atacante gera os dois.
6
+ //
7
+ // CONTRATO — `ed25519-sha256-raw` (o mesmo que o vox-engine já pratica em produção, para que a
8
+ // migração dele não exija reassinar nada):
9
+ // • hash-then-sign: assina-se o SHA-256 (32 bytes) do artefato, NÃO o artefato direto;
10
+ // • o `.sig` é a assinatura Ed25519 CRUA — 64 bytes binários, nunca base64;
11
+ // • a chave pública é 32 bytes em hex (64 chars), PINADA no manifest do registry;
12
+ // • a chave privada é 32 bytes em hex, LOCAL (~/.engine-signing/), e nunca entra em CI.
13
+ //
14
+ // Armadilhas do Node que este módulo encapsula (motivo de existir um módulo só para isto):
15
+ // • `crypto.createSign()/createVerify()` NÃO suportam Ed25519 — só a API estática
16
+ // `crypto.sign(null, ...)` / `crypto.verify(null, ...)`, com o 1º argumento OBRIGATORIAMENTE
17
+ // `null` (passar 'ed25519' ou 'sha256' quebra);
18
+ // • o Node não importa chave Ed25519 crua: exige DER. O envelope SPKI é um prefixo FIXO de
19
+ // 12 bytes + os 32 bytes crus. O PKCS8 privado é um prefixo fixo de 16 bytes + 32 crus.
20
+ //
21
+ // Contrato do kit: NUNCA lança na verificação — devolve { ok:false, reason } para o caller
22
+ // decidir (fail-loud em quem chama, não aqui). A assinatura (lado publisher) SIM lança: ali um
23
+ // erro tem de estourar na cara de quem publica.
24
+ import { createHash, createPublicKey, createPrivateKey, sign as edSign, verify as edVerify, generateKeyPairSync } from "node:crypto";
25
+ /** Algoritmos que o kit sabe verificar. Desconhecido = ABORT, nunca aceite silencioso. */
26
+ export const SUPPORTED_ALGORITHMS = ["ed25519-sha256-raw"];
27
+ export const DEFAULT_ALGORITHM = "ed25519-sha256-raw";
28
+
29
+ /** Envelope DER fixo de uma chave Ed25519 (RFC 8410): SPKI público e PKCS8 privado. */
30
+ const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); // 12 bytes + 32 crus
31
+ const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); // 16 bytes + 32 crus
32
+
33
+ const RAW_KEY_BYTES = 32;
34
+ export const SIGNATURE_BYTES = 64;
35
+
36
+ const isHexKey = (s) => /^[0-9a-fA-F]{64}$/.test(String(s ?? ""));
37
+
38
+ /** Converte a chave PÚBLICA hex (32 bytes) num KeyObject utilizável pelo `crypto.verify`. */
39
+ export function publicKeyFromHex(hex) {
40
+ if (!isHexKey(hex)) {
41
+ throw new Error(`chave pública Ed25519 inválida: esperado 64 chars hex (32 bytes), veio ${String(hex ?? "").length}`);
42
+ }
43
+ const raw = Buffer.from(hex, "hex");
44
+ return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: "der", type: "spki" });
45
+ }
46
+
47
+ /** Converte a chave PRIVADA hex (32 bytes) num KeyObject utilizável pelo `crypto.sign`. */
48
+ export function privateKeyFromHex(hex) {
49
+ if (!isHexKey(hex)) {
50
+ throw new Error(`chave privada Ed25519 inválida: esperado 64 chars hex (32 bytes), veio ${String(hex ?? "").length}`);
51
+ }
52
+ const raw = Buffer.from(hex, "hex");
53
+ return createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, raw]), format: "der", type: "pkcs8" });
54
+ }
55
+
56
+ /** Gera um par novo. Devolve as duas metades em hex cru (32 bytes cada). */
57
+ export function generateKeyPairHex() {
58
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
59
+ const pub = publicKey.export({ type: "spki", format: "der" }).subarray(SPKI_PREFIX.length);
60
+ const priv = privateKey.export({ type: "pkcs8", format: "der" }).subarray(PKCS8_PREFIX.length);
61
+ if (pub.length !== RAW_KEY_BYTES || priv.length !== RAW_KEY_BYTES) {
62
+ throw new Error(`envelope DER inesperado (pub=${pub.length}B, priv=${priv.length}B) — Node incompatível`);
63
+ }
64
+ return { publicKeyHex: pub.toString("hex"), privateKeyHex: priv.toString("hex") };
65
+ }
66
+
67
+ /** Deriva a chave PÚBLICA (hex cru) a partir da privada — para provar a assinatura aqui mesmo. */
68
+ export function publicKeyHexFromPrivateHex(privateKeyHex) {
69
+ const pub = createPublicKey(privateKeyFromHex(privateKeyHex));
70
+ return pub.export({ type: "spki", format: "der" }).subarray(SPKI_PREFIX.length).toString("hex");
71
+ }
72
+
73
+ /** LADO PUBLISHER: assina o SHA-256 do artefato. Devolve os 64 bytes crus do `.sig`. LANÇA em erro. */
74
+ export function signBlob(blob, privateKeyHex) {
75
+ const key = privateKeyFromHex(privateKeyHex);
76
+ const sig = edSign(null, createHash("sha256").update(blob).digest(), key);
77
+ if (sig.length !== SIGNATURE_BYTES) {
78
+ throw new Error(`assinatura com tamanho inesperado (${sig.length}B, esperado ${SIGNATURE_BYTES}B)`);
79
+ }
80
+ return sig;
81
+ }
82
+
83
+ /**
84
+ * LADO CONSUMIDOR: verifica a assinatura de um artefato. FAIL-CLOSED — qualquer dúvida é `false`.
85
+ * @returns {{ok:true} | {ok:false, reason:string}} nunca lança.
86
+ */
87
+ export function verifyBlob(blob, signature, publicKeyHex, { algorithm = DEFAULT_ALGORITHM } = {}) {
88
+ if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
89
+ return { ok: false, reason: `algoritmo de assinatura desconhecido "${algorithm}" (suportados: ${SUPPORTED_ALGORITHMS.join(", ")}) → ABORT` };
90
+ }
91
+ if (!isHexKey(publicKeyHex)) {
92
+ return { ok: false, reason: "chave pública ausente/malformada no registry (esperado 64 chars hex) → ABORT" };
93
+ }
94
+ const sig = Buffer.isBuffer(signature) ? signature : Buffer.from(signature ?? []);
95
+ if (sig.length !== SIGNATURE_BYTES) {
96
+ return { ok: false, reason: `sidecar .sig com ${sig.length} bytes (esperado ${SIGNATURE_BYTES} crus, sem base64) → ABORT` };
97
+ }
98
+ try {
99
+ const key = publicKeyFromHex(publicKeyHex);
100
+ const digest = createHash("sha256").update(blob).digest();
101
+ if (edVerify(null, digest, key, sig)) { return { ok: true }; }
102
+ return { ok: false, reason: "assinatura Ed25519 NÃO confere — artefato não veio de quem detém a chave privada → ABORT" };
103
+ } catch (e) {
104
+ return { ok: false, reason: `verificação de assinatura falhou: ${e?.message || e} → ABORT` };
105
+ }
106
+ }
package/kit/trust.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // engine-kit/trust.mjs — RAIZ DE CONFIANÇA local (fecha a circularidade do publicKey).
2
+ //
3
+ // O problema real: a `publicKey` que valida a assinatura vinha do MESMO `manifest.json` que o kit
4
+ // baixa. Quem controla o repo troca o `.tgz`, o `.sig` **e** a chave — e o fail-closed aprova
5
+ // tudo, porque as três coisas vêm da mesma origem. Integridade sem raiz de confiança externa é
6
+ // uma tranca cuja chave está pendurada na porta.
7
+ //
8
+ // O que isto resolve (e o que NÃO resolve, dito na cara):
9
+ // • RESOLVE — troca de chave DEPOIS da primeira instalação (repo comprometido, release
10
+ // substituída, rollback com chave nova): a chave fica gravada localmente na primeira vez
11
+ // (TOFU) e qualquer divergência posterior é ABORT, não aviso.
12
+ // • RESOLVE — pin explícito: o consumidor pode passar a chave que ele confia (`trustedKeys`),
13
+ // e aí nem a primeira instalação depende do manifest. É o padrão do vox-engine (chave
14
+ // embutida no consumidor) trazido para cá, sem obrigar todo consumidor a adotá-lo.
15
+ // • NÃO RESOLVE — comprometimento ANTERIOR ao primeiro contato: se o repo já estava tomado na
16
+ // primeira instalação, o TOFU grava a chave do atacante. Só transparência pública (Sigstore/
17
+ // TUF) fecha isso, e essa é decisão de produto — está registrada, não esquecida.
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+
22
+ // Lido a CADA chamada, não no import: assim um teste (ou um consumidor com HOME próprio) pode
23
+ // isolar a raiz de confiança sem depender da ordem de carregamento dos módulos. Foi a própria
24
+ // suíte que expôs isto — o motor-fake do teste de assinatura gera chave nova a cada execução e
25
+ // ficava preso na confiança da execução anterior.
26
+ const trustDir = () => process.env.ENGINE_KIT_HOME || join(homedir(), ".engine-kit");
27
+ const trustFile = () => join(trustDir(), "trust.json");
28
+
29
+ function readTrust() {
30
+ try {
31
+ const v = JSON.parse(readFileSync(trustFile(), "utf8"));
32
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Confronta a chave que veio do registry com a raiz de confiança local.
40
+ * @returns {{ok:true, tofu:boolean} | {ok:false, reason:string}} nunca lança.
41
+ */
42
+ export function checkTrust(engineName, publicKey, { trustedKeys } = {}) {
43
+ const pinned = trustedKeys?.[engineName];
44
+ if (pinned && pinned !== publicKey) {
45
+ return { ok: false, reason: `${engineName}: chave do registry ≠ chave FIXADA pelo consumidor → ABORT (registry pode estar comprometido)` };
46
+ }
47
+
48
+ const store = readTrust();
49
+ const known = store[engineName]?.publicKey;
50
+ if (known && known !== publicKey) {
51
+ return {
52
+ ok: false,
53
+ reason:
54
+ `${engineName}: a chave pública MUDOU desde a primeira instalação → ABORT. ` +
55
+ `Rotação legítima exige apagar a entrada em ${trustFile()} de propósito; ` +
56
+ `se você não rotacionou, o registry pode estar comprometido.`,
57
+ };
58
+ }
59
+ if (known) return { ok: true, tofu: false };
60
+
61
+ try {
62
+ mkdirSync(trustDir(), { recursive: true });
63
+ store[engineName] = { publicKey, firstSeen: new Date().toISOString(), source: pinned ? "pinned" : "tofu" };
64
+ writeFileSync(trustFile(), JSON.stringify(store, null, 2));
65
+ } catch {
66
+ // Não poder gravar a raiz não pode impedir a instalação — mas também não vira silêncio:
67
+ // sem gravar, a próxima execução repete o TOFU (e o consumidor vê `tofu:true` de novo).
68
+ }
69
+ return { ok: true, tofu: true };
70
+ }
71
+
72
+ export const trustPath = () => trustFile();
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // engine-kit/update-manifest.mjs — patch cirúrgico de UM motor no manifest.json do registry.
3
+ //
4
+ // node kit/update-manifest.mjs <motor> --version 0.2.0 --public-key <hex> [--repo owner/name]
5
+ // [--asset <template>] [--tag <template>] [--manifest <path>]
6
+ // node kit/update-manifest.mjs --app <nome> [--version 0.3.0] [--public-key <hex>] [--repo owner/name]
7
+ // [--tag <template>] [--asset <template>=<plataforma> ...] [--manifest <path>]
8
+ //
9
+ // Com --app, a entrada é procurada em manifest.apps (aplicativos, que o engine-kit não
10
+ // provisiona). Cada --asset repetido SUBSTITUI a lista de assets, com sidecars
11
+ // '{asset}.sha256' e '{asset}.sig'.
12
+ //
13
+ // Existe para o publish não editar JSON com regex em PowerShell (o caminho clássico para corromper
14
+ // um manifest silenciosamente). Aqui o arquivo é parseado, validado e reescrito — e se o motor não
15
+ // existir, falha alto em vez de criar uma entrada meia-boca.
16
+ import { readFileSync, writeFileSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, join } from "node:path";
19
+
20
+ const die = (msg) => { console.error(`FALHOU: ${msg}`); process.exit(1); };
21
+
22
+ const USAGE = "uso: node kit/update-manifest.mjs <motor> --version <x.y.z> [...]\n" +
23
+ " node kit/update-manifest.mjs --app <nome> [--version <x.y.z>] [--public-key <hex>] [--repo owner/name]\n" +
24
+ " [--tag <template>] [--asset <template>=<plataforma> ...] [--manifest <path>]";
25
+ const args = process.argv.slice(2);
26
+ const opt = (flag) => { const i = args.indexOf(flag); return i === -1 ? null : args[i + 1]; };
27
+ const optAll = (flag) => args.flatMap((a, i) => (a === flag ? [args[i + 1]] : []));
28
+
29
+ const appName = args.includes("--app") ? opt("--app") : null;
30
+ if (args.includes("--app") && (!appName || appName.startsWith("--"))) { die(USAGE); }
31
+ const engineName = appName ? null : args[0];
32
+ if (!appName && (!engineName || engineName.startsWith("--"))) { die(USAGE); }
33
+
34
+ const manifestPath = opt("--manifest") ?? join(dirname(dirname(fileURLToPath(import.meta.url))), "manifest.json");
35
+ const version = opt("--version");
36
+ const publicKey = opt("--public-key");
37
+ const repo = opt("--repo");
38
+ const asset = opt("--asset");
39
+ const tag = opt("--tag");
40
+
41
+ if (version && !/^\d+\.\d+\.\d+$/.test(version)) { die(`versão inválida "${version}" (esperado x.y.z)`); }
42
+ if (publicKey && !/^[0-9a-fA-F]{64}$/.test(publicKey)) { die(`publicKey inválida (esperado 64 chars hex, veio ${publicKey.length})`); }
43
+ if (repo && !/^[^/]+\/[^/]+$/.test(repo)) { die(`repo inválido "${repo}" (esperado owner/name)`); }
44
+
45
+ let manifest;
46
+ try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")); }
47
+ catch (e) { die(`manifest ilegível em ${manifestPath}: ${e.message}`); }
48
+
49
+ const save = (label, entry) => {
50
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
51
+ console.log(`manifest atualizado: ${manifestPath}`);
52
+ console.log(JSON.stringify({ name: entry.name, version: entry.version, [label]: entry[label] }, null, 2));
53
+ };
54
+
55
+ // Aplicativo: a entrada mora em manifest.apps, e o que muda numa release são versão, chave e assets.
56
+ if (appName) {
57
+ const app = (manifest.apps ?? []).find((a) => a.name === appName);
58
+ if (!app) {
59
+ die(`app "${appName}" não existe em manifest.apps de ${manifestPath}.\n` +
60
+ ` Apps registrados: ${(manifest.apps ?? []).map((a) => a.name).join(", ") || "(nenhum)"}\n` +
61
+ " Registre a entrada à mão primeiro — este script ATUALIZA, não inventa descritor.");
62
+ }
63
+ if (version) { app.version = version; }
64
+ if (publicKey) {
65
+ if (app.release.publicKey && app.release.publicKey.toLowerCase() !== publicKey.toLowerCase()) {
66
+ console.warn(`AVISO: publicKey de "${appName}" MUDOU — downloads assinados com a chave antiga deixam de conferir.`);
67
+ }
68
+ app.release.publicKey = publicKey.toLowerCase();
69
+ app.release.signatureAlgorithm = "ed25519-sha256-raw";
70
+ app.release.signatureRequired = true;
71
+ }
72
+ if (repo) { app.release.repo = repo; }
73
+ if (tag) { app.release.tag = tag; }
74
+ const assets = optAll("--asset");
75
+ if (assets.length) {
76
+ // Substitui a lista inteira: uma release é o conjunto de assets dela, não um acréscimo.
77
+ app.release.assets = assets.map((spec) => {
78
+ const cut = String(spec ?? "").lastIndexOf("=");
79
+ if (cut <= 0 || cut === spec.length - 1) { die(`--asset "${spec}" inválido (esperado <template>=<plataforma>)`); }
80
+ return { name: spec.slice(0, cut), platform: spec.slice(cut + 1), checksum: "{asset}.sha256", signature: "{asset}.sig" };
81
+ });
82
+ }
83
+ save("release", app);
84
+ process.exit(0);
85
+ }
86
+
87
+ const engine = (manifest.engines ?? []).find((e) => e.name === engineName);
88
+ if (!engine) {
89
+ die(`motor "${engineName}" não existe em ${manifestPath}.\n` +
90
+ ` Motores registrados: ${(manifest.engines ?? []).map((e) => e.name).join(", ") || "(nenhum)"}\n` +
91
+ " Registre a entrada à mão primeiro — este script ATUALIZA, não inventa descritor.");
92
+ }
93
+
94
+ if (version) { engine.version = version; }
95
+ if (publicKey) {
96
+ // Trocar a chave de um motor já publicado invalida TODAS as releases anteriores dele nos
97
+ // consumidores que já a pinaram. Se for rotação intencional, é consciente; se não, é acidente.
98
+ if (engine.install.publicKey && engine.install.publicKey.toLowerCase() !== publicKey.toLowerCase()) {
99
+ console.warn(`AVISO: publicKey de "${engineName}" MUDOU — releases assinadas com a chave antiga passam a ser recusadas.`);
100
+ }
101
+ engine.install.publicKey = publicKey.toLowerCase();
102
+ engine.install.signatureAlgorithm = "ed25519-sha256-raw";
103
+ engine.install.signatureRequired = true;
104
+ }
105
+ if (repo) { engine.install.repo = repo; }
106
+ if (asset) { engine.install.asset = asset; }
107
+ if (tag) { engine.install.tag = tag; }
108
+
109
+ save("install", engine);
package/kit/update.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // engine-kit/update.mjs — o próprio kit se atualiza antes de instalar motores.
2
+ //
3
+ // `npx engine-kit` sem versão reusa o pacote que já está na cache do npx: quem rodou uma vez fica preso naquela
4
+ // versão (e no manifest embarcado dela, que é a raiz de confiança do ensure). Por isso o cli consulta a última
5
+ // versão publicada e, por padrão, roda de novo como `npx --yes engine-kit@<última>` com os mesmos argumentos. A
6
+ // versão nova vem do mesmo canal do primeiro `npx` (npm, publicação confiável com proveniência): não é confiança nova.
7
+ //
8
+ // ENGINE_KIT_NO_AUTO_UPDATE=1 desliga: o kit não se troca nem troca motor já instalado, só AVISA da versão nova.
9
+ // ENGINE_KIT_UPDATED_FROM marca o processo relançado, para não entrar em laço.
10
+ import { spawnSync } from "node:child_process";
11
+ import { readFileSync } from "node:fs";
12
+
13
+ export const LATEST_URL = "https://registry.npmjs.org/engine-kit/latest";
14
+
15
+ export const autoUpdateEnabled = (env = process.env) => !["1", "true", "yes"].includes(String(env.ENGINE_KIT_NO_AUTO_UPDATE ?? "").toLowerCase());
16
+
17
+ export const ownVersion = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+
19
+ /** true se `a` é versão estável maior que `b` (x.y.z; pré-release como 0.2.0-bootstrap.0 nunca é alvo). */
20
+ export function isNewer(a, b) {
21
+ if (!/^\d+\.\d+\.\d+$/.test(a ?? "")) return false;
22
+ const pa = a.split(".").map(Number), pb = String(b).split(/[.-]/).map((n) => parseInt(n, 10) || 0);
23
+ for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] > pb[i]; }
24
+ return /-/.test(b); // 0.2.0 > 0.2.0-bootstrap.0
25
+ }
26
+
27
+ async function fetchLatest() {
28
+ const res = await fetch(LATEST_URL, { signal: AbortSignal.timeout(5000) });
29
+ if (!res.ok) { throw new Error(`HTTP ${res.status}`); }
30
+ return (await res.json()).version;
31
+ }
32
+
33
+ function runLatest(version, args, env) {
34
+ // npx no Windows é npx.cmd: o spawn de .cmd exige shell. Argumentos aqui são nome de motor e flags simples.
35
+ const r = spawnSync("npx", ["--yes", `engine-kit@${version}`, ...args], { stdio: "inherit", shell: process.platform === "win32", windowsHide: true, env });
36
+ return r.error ? { status: 1, error: r.error.message } : { status: r.status ?? 1 };
37
+ }
38
+
39
+ /**
40
+ * Decide e, se for o caso, executa a autoatualização. Nunca lança.
41
+ * @returns {Promise<{action:"none"|"warn"|"relaunched", status?:number, latest?:string, message?:string}>}
42
+ * `relaunched` = a versão nova já rodou o comando (o chamador só repassa `status`).
43
+ */
44
+ export async function selfUpdate(args, { env = process.env, current = ownVersion(), latest = fetchLatest, run = runLatest, log = console.error } = {}) {
45
+ if (env.ENGINE_KIT_UPDATED_FROM) {
46
+ log(`engine-kit atualizado: v${env.ENGINE_KIT_UPDATED_FROM} → v${current}`);
47
+ return { action: "none" };
48
+ }
49
+ let v;
50
+ try { v = await latest(); }
51
+ catch (e) {
52
+ log(`aviso: não consegui consultar a versão mais nova do engine-kit (${e.message}); seguindo com v${current}`);
53
+ return { action: "none" };
54
+ }
55
+ if (!isNewer(v, current)) return { action: "none" };
56
+ if (!autoUpdateEnabled(env)) {
57
+ const message = `engine-kit v${v} disponível (você roda v${current}); ENGINE_KIT_NO_AUTO_UPDATE ligado — rode npx engine-kit@latest para atualizar`;
58
+ log(message);
59
+ return { action: "warn", latest: v, message };
60
+ }
61
+ log(`engine-kit v${v} disponível (você roda v${current}) → atualizando`);
62
+ const r = run(v, args, { ...env, ENGINE_KIT_UPDATED_FROM: current });
63
+ if (r.error) {
64
+ log(`aviso: não consegui rodar engine-kit@${v} (${r.error}); seguindo com v${current}`);
65
+ return { action: "none" };
66
+ }
67
+ return { action: "relaunched", status: r.status, latest: v };
68
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // engine-kit/verify-release.mjs — prova o CAMINHO REAL depois de publicar.
3
+ //
4
+ // node kit/verify-release.mjs <motor> [--manifest <path>]
5
+ // node kit/verify-release.mjs --app <nome> [--manifest <path>] # aplicativo: TODOS os assets
6
+ //
7
+ // Baixa do release público exatamente o que um consumidor baixaria (asset + .sha256 + .sig),
8
+ // usando as URLs montadas a partir do manifest, e confere as duas garantias:
9
+ // integridade (sha256 bate) e autenticidade (assinatura confere com a publicKey do registry).
10
+ //
11
+ // Por que existe: "publiquei" não é prova. Asset faltando, .sig esquecido, tag errada e artefato
12
+ // do build anterior só aparecem quando um consumidor tenta instalar — em produção. Este passo
13
+ // puxa isso para dentro do publish. Também prova a metade negativa: adulterado é RECUSADO.
14
+ import { readFileSync } from "node:fs";
15
+ import { createHash } from "node:crypto";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+ import { resolve } from "./resolve.mjs";
19
+ import { verifyBlob, DEFAULT_ALGORITHM } from "./signature.mjs";
20
+ import { findApp, appAssetUrls, verifyAppRelease } from "./app-release.mjs";
21
+
22
+ const die = (msg) => { console.error(`FALHOU: ${msg}`); process.exit(1); };
23
+
24
+ const USAGE = "uso: node kit/verify-release.mjs <motor> [--manifest <path>]\n" +
25
+ " node kit/verify-release.mjs --app <nome> [--manifest <path>]";
26
+ const args = process.argv.slice(2);
27
+ const opt = (flag) => { const k = args.indexOf(flag); return k === -1 ? null : args[k + 1]; };
28
+ const appName = args.includes("--app") ? opt("--app") : null;
29
+ if (args.includes("--app") && (!appName || appName.startsWith("--"))) { die(USAGE); }
30
+ const engineName = appName ? null : args[0];
31
+ if (!appName && (!engineName || engineName.startsWith("--"))) { die(USAGE); }
32
+ const manifestPath = opt("--manifest") ?? join(dirname(dirname(fileURLToPath(import.meta.url))), "manifest.json");
33
+
34
+ let manifest;
35
+ try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")); }
36
+ catch (e) { die(`manifest ilegível em ${manifestPath}: ${e.message}`); }
37
+
38
+ // Aplicativo: não há asset único nem provision — baixa TODOS os assets da entrada, anônimo.
39
+ if (appName) {
40
+ const f = findApp(manifest, appName);
41
+ if (!f.ok) { die(f.reason); }
42
+ const { tag } = appAssetUrls(f.app);
43
+ console.log(`app : ${f.app.name} v${f.app.version}`);
44
+ console.log(`release : ${f.app.release.repo} @ ${tag}`);
45
+ if (!f.app.release.publicKey) { console.warn("AVISO: app sem publicKey — integridade será verificada, AUTENTICIDADE não."); }
46
+ const r = await verifyAppRelease(f.app, { log: (m) => console.log(` OK ${m}`) });
47
+ if (!r.ok) { die(r.reason); }
48
+ console.log(`\nOK: os ${r.assets.length} assets de ${f.app.name} v${f.app.version} baixam sem login e conferem.`);
49
+ process.exit(0);
50
+ }
51
+
52
+ const r = await resolve(engineName, { manifest });
53
+ if (!r.ok) { die(r.reason); }
54
+ const engine = r.engine;
55
+
56
+ async function grab(url) {
57
+ const res = await fetch(url, { signal: AbortSignal.timeout(180000) });
58
+ if (!res.ok) { throw new Error(`HTTP ${res.status} em ${url}`); }
59
+ return Buffer.from(await res.arrayBuffer());
60
+ }
61
+
62
+ console.log(`motor : ${engine.name} v${engine.version}`);
63
+ console.log(`origem : ${engine.install.repo}`);
64
+ console.log(`asset : ${engine.assetUrl}`);
65
+
66
+ let asset, sha, sig;
67
+ try { asset = await grab(engine.assetUrl); } catch (e) { die(`asset não baixou — ${e.message}`); }
68
+ try { sha = String(await grab(engine.checksumUrl)).trim().split(/\s+/)[0].toLowerCase(); }
69
+ catch (e) { die(`sidecar .sha256 não baixou — ${e.message}`); }
70
+
71
+ const actual = createHash("sha256").update(asset).digest("hex");
72
+ if (actual !== sha) { die(`SHA256 divergente (release=${sha.slice(0, 12)}… baixado=${actual.slice(0, 12)}…)`); }
73
+ console.log(`sha256 : ${actual} OK`);
74
+
75
+ const publicKey = engine.install.publicKey;
76
+ if (!publicKey) {
77
+ if (engine.install.signatureRequired) { die("signatureRequired sem publicKey no manifest — corrija antes de anunciar"); }
78
+ console.warn("AVISO: motor sem publicKey no registry — integridade verificada, AUTENTICIDADE não.");
79
+ process.exit(0);
80
+ }
81
+
82
+ try { sig = await grab(engine.signatureUrl); } catch (e) { die(`sidecar .sig não baixou — ${e.message} (a release saiu SEM assinatura)`); }
83
+
84
+ const algorithm = engine.install.signatureAlgorithm ?? DEFAULT_ALGORITHM;
85
+ const v = verifyBlob(asset, sig, publicKey, { algorithm });
86
+ if (!v.ok) { die(v.reason); }
87
+
88
+ // A metade negativa: se um artefato adulterado passasse, a verificação positiva não valeria nada.
89
+ if (verifyBlob(Buffer.concat([asset, Buffer.from([0])]), sig, publicKey, { algorithm }).ok) {
90
+ die("a verificação ACEITOU um artefato adulterado — contrato quebrado, NÃO anuncie esta release");
91
+ }
92
+
93
+ console.log(`assinatura : ${algorithm} OK (e artefato adulterado é RECUSADO)`);
94
+ console.log("\nOK: a release publicada instala pelo caminho real de um consumidor.");