diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 105082eb..7cec78a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,13 @@ permissions: env: CARGO_TERM_COLOR: always + # Code signing (spec 15.5). With the certificate's thumbprint in the + # repository variable BOTLOFT_SIGN_THUMBPRINT, scripts/sign.mjs signs every + # binary; without it the release goes out unsigned, as today. The step that + # puts the certificate in the runner's certificate store (signing in to the + # cloud key's client) goes before "Build the signed installer". + BOTLOFT_SIGN_THUMBPRINT: ${{ vars.BOTLOFT_SIGN_THUMBPRINT }} + BOTLOFT_SIGN_TIMESTAMP: ${{ vars.BOTLOFT_SIGN_TIMESTAMP }} jobs: installer: diff --git a/CLAUDE.md b/CLAUDE.md index c6f20746..9cb7c8a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup ``` -Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. +Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A assinatura de código passa por `app/scripts/sign.mjs` e só liga com a variável `BOTLOFT_SIGN_THUMBPRINT` (spec 15.5); a política pública fica em `docs/code-signing-policy.md`. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. diff --git a/README.md b/README.md index 7d1c8a96..33b9dc45 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ The full design, from the protocol to the states of a bot, lives in [`docs/spec. cooperative: a bot can read other bots' folders if it decides to. Give bots only the permissions you would give Claude Code directly. +What Botloft sends over the network, what it changes on your computer and how to remove it are in +the [code signing policy](docs/code-signing-policy.md). + ## FAQ
@@ -150,7 +153,8 @@ that you can point at a project of yours. Botloft's own data (SQLite, logs, secr The installer is not code-signed yet, so SmartScreen does not know its publisher. Each release is built by GitHub Actions from this repository, and updates are signed and checked by the app before -they install. +they install. The [code signing policy](docs/code-signing-policy.md) says how releases are made and +how to check a download.
diff --git a/app/scripts/setup.mjs b/app/scripts/setup.mjs index aa94e19e..f5a777bd 100644 --- a/app/scripts/setup.mjs +++ b/app/scripts/setup.mjs @@ -40,6 +40,12 @@ execFileSync( { cwd: root, stdio: "inherit", env }, ); +execFileSync( + process.execPath, + [join(app, "scripts", "sign.mjs"), join(target, "release", "botloft-setup.exe")], + { stdio: "inherit" }, +); + const out = join(target, "release", "bundle", "setup"); mkdirSync(out, { recursive: true }); const setup = join(out, `Botloft_${version}_x64-setup.exe`); diff --git a/app/scripts/sign.mjs b/app/scripts/sign.mjs new file mode 100644 index 00000000..e6543b4e --- /dev/null +++ b/app/scripts/sign.mjs @@ -0,0 +1,76 @@ +// Signs one Windows binary with the release certificate (spec 15.5). +// +// node scripts/sign.mjs +// +// Tauri runs it for everything that goes into the installer +// (`bundle.windows.signCommand`): the app, the daemon, the NSIS plugins, the +// uninstaller and the installer itself. setup.mjs runs it for the setup +// window, which Tauri does not build. The certificate lives in the Windows +// certificate store of the machine that builds (a cloud key shows up there +// once its client is signed in), and is picked by its SHA-1 thumbprint: +// +// BOTLOFT_SIGN_THUMBPRINT the certificate's thumbprint; without it nothing +// is signed, as in dev builds and in releases +// made before the certificate exists +// BOTLOFT_SIGN_TIMESTAMP RFC 3161 timestamp server (a public one by default) +// BOTLOFT_SIGNTOOL signtool.exe, when it is not in the Windows SDK + +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; + +const file = process.argv[2]; +if (!file) { + throw new Error("usage: sign.mjs "); +} +const thumbprint = (process.env.BOTLOFT_SIGN_THUMBPRINT ?? "").replace(/\s/g, ""); +if (!thumbprint) { + console.log(`not signing ${basename(file)}: BOTLOFT_SIGN_THUMBPRINT is not set`); + process.exit(0); +} + +/** signtool.exe from the newest Windows SDK on this machine. */ +function signtool() { + if (process.env.BOTLOFT_SIGNTOOL) { + return process.env.BOTLOFT_SIGNTOOL; + } + const kits = join(process.env["ProgramFiles(x86)"] ?? "", "Windows Kits", "10", "bin"); + const versions = existsSync(kits) + ? readdirSync(kits) + .filter((name) => /^10\./.test(name)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + : []; + for (const version of versions) { + const tool = join(kits, version, "x64", "signtool.exe"); + if (existsSync(tool)) { + return tool; + } + } + throw new Error(`signtool.exe not found under ${kits}; set BOTLOFT_SIGNTOOL`); +} + +const tool = signtool(); +const timestamp = process.env.BOTLOFT_SIGN_TIMESTAMP || "http://timestamp.digicert.com"; +execFileSync( + tool, + [ + "sign", + "/sha1", + thumbprint, + "/fd", + "sha256", + "/tr", + timestamp, + "/td", + "sha256", + "/d", + "Botloft", + "/du", + "https://github.com/httpsphl/botloft", + file, + ], + { stdio: "inherit" }, +); +// A signature Windows does not trust would only show up on the owner's PC. +execFileSync(tool, ["verify", "/pa", file], { stdio: "inherit" }); +console.log(`signed ${basename(file)}`); diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json index fc42e1dc..e6bb535e 100644 --- a/app/src-tauri/tauri.bundle.conf.json +++ b/app/src-tauri/tauri.bundle.conf.json @@ -4,6 +4,7 @@ "externalBin": ["binaries/botloftd"], "createUpdaterArtifacts": true, "windows": { + "signCommand": { "cmd": "node", "args": ["../scripts/sign.mjs", "%1"] }, "nsis": { "installMode": "currentUser", "installerIcon": "icons/icon.ico", diff --git a/crates/botloftd/botloftd.rc b/crates/botloftd/botloftd.rc index fac54ca2..03081418 100644 --- a/crates/botloftd/botloftd.rc +++ b/crates/botloftd/botloftd.rc @@ -1,3 +1,31 @@ -// The daemon's Windows resources: only its manifest (spec 14). +// The daemon's Windows resources (spec 14): its manifest and its version +// information. build.rs fills in the @PLACEHOLDERS@ and compiles the result. #define RT_MANIFEST 24 -1 RT_MANIFEST "botloftd.manifest" +1 RT_MANIFEST "@MANIFEST@" + +// What Windows shows in the file's properties and in Task Manager, and what a +// code signing service checks against the product it signs (spec 15.5). +1 VERSIONINFO +FILEVERSION @VERSION_NUMBERS@ +PRODUCTVERSION @VERSION_NUMBERS@ +FILEOS 0x40004L +FILETYPE 0x1L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "ProductName", "Botloft" + VALUE "FileDescription", "Botloft background service" + VALUE "FileVersion", "@VERSION@" + VALUE "ProductVersion", "@VERSION@" + VALUE "LegalCopyright", "Copyright 2026 Phelipe Lorran" + VALUE "InternalName", "botloftd" + VALUE "OriginalFilename", "botloftd.exe" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs index 9c3a71d0..9c7f8d7b 100644 --- a/crates/botloftd/build.rs +++ b/crates/botloftd/build.rs @@ -1,14 +1,42 @@ -//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in -//! as a resource: the linker's own manifest tool predates -//! `consoleAllocationPolicy` and warns about it on every build. +//! Embeds the daemon's Windows resources (spec 14): `botloftd.manifest` and +//! the version information. The manifest goes in as a resource because the +//! linker's own manifest tool predates `consoleAllocationPolicy` and warns +//! about it on every build. + +use std::path::PathBuf; +use std::{env, fs}; + +/// `1.2.3` (with or without a tag) as the four numbers Windows wants. +fn version_numbers(version: &str) -> String { + let core = version.split(['-', '+']).next().unwrap_or_default(); + let mut parts: Vec<&str> = core.split('.').collect(); + parts.resize(4, "0"); + parts.join(",") +} fn main() { println!("cargo:rerun-if-changed=botloftd.rc"); println!("cargo:rerun-if-changed=botloftd.manifest"); - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") - && let Err(err) = - embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required() - { - panic!("cannot embed the manifest: {err}"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let here = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets the crate dir")); + let version = env::var("CARGO_PKG_VERSION").expect("cargo sets the version"); + // Forward slashes: a backslash starts an escape in a resource script. + let manifest = here + .join("botloftd.manifest") + .display() + .to_string() + .replace('\\', "/"); + let script = fs::read_to_string(here.join("botloftd.rc")) + .expect("cannot read botloftd.rc") + .replace("@MANIFEST@", &manifest) + .replace("@VERSION_NUMBERS@", &version_numbers(&version)) + .replace("@VERSION@", &version); + let out = + PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("botloftd.rc"); + fs::write(&out, script).expect("cannot write the resource script"); + if let Err(err) = embed_resource::compile(&out, embed_resource::NONE).manifest_required() { + panic!("cannot embed the resources: {err}"); } } diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md new file mode 100644 index 00000000..6e7b3666 --- /dev/null +++ b/docs/code-signing-policy.md @@ -0,0 +1,71 @@ +# Code signing policy + +This page says how Botloft's releases are made, who may release them, what Botloft sends over the +network and what it changes on your computer. + +## Status + +Botloft's releases are **not code-signed yet**. Until they are, Windows SmartScreen may ask you to +confirm when you run the installer, and an antivirus may flag it by guesswork (a "machine-learning" +or "heuristic" detection), as happens with new programs that have no signature. If that happens to +you, please [open an issue](https://github.com/httpsphl/botloft/issues); we report every false +positive to the vendor. + +The release pipeline is ready to sign `Botloft.exe`, `botloftd.exe`, the setup window, the installer +and the uninstaller as soon as a certificate is in place. All of them carry the product name +"Botloft" and the version of the release. + +## How releases are made + +- Every release is built by GitHub Actions ([`release.yml`](../.github/workflows/release.yml)) from + a `vX.Y.Z` tag on `main`, on GitHub's own runners. Nothing is built on a maintainer's computer. +- The workflow opens a draft release. A person reads it and publishes it: no release goes out + without that manual approval. +- Each installer gets a build provenance attestation, and the release notes list its SHA-256. To + check that a download came from this repository's workflow, with the + [GitHub CLI](https://cli.github.com/): + + ``` + gh attestation verify Botloft__x64-setup.exe --repo httpsphl/botloft + ``` + + This applies to releases after 0.6.0. +- Updates are signed with Botloft's own updater key. The app checks the signature, and that it was + made for the version announced, before it installs anything. + +## Team roles + +- **Committers and reviewers:** [Phelipe Lorran](https://github.com/httpsphl) +- **Approvers:** [Phelipe Lorran](https://github.com/httpsphl) + +## Privacy + +Botloft has no telemetry, no analytics and no accounts of its own. The background service listens +on `127.0.0.1` only. What goes over the network: + +- **Update check.** When the app opens, and every 6 hours while it is open, it asks GitHub for the + latest release's `latest.json`. It downloads an update only when you click **Update now**. No + information about you or your bots is sent. +- **Your bots.** Each bot is Claude Code running on your computer with your own Claude account. What + it sends to Anthropic, and the websites it opens when its work needs them, are the same as when + you use Claude Code yourself. + +## What Botloft changes on your computer + +- It installs for your Windows user only, under `%LOCALAPPDATA%\Botloft`, with shortcuts in the + Start menu and on the desktop. It never asks for an administrator. +- It registers a scheduled task for your user, named "Botloft", that keeps the bots running: it + starts when you sign in and brings the background service back if it stops. Settings > General + turns off starting with Windows, or stops the bots when you close the app. +- While the bots start with Windows and Botloft stays near the clock or opens its window at sign-in, + the app adds itself to your user's startup programs. Settings > General turns each of these off. +- While a bot is working, it asks Windows not to put the computer to sleep. Settings > General turns + this off. +- Bots keep their work in `%USERPROFILE%\Botloft`, or in the folder you pick for a crew. + +## Uninstalling + +Settings > Apps > Installed apps > Botloft > Uninstall removes the app, stops the background +service, and removes its scheduled task and the startup entry. Your bots, their conversations and +their files stay in `%LOCALAPPDATA%\Botloft` and `%USERPROFILE%\Botloft`; delete those two folders +to remove everything. diff --git a/docs/spec.md b/docs/spec.md index 1be68ac7..062638e2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -718,6 +718,10 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução. - Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), depois `pnpm bundle:setup` (15.7), `release.mjs stage`, o atestado de origem e `release.mjs publish`, que abre um release **rascunho** com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) e o `.sig` dele, e o `latest.json`. O updater só enxerga o release depois que o dono o publica. - Atestado de origem: entre o `stage` e o `publish`, o workflow roda `actions/attest@v4` nos dois `.exe` (permissões `id-token`, `attestations` e `artifact-metadata`). É um atestado de proveniência SLSA assinado pelo Sigstore: qualquer pessoa confere com `gh attestation verify --repo httpsphl/botloft` que o instalador saiu deste workflow, deste repositório e daquele commit. As notas do rascunho começam com "Verify your download": o SHA-256 dos dois instaladores e esse comando. O VirusTotal fica de fora das notas enquanto a tela de instalação tiver alertas de aprendizado de máquina (na 0.6.0, 2 de 71: Microsoft `Wacatac.C!ml` e Trapmine), à espera da análise de falso positivo. +- Assinatura de código (Authenticode): ainda não há certificado, e sem ele o release sai sem assinatura. O caminho já está pronto: `app/scripts/sign.mjs ` assina com o `signtool` do Windows SDK o certificado que estiver no repositório de certificados da máquina, escolhido pela impressão digital em `BOTLOFT_SIGN_THUMBPRINT` (uma chave em nuvem aparece ali depois que o cliente dela entra na conta), com carimbo de tempo RFC 3161 (`BOTLOFT_SIGN_TIMESTAMP`) e confere a assinatura em seguida. Sem a variável, não faz nada; com ela e sem o certificado, falha, para um release nunca sair sem assinatura por engano. Quem chama: o Tauri, pelo `bundle.windows.signCommand` do `tauri.bundle.conf.json`, para tudo que entra no instalador (o `Botloft.exe`, o sidecar `botloftd.exe`, os plugins do NSIS, o desinstalador e o próprio instalador; ele pula o que já estiver assinado); e o `setup.mjs` para a tela de instalação, que o Tauri não monta. A cópia do daemon em `\bin` é o mesmo arquivo assinado do instalador. O `release.yml` passa as duas variáveis do repositório. O `.sig` do atualizador e o atestado de origem são feitos depois, sobre os arquivos já assinados. +- Todo binário leva nome do produto e versão nos metadados, que é o que um serviço de assinatura confere: o app e a tela de instalação pelo `tauri-build`, o instalador pelo NSIS e o `botloftd.exe` por um `VERSIONINFO` no `botloftd.rc` (produto "Botloft", descrição "Botloft background service", versão do crate), preenchido pelo `build.rs`. +- Opções de certificado vistas em 2026-09-30, para uma pessoa física no Brasil: o Azure Artifact Signing não atende (pessoa física só nos EUA e no Canadá); a SignPath Foundation assina de graça projetos abertos aprovados, com o nome dela como editor e uma aprovação manual por release; o certificado Open Source da Certum (a partir de € 49, chave em nuvem) sai no nome do desenvolvedor; a SSL.com tem validação individual com assinatura em nuvem e action oficial, por cerca de US$ 300 por ano. Nenhuma tira o aviso do SmartScreen de imediato: a reputação cresce a cada release assinado com a mesma identidade. A política pública está em `docs/code-signing-policy.md`. +- Falso positivo do Defender (0.6.0, 2026-09-30): no PC do dono, o Microsoft Defender pôs em quarentena, por detecção de nuvem, o `-setup.exe` baixado (`Trojan:Win32/Wacatac.C!ml`) e depois o `Botloft.exe` instalado e em uso (`Trojan:Script/Wacatac.C!ml`), apagando os atalhos; o daemon não foi tocado e os bots seguiram. São palpites de aprendizado de máquina sobre binários novos e sem assinatura. Os dois arquivos foram enviados à Microsoft como falso positivo. É o motivo de a assinatura ter deixado de ser opcional para distribuir o app. ### 15.6 Idiomas @@ -768,7 +772,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - `/S` por cima de uma versão instalada: se desinstala a anterior (como o `/P` faz pela página de reinstalação) ou só sobrescreve, e se algum arquivo antigo fica. **Mesma versão** (0.5.0 sobre 0.5.0, 2026-09-29): nenhum processo de desinstalador apareceu (olhando a cada 100 ms); o NSIS só sobrescreveu os arquivos. Falta ver com uma versão mais antiga (I3). - `/S` com o app aberto: se o NSIS fecha o app sozinho, pergunta ou falha. O setup fecha o app antes (verificado: "Instalar de novo" com o app aberto fechou o app, instalou e abriu de novo), mas o comportamento do NSIS sozinho precisa ficar anotado. - `/S` com uma versão mais nova instalada: se sai com erro (`silentDowngrades`) e com qual código. -- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. Falta ver a atualização de verdade da 0.5.0 para a 0.6.0. +- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. **Verificado** (2026-09-30): o app instalado na 0.5.0 se atualizou para a 0.6.0 pelo `-update.exe`, com a janela passiva do NSIS, e a entrada em Aplicativos instalados e a pasta ficaram as mesmas. - Com o manifesto `asInvoker`, o Windows não pede administrador para o setup. **Verificado** (Windows 11 25H2, 2026-09-29): o `Botloft_0.5.0_x64-setup.exe` abriu a janela direto, sem pedido do UAC. - O identificador dos atalhos continua o mesmo, para os avisos saírem com o nome do Botloft.