From 168d4da1a89977e83e8cbbd49478388ef1b67102 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 16:53:49 -0400 Subject: [PATCH 01/12] =?UTF-8?q?docs(plano):=20#90=20=E2=80=94=20mTLS=20T?= =?UTF-8?q?raefik=20=E2=86=94=20backend,=20decis=C3=B5es=20do=20dono=20reg?= =?UTF-8?q?istradas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-10-27-issue-90-mtls-traefik-backend.md | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md diff --git a/docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md b/docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md new file mode 100644 index 00000000..159ae048 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md @@ -0,0 +1,654 @@ +# Issue #90 — mTLS Traefik ↔ backend — Plano de implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:executing-plans (execução **Native**, decisão do dono em 2026-10-27) tarefa a tarefa, com um **checkpoint de revisão independente depois da Task 5** (núcleo de segurança: patch do fork + `ApiTlsConfig` + wrapper do `SecureServerSocket`) antes de tocar Traefik, Compose e testes de stack — ver a seção "Checkpoint de revisão". Os passos usam checkbox (`- [ ]`). **Decisão do dono registrada em 2026-10-27: os itens 1 a 4 do "Ponto de decisão" foram aprovados, com as condições anotadas em cada um.** As condições viraram passos nas tarefas; nenhuma delas é opcional. + +**Objetivo:** o backend passa a exigir, no handshake TLS da 8080 interna, um certificado de cliente assinado pela CA de desenvolvimento do RPC; o Traefik passa a apresentar esse certificado (`serversTransport.certificates`); um processo na rede do Compose sem o certificado é recusado antes de qualquer byte HTTP; o Grupo E de `scripts/qa/tls_invariants.sh` prova o caso negativo e o positivo; os apps continuam falando com o backend via Traefik (regressão no emulador). + +**Arquitetura:** o Serverpod 3.4.13 sobe o apiServer por `RelicApp.serve` → `IOAdapter.bind` → `HttpServer.bindSecure`, sem como exigir certificado de cliente, e as versões 4.0.4 e 4.1.0-rc.1 têm a **mesma** chamada fixa (medido). A recomendação é um **fork pinado do Serverpod 3.4.13** com um patch mínimo e genérico — um `apiServerAdapterFactory` opcional que deixa a aplicação fornecer o `Adapter` do relic — consumido por `dependency_overrides` no workspace `backend/`. Toda a lógica de mTLS fica **no repositório** (`sinalacs_server`): `SecureServerSocket.bind(requireClientCertificate: true)` embrulhado num `ServerSocket` que o `HttpServer.listenOn` aceita, e `IOAdapter(httpServer)`. O `traefik-init` emite duas folhas `clientAuth` com a CA do RPC (uma para o Traefik, outra para o healthcheck do container), o Traefik as apresenta pelo `serversTransport` `backend-tls` que já existe, e `API_TLS_CLIENT_CA_PATH` passa a ser obrigatória junto com `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH`. + +**Tecnologias:** Serverpod 3.4.13 (Dart 3.12.2, `relic`/`relic_io` 1.2.0), `dart:io` (`SecureServerSocket`, `HttpServer.listenOn`), Traefik v2.11 (`serversTransports`), Docker Compose, OpenSSL, bash (`scripts/qa`), emulador Android (`emulator-5554`). + +**Spec:** issue #90 (`gh issue view 90`), continuação da #78 (PR #89; plano `docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md`, que registrou a decisão "TLS de mão única, sem fork e sem sidecar" em 2026-10-16). Requisito RNF04 (`spec/PRD_system.md:158`), linha CRIPTO-TRAEFIK-BACKEND de `docs/auditoria-prd/2026-10-15-lacunas-prd.md:96`. Fora do escopo (a issue é explícita): Postgres, Gorush, portas 8082/8083 e painel 8081. + +## Restrições globais + +- Português em código, comentários, mensagens, commits e documentação. Sem atribuição de IA em commits e PR (`CLAUDE.md`). +- **Não enfraquecer o TLS 1.3 Traefik → backend que existe:** `serverName: sinalacs-serverpod` e `rootCAs: [/certs/ca.crt]` do `backend-tls` ficam; nenhum `insecureSkipVerify` em lugar nenhum (E4 já cobra); `minimumTlsProtocolVersion = tls1_3` do `ApiTlsConfig` fica. +- **Nenhuma chave privada no git:** tudo nasce em `infra/docker/traefik/runtime/` (gitignored, `.gitignore:30`), gerado pelo `traefik-init`. Chave lida só por root → `600`; chave lida por outro uid → `644` (decisão em `PROGRESS.md:1525`, presa por `scripts/qa/dev_key_permissions_test.sh`). +- **Fora do escopo, intocados:** `postgres`, `gorush`, `SERVERPOD_DATABASE_REQUIRE_SSL`, Insights 8083, webServer 8082, dashboard 8081. RNF04 continua **parcial** por eles depois desta issue. +- **Um só escritor da configuração TLS do apiServer:** `ApiTlsConfig` (`lib/src/config/api_tls_config.dart`). A regra "as variáveis vêm juntas ou não vêm" passa a valer para **três** variáveis (`API_TLS_CERT_PATH`, `API_TLS_KEY_PATH`, `API_TLS_CLIENT_CA_PATH`). Sem as três = HTTP (caminho do Render, `backend/DEPLOY.md`), como hoje. +- **O fork do Serverpod carrega um só patch, genérico e sem semântica de mTLS:** `apiServerAdapterFactory`. Nada de lógica do SinalACS dentro do fork — assim o diff upstream é pequeno e o fork é descartável no dia em que o upstream expuser o ganho. +- `backend/pubspec.lock` muda **só** na entrada de `serverpod` (passa de `hosted` para `git`, mesma versão 3.4.13). Qualquer outra linha movendo é regressão a investigar. +- Sondas negativas **sempre** com controle positivo antes (desenho de `tls_invariants.sh` e `mtls_invariants.sh`): "recusado" com a stack no chão não conta. +- Testes de integração do backend: `dart test -j 1`. Nenhum dado real. + +## Diagnóstico e reprodução (feito em 2026-10-27, `develop` em `d64e23c`) + +Estado atual, medido com a stack de desenvolvimento de pé: um container **qualquer** na rede do Compose (`sinalacs_default`), sem certificado de cliente, completa o handshake TLS 1.3 com o backend e recebe o `health/check`. + +```bash +# Comando-evidência (vira a base do caso negativo E5 do tls_invariants.sh). +# Roda de FORA do container do backend, como faria um container comprometido. +rede="$(docker inspect sinalacs-serverpod -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')" +docker run --rm --network "$rede" \ + -v "$PWD/infra/docker/traefik/runtime/backend/ca.crt:/ca.crt:ro" \ + curlimages/curl:8.10.1 -sS --max-time 10 --tlsv1.3 --cacert /ca.crt \ + -X POST -H 'Content-Type: application/json' -d '{}' \ + -w '\nHTTP %{http_code} verify=%{ssl_verify_result}\n' \ + https://sinalacs-serverpod:8080/health/check +``` + +Saída medida hoje (o defeito): `{"__className__":"ServiceHealth","status":"ok","mqttConnected":true,"dbConnected":true}` / `HTTP 200 verify=0`. Com `-v`, o handshake mostra `Server hello`, `Certificate (11)`, `Finished (20)` e **nenhum `Request CERT (13)`**: o servidor nunca pede o certificado. Depois da Task 7, o mesmo comando tem de terminar sem nenhuma linha HTTP (curl exit 35 ou 56, `HTTP 000`). + +## Investigação do bloqueio (feita em 2026-10-27) + +Verificado no `~/.pub-cache` e nos tarballs do pub.dev (`https://pub.dev/api/archives/-.tar.gz`, descompactados em diretório isolado): + +| Camada | Versão em uso | Última publicada | O que faz | Expõe `requestClientCertificate`/`requireClientCertificate`? | +|---|---|---|---|---| +| `dart:io` | 3.12.2 | — | `HttpServer.bindSecure(…, requestClientCertificate)` chama `SecureServerSocket.bind` **sem** repassar `requireClientCertificate` (`_http/http_impl.dart:3481-3499`). `SecureServerSocket.bind` tem os dois flags (`io/secure_server_socket.dart:72-73`). `HttpServer.listenOn(ServerSocket)` existe, mas `SecureServerSocket implements ServerSocketBase`, não `ServerSocket`. | Só `request` pelo `HttpServer`; `require` só pelo `SecureServerSocket` | +| `relic_io` | 1.2.0 | 1.2.0 (2.0.0-rc.2 em pré-lançamento) | `bindHttpServer` chama `HttpServer.bindSecure(address, port, context, backlog:, v6Only:, shared:)` (`lib/src/adapter/bind_http_server.dart:39`). **Idêntico em 2.0.0-rc.2.** `IOAdapter(this._server)` aceita um `HttpServer` já ligado. | Não | +| `serverpod` | 3.4.13 | 4.0.4 (4.1.0-rc.1 em pré-lançamento) | `Server.start` chama `_app.serve(address: anyIPv6, port:, securityContext:)` fixo (`lib/src/server/server.dart:172`). **Idêntico em 4.0.4 e 4.1.0-rc.1** (`server.dart:182`). `RelicApp.run(adapterFactory)` existe no `relic_core` e é o que `serve` usa por baixo. | Não | + +Upstream: `gh search issues --repo serverpod/serverpod 'client certificate'`, `--repo serverpod/relic 'certificate'` e `--repo dart-lang/sdk 'HttpServer bindSecure requireClientCertificate'` em 2026-10-27 não acham nenhuma issue sobre certificado de cliente no servidor (a única do relic, #395, é sobre TLS no `relic_native`). O changelog do Serverpod cita "Improves client certificate security" num advisory antigo, que é sobre o **cliente** HTTP, não sobre o servidor. **Conclusão:** não existe versão para a qual subir; um upgrade para 4.x (major, com mudanças de API) não resolveria nada e está fora desta issue. + +Viabilidade de contribuir upstream: a mudança natural é em dois repositórios — `relic_io` ganhar `requestClientCertificate`/`requireClientCertificate` em `bindHttpServer`/`IOAdapter.bind`/`serve` (e, para o *require*, ligar por `SecureServerSocket` + `HttpServer.listenOn`, porque o `bindSecure` do SDK não repassa o flag), e o Serverpod plumbar isso em `SecurityContextConfig`. Dois ciclos de release, sem prazo que o SinalACS controle, e a linha 3.4.x pode não receber a feature. Por isso o fork temporário, e por isso o patch do fork é o **gancho genérico** (`apiServerAdapterFactory`), que é também a proposta a abrir upstream: ele resolve o nosso caso sem o upstream precisar decidir a semântica de mTLS. + +Prova de conceito (`dart:io` puro, feita em diretório temporário fora do repositório, com uma CA descartável): um `SecureServerSocket.bind(…, requireClientCertificate: true)` embrulhado num `ServerSocket` mínimo (`StreamView implements ServerSocket`, com `address`/`port`/`close`) e passado a `HttpServer.listenOn` **funciona**: `curl` sem certificado recebe erro TLS (`curl: (56) … unexpected eof while reading`, `HTTP 000`), com certificado da CA recebe 200, com certificado de **outra** CA recebe o mesmo erro TLS. O `HttpServer.bindSecure(requestClientCertificate: true)` sozinho **não** serve ao critério: sem certificado o handshake completa e a aplicação vê `request.certificate == null` (a recusa seria HTTP 401, não handshake). Detalhe medido que o teste precisa respeitar: `openssl s_client -brief` sem certificado imprime `CONNECTION ESTABLISHED` / `TLSv1.3` **mesmo recusado** — no TLS 1.3 o cliente conclui o seu lado antes de o servidor validar o certificado dele — então as sondas negativas usam `curl` e afirmam a **ausência de resposta HTTP**, não o texto do `s_client`. + +## Análise das alternativas + +| | (A) Fork pinado do Serverpod 3.4.13 + mTLS no processo | (B) Sidecar de terminação mTLS (stunnel/nginx) na frente da 8080 | (C) Rede Docker interna só com Traefik + backend | +|---|---|---|---| +| Atende o critério "sem certificado → recusa no handshake"? | **Sim** — `requireClientCertificate: true` (PoC acima) | Sim, no sidecar; o backend em si continua aceitando qualquer um, então a 8080 do backend tem de deixar de ser alcançável (`network_mode: service:sidecar` ou porta só em `127.0.0.1`) | **Não** — é mitigação. O backend escuta em `anyIPv6` e continua na rede `default` com Postgres, Mosquitto e Gorush; esses três seguem alcançando a 8080 sem certificado | +| Autentica o Traefik? | Sim, pela CA do RPC | Sim, pela CA do RPC | Não | +| `spec/stack.md` | **Dentro** — Serverpod continua o backend; muda a *procedência* do pacote (git pinado), não a stack | **Fora** — serviço novo não previsto; exige decisão de arquitetura e nova entrada no `docker-compose.yml`, nova imagem, novo par de certificados, dois TLS em sequência (ou o backend volta a HTTP atrás do sidecar, perdendo o que a #78 entregou) | Dentro | +| Complexidade e risco | Patch de ~15 linhas no fork (um parâmetro novo plumbado de `Serverpod` → `Server.start`), ~60 linhas Dart no repositório, 2 folhas novas no `traefik-init`. Risco: manutenção do fork no próximo upgrade do Serverpod (rebase de um patch pequeno); e o `ServerSocket` embrulhado depende de o `_HttpServer` só usar `listen`/`address`/`port`/`close` (preso por teste) | Mais um processo para subir, logar e manter; `network_mode: service:` quebra o DNS do backend e o provider Docker do Traefik; stunnel não fala HTTP (não há healthcheck de aplicação nele) | Baixa complexidade, mas não fecha a issue e dá falsa sensação de segurança | +| Manutenção | Rebase do fork a cada upgrade do Serverpod até o upstream aceitar o gancho | Imagem e configuração de um componente a mais, para sempre | Nenhuma | + +**Recomendação: (A).** É a única que atende o critério de aceite como *autenticação* e fica dentro do `spec/stack.md`. O custo é manter um fork pinado por commit com um patch pequeno, genérico e com proposta upstream aberta — custo que a #78 recusou quando o escopo era "TLS 1.3 nos três trechos" e que esta issue existe justamente para reavaliar. (C) pode ser somada depois como defesa em profundidade, mas não substitui (A). (B) só se (A) for recusada **e** o dono aceitar mudar o `spec/stack.md`. + +## Ponto de decisão — respondido pelo dono em 2026-10-27 + +A recomendação **não sai do `spec/stack.md`**, mas reverte uma decisão registrada (2026-10-16: "sem fork") e envolve ações externas. O dono aprovou os quatro itens, com condições que agora são parte do plano: + +1. **Fork pinado do Serverpod — APROVADO, com condições.** `hbgit/serverpod` (fork de `serverpod/serverpod`), branch `sinalacs/3.4.13-api-server-adapter` a partir da tag `3.4.13`, **um** commit de patch, consumido por `dependency_overrides` apontando para o **SHA** (nunca para o branch); diff versionado em `backend/patches/serverpod-3.4.13-api-server-adapter.patch`. Condições: (a) **registro da reversão** da decisão da #78 em `spec/stack.md`, numa nota-ADR curta com três coisas — o motivo (bloqueio upstream comprovado: `Server.start` fixo em 3.4.13, 4.0.4 e 4.1.0-rc.1; `relic_io` sem o flag; `bindSecure` do SDK sem `require`), o **critério de saída** (remover o fork e voltar ao pub.dev no dia em que o gancho — ou equivalente que recuse no handshake — entrar numa versão oficial) e o **custo de upgrade** (subir para 4.x exige rebase do patch antes de qualquer outra coisa) → Task 10; (b) **o build Docker busca o fork sem credencial** (repositório público; prova por `docker build` numa máquina sem `~/.gitconfig`/credential helper, isto é, com `GIT_CONFIG_GLOBAL=/dev/null` no estágio de build, e pelo job `backend-docker-build` da CI, que não tem token) → Task 2; (c) **o cliente Flutter não é afetado**: num workspace Dart o `dependency_overrides` só vale no `pubspec.yaml` da **raiz** (`backend/pubspec.yaml`; um override num membro é ignorado com aviso), então ele mora lá por regra do pub, mas o único pacote que depende de `serverpod` é `sinalacs_server` — `sinalacs_client` depende de `serverpod_client` e os três apps de `serverpod_client`/`serverpod_flutter`, todos do pub.dev; a prova é `git diff --stat -- backend/sinalacs_client apps/*/pubspec.lock` vazio e `grep -n '^ serverpod:' apps/*/pubspec.yaml backend/sinalacs_client/pubspec.yaml` sem resultado → Task 2. +2. **Issue upstream — APROVADO, com condições.** O patch é proposto contra o **`main`** (linha 4.x; `Server.start` lá é idêntico, então o mesmo diff aplica) num segundo branch do fork, `upstream/api-server-adapter`; o branch `sinalacs/3.4.13-…` continua sendo o consumido. A issue traz a **evidência** de que `requestClientCertificate` sozinho não recusa no handshake (saída da PoC: handshake completo, `request.certificate == null`, HTTP 401 só na aplicação) — é o que justifica o gancho de adapter em vez de uma flag, e evita o upstream "resolver" com a flag errada → Task 10. +3. **Três variáveis juntas ou nenhuma — APROVADO, com condições.** (a) **Fail-fast no boot**: cert + chave sem CA (ou qualquer subconjunto próprio) → o processo não sobe e a mensagem nomeia as três variáveis e diz que TLS de mão única não é mais uma configuração válida; nunca cai para mão única em silêncio → Task 3 (já era `StateError` em `fromMap`; a condição fixa o **texto** e acrescenta o teste de que o boot do container falha, Task 7 mutação b). (b) **Inventário de quem fala direto com a 8080** feito antes de executar — seção abaixo. +4. **Folha de cliente para o healthcheck — APROVADO, com condições.** Folha **distinta** da do Traefik, CN próprio `sinalacs-healthcheck`, EKU restrito a `clientAuth`, **validade curta** (`healthcheck_days=90`, contra 825 das demais; a folha é regerada a cada `traefik-init`, e o Grupo E ganha a asserção E9, que acusa quando faltam menos de 7 dias) → Tasks 6 e 8. Limite registrado: as duas origens são distinguíveis pelo CN no TLS, mas o backend **não loga** o CN do cliente hoje (nem o Serverpod nem o relic expõem o `peerCertificate` ao handler); registrar isso no `PROGRESS.md` como aberto, fora do escopo. +5. **Rede interna como defesa em profundidade (C)** — fica **fora** deste plano (issue de continuação). + +## Inventário: quem fala com a 8080 do backend (condição 3b, levantado em 2026-10-27) + +`grep -rn ':8080' --include='*.dart' --include='*.sh' --include='*.yml' --include='*.yaml' --include='*.py' --include=Dockerfile --include='*.md'` fora de `pg_data/`, `build/`, `graphify-out/` e dos planos, mais os defaults de host dos apps e das ferramentas: + +| Cliente | Hoje | Depois da #90 | Tarefa | +|---|---|---|---| +| `HEALTHCHECK` do `docker-compose.yml` (`curl … https://localhost:8080/health/check`, dentro do container) | TLS de mão única | **Quebra** sem certificado → recebe `healthcheck.{crt,key}` | Task 7 | +| Grupo E de `tls_invariants.sh` (E1–E3 por `docker exec` no container do backend) | sondas sem certificado | **Quebra** a positiva E2 → reescrito de fora da rede, com certificado na positiva | Task 8 | +| Grupos C e D de `tls_invariants.sh` (`http://localhost:8080`, texto claro, negativos) | recusados | inalterados (continuam negativos) | — | +| `HEALTHCHECK` do `Dockerfile` (`http://localhost:8080`, caminho do Render) | HTTP, sem `API_TLS_*` | inalterado: sem as três variáveis o apiServer segue em HTTP | — (uma frase em `backend/DEPLOY.md`) | +| `dart run bin/main.dart` fora do Compose, e o harness `withServerpod` dos testes de integração | HTTP (não leem `API_TLS_*`) | inalterados; quem exportar `API_TLS_CERT_PATH`/`KEY_PATH` à mão passa a precisar da terceira | `backend/CLAUDE.md` (Task 10) | +| Traefik (`serversTransport backend-tls`) | TLS verificado, sem certificado de cliente | **Quebra** → `certificates` | Task 7 | +| Apps (`SINALACS_HOST`, default `https://10.0.2.2/` ou `https://localhost:8443/` via `adb reverse`), `tool/live_check.dart`, `tool/send_notice.dart`, `tool/schedule_notice.dart`, `scripts/qa/measure_latency.dart` | todos pelo Traefik em :443 (`requireSecureHost` recusa `http://…:8080`) | inalterados | — (Task 9 prova) | +| `apps/*/test/*` com `http://10.0.2.2:8080/` | é a **fixture negativa** de `requireSecureHost` | inalterados | — | +| `scripts/qa/admin_alertas_escalonar_e2e.sh:41`, `video/README.md:144`, `spec/PRD_system.md:741` | só comentário/histórico | inalterados | — | + +Conclusão: só três consumidores reais da 8080 existem (healthcheck do Compose, Grupo E, Traefik) e os três estão nas Tasks 7 e 8. Nenhum app, ferramenta ou teste fala com a 8080 diretamente. + +## Risco coberto: handshake falho é erro no stream do listener (DoS) + +No `SecureServerSocket`, um handshake que falha vira `addError` no stream do socket servidor (`RawSecureServerSocket._onData` → `catchError` → `_controller.addError`). Medido no SDK 3.12.2 e no relic 1.2.0: + +- `_HttpServer.listenOn` escuta o socket com `onError` que **ignora `HandshakeException`** ("bound to a single request, and are not fatal for the server", `_http/http_impl.dart:3564-3570`) e repassa os demais ao stream de requests; `cancelOnError` não é usado. +- O relic escuta `adapter.requests.listen(_handleRequest)` dentro de `catchTopLevelErrors` (`relic_core/lib/src/relic_server.dart:97-108`), que só **loga** erros assíncronos; e `bin/main.dart` ainda tem o `runZonedGuarded` da #56. +- PoC: 30 conexões sem certificado + 10 de outra CA + 10 conexões TCP com bytes inválidos abortadas no meio do handshake, **depois** a sonda positiva recebe 200 e o stderr do servidor fica vazio. + +Decisões do plano: (1) o wrapper `SecureServerSocketAsServerSocket` **não** usa `cancelOnError` e acrescenta, como defesa em profundidade, `handleError` que descarta `TlsException` (inclui `HandshakeException`) e `SocketException` do caminho de aceitação, contando-os e logando só o tipo (VAZ-01: nada do peer) — para o listener não depender de um detalhe do SDK; (2) teste unitário "rajada de handshakes falhos não derruba o listener" na Task 4; (3) asserção **E8** no Grupo E (Task 8): 20 sondas negativas seguidas e, em seguida, a positiva ainda recebe 200; (4) mutação (d) na Task 8: trocar o `handleError` por um `map` que relança como `StateError` — o resultado esperado é que E8 e o unitário **continuem verdes** (a camada do SDK protege) e o log do backend ganhe a linha de erro; se E8 ficar vermelho, o SDK mudou e o wrapper é a única proteção, o que é informação a registrar no `PROGRESS.md`. A mutação é medida, não gate. + +## Mapa de arquivos + +- **Fork (repositório externo `hbgit/serverpod`, branch `sinalacs/3.4.13-api-server-adapter`):** + - Create: `packages/serverpod/lib/src/server/api_server_adapter_factory.dart` — `typedef ApiServerAdapterFactory`. + - Modify: `packages/serverpod/lib/src/server/server.dart` (construtor `Server` e `start`), `packages/serverpod/lib/src/server/serverpod.dart` (construtor `Serverpod`, repasse ao `Server`), `packages/serverpod/lib/serverpod.dart` (export). +- **Repositório:** + - Create: `backend/patches/serverpod-3.4.13-api-server-adapter.patch`, `backend/patches/README.md`. + - Modify: `backend/pubspec.yaml` (`dependency_overrides`), `backend/pubspec.lock` (só a entrada `serverpod`), `backend/sinalacs_server/pubspec.yaml` (`relic` como dependência direta), `backend/sinalacs_server/Dockerfile` (copia o `pubspec.yaml` real). + - Create: `backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart`; Test: `backend/sinalacs_server/test/unit/mtls_api_server_test.dart`. + - Modify: `backend/sinalacs_server/lib/src/config/api_tls_config.dart`; Test: `backend/sinalacs_server/test/unit/api_tls_config_test.dart`. + - Modify: `backend/sinalacs_server/lib/server.dart` (`Serverpod(… apiServerAdapterFactory:)`). + - Modify: `infra/docker/traefik/init.sh` (duas folhas `clientAuth`), `infra/docker/traefik/dynamic/tls.yaml` (`certificates` do `backend-tls`), `docker-compose.yml` (`API_TLS_CLIENT_CA_PATH`, healthcheck com certificado, comentários), `scripts/qa/e2e_stack.sh` (lista de arquivos que o `traefik-init` tem de produzir). + - Modify: `scripts/qa/tls_invariants.sh` (Grupo E), `scripts/qa/dev_key_permissions_test.sh`. + - Modify (docs): `CLAUDE.md`, `backend/CLAUDE.md:39`, `spec/stack.md` (nota ao lado da do broker, `:76`), `docs/auditoria-prd/2026-10-15-lacunas-prd.md:96,107,231`, `PROGRESS.md`, `.claude/skills/validacao-e2e/SKILL.md:17`, `backend/DEPLOY.md` (uma frase: no Render as três variáveis ficam vazias). + +## Review Focus + +Entradas que a issue implica e que nenhum teste cobria; cada uma ganha um teste na tarefa dona: + +1. **Duas das três `API_TLS_*` definidas** (por exemplo, `.env` antigo com cert+key e sem `CLIENT_CA`) → o boot tem de **falhar nomeando as três**, nunca subir em TLS de mão única. → Task 3 (`fromMap`). +2. **Certificado de cliente de outra CA com CN plausível** (`sinalacs-traefik`) → recusado no handshake. → Task 4 (unitário) e Task 8 (E6 na stack). +3. **Folha do Traefik emitida com `extendedKeyUsage=serverAuth`** (reuso descuidado de `emitir_folha`) → o BoringSSL do backend recusa como certificado de cliente e o Traefik devolve 5xx para **todo** app, com uma mensagem que não diz por quê. → Task 6 (`openssl x509 -ext extendedKeyUsage` nas duas folhas) e Task 8 (E7). +4. **`docker compose up -d` com a stack de pé regera as folhas** sem recriar o Traefik → o Traefik segue com a folha antiga em memória; ela continua válida (825 dias, sem revogação), então **não quebra** — mas o `e2e_stack.sh up` com `--force-recreate` tem de continuar encontrando os arquivos novos. → Task 7 (lista de arquivos do `e2e_stack.sh`). +5. **Código de saída do `curl` varia com a versão do OpenSSL** (35 ou 56 na recusa TLS) → as sondas afirmam "nenhuma linha HTTP e nenhum `"status"`", nunca um código fixo. → Task 4 e Task 8. +6. **Upgrade futuro do Dart SDK muda o `_HttpServer`** e o `ServerSocket` embrulhado deixa de bastar → o teste unitário que liga de verdade e sonda com `curl` acusa. → Task 4. + +--- + +### Task 1: Fork pinado do Serverpod com o gancho `apiServerAdapterFactory` + +**Files (fork `hbgit/serverpod`, branch `sinalacs/3.4.13-api-server-adapter`, base tag `3.4.13`):** +- Create: `packages/serverpod/lib/src/server/api_server_adapter_factory.dart` +- Modify: `packages/serverpod/lib/src/server/server.dart:105-125` (construtor) e `:160-185` (`start`) +- Modify: `packages/serverpod/lib/src/server/serverpod.dart:386-425` (campo + construtor) e `:590-601` (construção do `Server`) +- Modify: `packages/serverpod/lib/serverpod.dart` (export do typedef) +- Create (repositório): `backend/patches/serverpod-3.4.13-api-server-adapter.patch`, `backend/patches/README.md` + +**Interfaces:** +- Produces: `typedef ApiServerAdapterFactory = FutureOr Function(InternetAddress address, int port, SecurityContext? securityContext);` (import `dart:async`, `dart:io`, `package:relic/relic.dart`), exportado por `package:serverpod/serverpod.dart`. +- Produces: `Serverpod(args, serializationManager, endpoints, {…, SecurityContextConfig? securityContextConfig, ApiServerAdapterFactory? apiServerAdapterFactory})` — novo parâmetro nomeado, opcional, padrão `null`. +- Comportamento em `Server.start`: `factory == null` → `_app.serve(address: anyIPv6, port: _port, securityContext: _securityContext)` **inalterado**; `factory != null` → `_app.run(() => factory(io.InternetAddress.anyIPv6, _port, _securityContext))`. `_actualPort`, `_relicServer`, log e tratamento de erro continuam os mesmos. + +- [ ] **Step 1: Criar o fork e o branch** (ação externa — só depois do item 1 do Ponto de decisão) + +```bash +gh repo fork serverpod/serverpod --clone=false --org hbgit 2>/dev/null || gh repo fork serverpod/serverpod --clone=false +git clone --depth 50 --branch 3.4.13 https://github.com/hbgit/serverpod.git /tmp/claude-1000/-home-rock-Documents-Dev-APPs-SinalACS/cab90aba-7955-4af2-a40f-8109919254cc/scratchpad/serverpod-fork +cd /tmp/claude-1000/-home-rock-Documents-Dev-APPs-SinalACS/cab90aba-7955-4af2-a40f-8109919254cc/scratchpad/serverpod-fork && git switch -c sinalacs/3.4.13-api-server-adapter +``` + +Confirmar antes de editar: `grep -n 'resolution' packages/serverpod/pubspec.yaml` não acha nada (verificado em 2026-10-27 no raw da tag: o pacote **não** usa `resolution: workspace`, então a dependência `git` + `path: packages/serverpod` resolve). + +- [ ] **Step 2: Escrever o patch** — o typedef no arquivo novo; em `Server`, campo `final ApiServerAdapterFactory? _apiServerAdapterFactory` e parâmetro nomeado `ApiServerAdapterFactory? apiServerAdapterFactory` no construtor; em `start`, o `if` descrito nas Interfaces; em `Serverpod`, campo + parâmetro + repasse na construção do `Server` (`:590`); export no `serverpod.dart`. Doc-comment em inglês (é o idioma do upstream), dizendo que o gancho existe para quem precisa de opções de bind que o `serve` não expõe (por exemplo, `requireClientCertificate`). + +- [ ] **Step 3: Verificar que o fork compila e que o comportamento padrão não mudou** + +Run: `cd packages/serverpod && dart pub get && dart analyze && dart test test/unit 2>&1 | tail -3` (dentro do clone do fork) +Expected: `No issues found!` e a suíte unitária do pacote verde (a mesma contagem que no `3.4.13` sem o patch — medir antes com `git stash`). + +- [ ] **Step 4: Commit no fork, push, anotar o SHA** + +```bash +git add -A && git commit -m "feat(server): optional apiServerAdapterFactory to supply the API server Adapter" +git push -u origin sinalacs/3.4.13-api-server-adapter +git rev-parse HEAD # usado na Task 2 +git format-patch -1 --stdout > /home/rock/Documents/Dev/APPs/SinalACS/backend/patches/serverpod-3.4.13-api-server-adapter.patch +``` + +- [ ] **Step 4b: O mesmo patch contra o `main` (decisão 2: a proposta upstream vai para a linha 4.x)** + +```bash +git fetch origin main && git switch -c upstream/api-server-adapter origin/main +git cherry-pick # Server.start é idêntico no main (medido em 4.0.4/4.1.0-rc.1); conflito só se o construtor mudou +cd packages/serverpod && dart analyze && dart test test/unit 2>&1 | tail -2 +git push -u origin upstream/api-server-adapter +``` + +Expected: `cherry-pick` limpo (ou conflito trivial no construtor de `Serverpod`), análise e unitários verdes. Esse branch é só a proposta; o `dependency_overrides` continua apontando para o `` do branch 3.4.13. + +- [ ] **Step 5: `backend/patches/README.md`** — três parágrafos: por que o fork existe (#90, bloqueio do `relic_io`/`Server.start`), como refazê-lo a partir da tag `3.4.13` com `git am`, e a regra "o `dependency_overrides` aponta para SHA, nunca para branch; ao subir o Serverpod, rebase do patch primeiro e issue upstream linkada". + +- [ ] **Step 6: Commit (repositório)** + +```bash +git add backend/patches +git commit -m "build(serverpod): patch do fork pinado com apiServerAdapterFactory (#90)" +``` + +### Task 2: `dependency_overrides` e Dockerfile que o preserva + +**Files:** +- Modify: `backend/pubspec.yaml`, `backend/pubspec.lock`, `backend/sinalacs_server/pubspec.yaml`, `backend/sinalacs_server/Dockerfile:14-25` + +**Interfaces:** +- Consumes: `` da Task 1. +- Produces: `package:serverpod/serverpod.dart` passa a exportar `ApiServerAdapterFactory`; `package:relic/relic.dart` passa a ser importável de `sinalacs_server` (`IOAdapter`, `Adapter`). + +- [ ] **Step 1: Teste de falha primeiro — o import ainda não existe** + +Criar `backend/sinalacs_server/test/unit/serverpod_fork_test.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:test/test.dart'; + +/// O fork pinado do Serverpod (#90) expõe o gancho que o 3.4.13 do pub.dev não tem. +void main() { + test('Serverpod exporta ApiServerAdapterFactory (fork pinado, #90)', () { + ApiServerAdapterFactory? f; + expect(f, isNull); + }); +} +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/serverpod_fork_test.dart` +Expected: FALHA de compilação, `Undefined class 'ApiServerAdapterFactory'`. + +- [ ] **Step 2: Override no workspace** — em `backend/pubspec.yaml`: + +```yaml +dependency_overrides: + # Fork pinado do Serverpod 3.4.13 com o gancho apiServerAdapterFactory (#90). + # Patch e motivo: backend/patches/README.md. SHA, nunca branch. + serverpod: + git: + url: https://github.com/hbgit/serverpod.git + ref: + path: packages/serverpod +``` + +Em `backend/sinalacs_server/pubspec.yaml`, `dependencies:` ganha `relic: ^1.2.0` (já resolvido como transitiva em 1.2.0; só vira direta para o `IOAdapter` ser importável sem o lint `depend_on_referenced_packages`). + +Run: `cd backend && dart pub get && git diff --stat pubspec.lock` +Expected: só a entrada `serverpod` muda (`source: git`, `resolved-ref: `, `version: "3.4.13"`); `relic` não se move. + +- [ ] **Step 3: Rodar o teste do Step 1** + +Run: `cd backend/sinalacs_server && dart test test/unit/serverpod_fork_test.dart` +Expected: PASS. + +- [ ] **Step 4: Dockerfile preserva o override** — hoje o estágio de build **gera** um `pubspec.yaml` de raiz por `printf` (`Dockerfile:17-24`), que perderia o `dependency_overrides`. Trocar por copiar o real e tirar o membro que não entra na imagem: + +```dockerfile +COPY pubspec.yaml pubspec.lock . +COPY sinalacs_server sinalacs_server +# O workspace real lista também o sinalacs_client, que não entra na imagem; o +# dependency_overrides (fork pinado do Serverpod, #90) tem de entrar, por isso +# o arquivo é copiado em vez de gerado aqui. +RUN sed -i '/^ - sinalacs_client$/d' pubspec.yaml +RUN dart pub get +``` + +A imagem `dart:3.12.2` tem `git 2.47.3` (medido), necessário para a dependência `git`. + +Run: `docker build -f backend/sinalacs_server/Dockerfile backend -t sinalacs/server:local 2>&1 | tail -3` +Expected: build concluído; `docker run --rm --entrypoint sh sinalacs/server:local -c 'true'` sai 0. O estágio de build não monta `~/.gitconfig` nem recebe token: o `dart pub get` clona `https://github.com/hbgit/serverpod.git` **anônimo** (decisão 1b). Repetir sem cache de camadas — `docker build --no-cache …` — para a prova não vir de uma camada antiga; e o job `backend-docker-build` da CI (sem credencial) é a segunda prova, na Task 10. + +- [ ] **Step 4b: O cliente Flutter não é afetado (decisão 1c)** + +Run: `cd backend/sinalacs_client && dart pub get && cd ../.. && for a in acs patient admin; do (cd apps/$a && flutter pub get >/dev/null); done; git diff --stat -- backend/sinalacs_client apps/*/pubspec.lock; grep -n '^ serverpod:' apps/*/pubspec.yaml backend/sinalacs_client/pubspec.yaml` +Expected: `git diff --stat` **vazio** e o `grep` sem resultado — nenhum app nem o cliente gerado dependem de `serverpod`; só de `serverpod_client`/`serverpod_flutter` do pub.dev. (O override mora em `backend/pubspec.yaml` porque o pub só honra `dependency_overrides` na raiz do workspace; isso é regra do pub, não escolha.) + +- [ ] **Step 5: Suíte unitária e análise** + +Run: `cd backend && dart analyze && cd sinalacs_server && dart test test/unit 2>&1 | tail -2` +Expected: `No issues found!`; unitários verdes. + +- [ ] **Step 6: Commit** + +```bash +git add backend/pubspec.yaml backend/pubspec.lock backend/sinalacs_server/pubspec.yaml backend/sinalacs_server/Dockerfile backend/sinalacs_server/test/unit/serverpod_fork_test.dart +git commit -m "build(backend): Serverpod pelo fork pinado; Dockerfile preserva o dependency_overrides (#90)" +``` + +### Task 3: `ApiTlsConfig` com a CA dos certificados de cliente (três variáveis juntas) + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/config/api_tls_config.dart` +- Test: `backend/sinalacs_server/test/unit/api_tls_config_test.dart` + +**Interfaces:** +- Produces: `const ApiTlsConfig({required String certificatePath, required String keyPath, required String clientCaPath})`. +- Produces: `static ApiTlsConfig? fromMap(Map environment)` — lê `API_TLS_CERT_PATH`, `API_TLS_KEY_PATH`, `API_TLS_CLIENT_CA_PATH`; as três ausentes/brancas → `null`; qualquer subconjunto próprio → `StateError` com a mensagem **exata** `API_TLS_CERT_PATH, API_TLS_KEY_PATH e API_TLS_CLIENT_CA_PATH devem vir juntas: o apiServer exige certificado de cliente (#90) e TLS sem essa exigência não é mais uma configuração válida.` (decisão 3a: fail-fast, texto que diz o porquê; a exceção sobe por `run()` e o processo sai ≠ 0 — a mutação (b) da Task 8 prova no container). +- Produces: `SecurityContext toSecurityContext()` — o de hoje mais `..setTrustedCertificates(clientCaPath)..setClientAuthorities(clientCaPath)`; `withTrustedRoots: false` fica (só a CA do RPC assina clientes aceitos). + +- [ ] **Step 1: Testes que falham** — em `api_tls_config_test.dart`, no grupo `fromMap`: `'só duas das três é erro de boot que nomeia as três'` (cert+key sem CA → `StateError` contendo `API_TLS_CERT_PATH`, `API_TLS_KEY_PATH`, `API_TLS_CLIENT_CA_PATH`; repetir para cert+CA e key+CA via loop de três casos), `'as três juntas produzem os caminhos'` (`clientCaPath == '/ca.crt'`). Atualizar os testes existentes `'só o certificado…'` e `'só a chave…'` para esperar os três nomes, e `'as duas juntas produzem os caminhos'` passa a ser o caso de **erro** (duas sem a terceira). No grupo `toSecurityContext`, o `setUpAll` passa `clientCaPath: caPath`. + +Run: `cd backend/sinalacs_server && dart test test/unit/api_tls_config_test.dart` +Expected: FALHA — `No named parameter with the name 'clientCaPath'`. + +- [ ] **Step 2: Implementar** conforme as Interfaces. Reescrever o cabeçalho do arquivo: o parágrafo "É TLS de mão única, de propósito (decisão de 2026-10-16)…" sai e entra: desde a #90 o apiServer **exige** certificado de cliente assinado pela CA de `API_TLS_CLIENT_CA_PATH`; o bind com `requireClientCertificate` está em `lib/src/runtime/mtls_api_server.dart` (Task 4) porque o `HttpServer.bindSecure` do SDK e o `relic_io` não expõem o flag — o Serverpod é consumido por fork pinado (`backend/patches/README.md`). + +- [ ] **Step 3: Rodar** + +Run: `cd backend/sinalacs_server && dart test test/unit/api_tls_config_test.dart` +Expected: PASS (todos; o `'servidor com o contexto aceita TLS 1.3 e recusa TLS 1.2'` continua verde — ele usa `bindSecure` sem pedir certificado, e `setClientAuthorities` sem o flag não muda o handshake). + +- [ ] **Step 4: Commit** + +```bash +git add backend/sinalacs_server/lib/src/config/api_tls_config.dart backend/sinalacs_server/test/unit/api_tls_config_test.dart +git commit -m "feat(tls): API_TLS_CLIENT_CA_PATH obrigatória junto com cert e chave; contexto confia só na CA do RPC (#90)" +``` + +### Task 4: Bind do apiServer exigindo certificado de cliente + +**Files:** +- Create: `backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart` +- Test: `backend/sinalacs_server/test/unit/mtls_api_server_test.dart` + +**Interfaces:** +- Produces: `Future bindRequiringClientCertificate(InternetAddress address, int port, SecurityContext context)` — `SecureServerSocket.bind(address, port, context, requireClientCertificate: true)` embrulhado por `SecureServerSocketAsServerSocket` e entregue a `HttpServer.listenOn`. +- Produces: `class SecureServerSocketAsServerSocket extends StreamView implements ServerSocket` — `address`, `port` e `close()` delegados ao `SecureServerSocket` (o `_HttpServer` só usa esses quatro membros, medido no SDK 3.12.2 em `_http/http_impl.dart:3510`). O stream passado ao `super` é `inner.handleError(_registrarHandshakeFalho, test: (e) => e is TlsException || e is SocketException)`: um handshake falho **nunca** chega ao `HttpServer` como erro, não há `cancelOnError`, e `_registrarHandshakeFalho` incrementa `handshakesRecusados` (campo `int`, exposto para o teste) e loga só o tipo do erro (`[tls] handshake recusado: HandshakeException`), nunca endereço ou bytes do peer (VAZ-01). Construtor: `SecureServerSocketAsServerSocket(SecureServerSocket inner, {void Function(String) log = print})`. +- Produces: `Future mtlsApiServerAdapter(InternetAddress address, int port, SecurityContext? securityContext)` — a `ApiServerAdapterFactory` da Task 1; `securityContext == null` → `StateError('mtlsApiServerAdapter exige SecurityContext: sem API_TLS_* o apiServer sobe em HTTP pelo caminho padrão do Serverpod.')` (falha fechada); senão `IOAdapter(await bindRequiringClientCertificate(...))`. + +- [ ] **Step 1: Testes que falham** — `mtls_api_server_test.dart` gera, no `setUpAll`, a mesma CA + folha de servidor que `api_tls_config_test.dart` (`openssl` em `Directory.systemTemp`) **mais** uma folha de cliente (`cliente.ext` = `extendedKeyUsage=clientAuth`, CN `traefik-de-teste`) **e** uma segunda CA com uma folha de cliente própria (`fora.crt`). O servidor de teste responde `{"status":"ok"}` a qualquer pedido. Sondas com `Process.run('curl', […])` (curl está no host e no runner), sempre `--tlsv1.3 --cacert ca.crt -sS --max-time 5 -w '\nHTTP %{http_code}'`: + +```dart +test('sem certificado de cliente: nenhuma resposta HTTP', () async { + final r = await curl([]); // sem --cert/--key + expect(r.exitCode, isNot(0)); + expect(r.stdout, isNot(contains('"status"'))); + expect(r.stdout, contains('HTTP 000')); +}); +test('certificado assinado pela CA: 200 e corpo', () async { + final r = await curl(['--cert', cliCrt, '--key', cliKey]); + expect(r.exitCode, 0); + expect(r.stdout, allOf(contains('"status"'), contains('HTTP 200'))); +}); +test('certificado de outra CA: nenhuma resposta HTTP', () async { + final r = await curl(['--cert', foraCrt, '--key', foraKey]); + expect(r.exitCode, isNot(0)); + expect(r.stdout, isNot(contains('"status"'))); +}); +test('TLS 1.2 com certificado válido continua recusado', () async { + final r = await curl(['--tlsv1.2', '--tls-max', '1.2', '--cert', cliCrt, '--key', cliKey]); + expect(r.exitCode, isNot(0)); +}); +test('rajada de handshakes falhos não derruba o listener (DoS)', () async { + for (var i = 0; i < 20; i++) { + await curl([]); // sem certificado + await curl(['--cert', foraCrt, '--key', foraKey]); // outra CA + } + // conexão TCP que manda lixo e fecha no meio do handshake + final s = await Socket.connect(InternetAddress.loopbackIPv4, port); + s.add([0x16, 0x03, 0x01, 0x00, 0x05, 0x6c, 0x69, 0x78, 0x6f]); + await s.flush(); await s.close(); + final r = await curl(['--cert', cliCrt, '--key', cliKey]); + expect(r.stdout, allOf(contains('"status"'), contains('HTTP 200'))); + expect(wrapper.handshakesRecusados, greaterThanOrEqualTo(40)); + expect(logLines, everyElement(isNot(contains('127.0.0.1')))); +}); +test('mtlsApiServerAdapter sem SecurityContext falha fechado', () { + expect(() => mtlsApiServerAdapter(InternetAddress.loopbackIPv4, 0, null), + throwsA(isA())); +}); +test('mtlsApiServerAdapter devolve um Adapter na porta ligada', () async { + final a = await mtlsApiServerAdapter(InternetAddress.loopbackIPv4, 0, ctx); + expect(a.port, greaterThan(0)); + await a.close(); +}); +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/mtls_api_server_test.dart` +Expected: FALHA — `Target of URI doesn't exist: 'package:sinalacs_server/src/runtime/mtls_api_server.dart'`. + +- [ ] **Step 2: Implementar** conforme as Interfaces. Cabeçalho do arquivo (em português) com as três razões de o arquivo existir: o `HttpServer.bindSecure` do SDK não repassa `requireClientCertificate`; `SecureServerSocket` não implementa `ServerSocket`, por isso o embrulho; o `relic_io`/Serverpod ligam o socket sozinhos, por isso o gancho do fork. Citar a medida da PoC: `s_client` imprime `CONNECTION ESTABLISHED` mesmo recusado, então nenhuma sonda deste trecho pode afirmar recusa pelo texto do `s_client`. + +- [ ] **Step 3: Rodar** + +Run: `cd backend/sinalacs_server && dart test test/unit/mtls_api_server_test.dart` +Expected: PASS (7). Para o caso de DoS o teste constrói o wrapper diretamente (`bindRequiringClientCertificate` recebe um `log` opcional repassado ao wrapper e expõe o wrapper via um segundo retorno ou um `@visibleForTesting`) — decidir a forma mais simples que deixe `handshakesRecusados` e as linhas de log observáveis. + +- [ ] **Step 4: Commit** + +```bash +git add backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart backend/sinalacs_server/test/unit/mtls_api_server_test.dart +git commit -m "feat(tls): apiServer liga com requireClientCertificate pelo gancho do fork (#90)" +``` + +### Task 5: Ligar o gancho no `server.dart` + +**Files:** +- Modify: `backend/sinalacs_server/lib/server.dart:20-35` + +**Interfaces:** +- Consumes: `ApiTlsConfig.fromMap` (Task 3), `mtlsApiServerAdapter` (Task 4), `Serverpod(… apiServerAdapterFactory:)` (Task 1). + +- [ ] **Step 1: Teste que falha** — `test/unit/server_wiring_test.dart` lê o texto de `lib/server.dart` (mesmo estilo de `endpoint_auth_posture_test.dart`) e exige que a construção do `Serverpod` contenha `apiServerAdapterFactory: apiTls == null ? null : mtlsApiServerAdapter` **e** `SecurityContextConfig(apiServer: apiTls.toSecurityContext())` no mesmo bloco — para o TLS do apiServer e o `require` do certificado não poderem ser ligados separadamente. + +Run: `cd backend/sinalacs_server && dart test test/unit/server_wiring_test.dart` +Expected: FALHA. + +- [ ] **Step 2: Implementar** — em `run()`: + +```dart +final pod = Serverpod( + args, Protocol(), Endpoints(), + securityContextConfig: apiTls == null + ? null + : SecurityContextConfig(apiServer: apiTls.toSecurityContext()), + // #90: com API_TLS_* o apiServer é ligado por nós, exigindo certificado de + // cliente assinado pela CA de API_TLS_CLIENT_CA_PATH (mtls_api_server.dart). + apiServerAdapterFactory: apiTls == null ? null : mtlsApiServerAdapter, +); +``` + +Atualizar o comentário acima (`:21-24`): "TLS 1.3 **mútuo** no apiServer quando API_TLS_* está definido (RNF04, #78 e #90)". + +- [ ] **Step 3: Rodar unitários + análise** + +Run: `cd backend && dart analyze && cd sinalacs_server && dart test test/unit 2>&1 | tail -2` +Expected: verde. + +- [ ] **Step 4: Commit** + +```bash +git add backend/sinalacs_server/lib/server.dart backend/sinalacs_server/test/unit/server_wiring_test.dart +git commit -m "feat(tls): Serverpod sobe o apiServer pelo adapter com mTLS quando API_TLS_* existe (#90)" +``` + +## Checkpoint de revisão (depois da Task 5, antes da Task 6) + +O núcleo de segurança está pronto e ainda não há Traefik, Compose nem teste de stack mexidos. Antes de seguir: + +- [ ] Rodar `superpowers:requesting-code-review` com um revisor **independente** (subagente novo, sem este contexto) sobre: o patch do fork (`backend/patches/*.patch`), `api_tls_config.dart`, `mtls_api_server.dart`, `server.dart` e os testes das Tasks 2–5. Pedir especificamente: (1) o wrapper `ServerSocket` cobre tudo que o `_HttpServer` usa e não engole erro que **deveria** derrubar o boot (porta ocupada, arquivo de certificado ilegível — esses acontecem no `bind`, antes do stream, e têm de continuar propagando); (2) nenhum caminho sobe em TLS de mão única; (3) o gancho do fork não muda o comportamento padrão; (4) nada do peer vai para log. +- [ ] Tratar os achados pelo `superpowers:receiving-code-review` (verificar antes de aceitar). Só então a Task 6. + +### Task 6: `traefik-init` emite as folhas de cliente (Traefik e healthcheck) + +**Files:** +- Modify: `infra/docker/traefik/init.sh:150-215` (`emitir_folha` e as chamadas), `:216-230` (permissões) +- Modify: `scripts/qa/dev_key_permissions_test.sh` + +**Interfaces:** +- Produces: `emitir_folha ` — `eku` ∈ {`serverAuth`, `clientAuth`}; `dias` é a validade (`server_days=825` para as duas folhas de servidor e para a do Traefik; `healthcheck_days=90` para a do healthcheck — decisão 4: validade curta, regerada a cada `traefik-init`); `san` vazio omite a linha `subjectAltName` do `.ext` (o OpenSSL recusa SAN vazio); `keyUsage=digitalSignature,keyEncipherment` para servidor e `digitalSignature` para cliente. +- Produces (arquivos): `runtime/certs/traefik-client.crt|key` (CN `sinalacs-traefik`, `clientAuth`, sem SAN; chave **600**, lida só pelo Traefik, que roda como root — medido `uid=0`) e `runtime/backend/healthcheck.crt|key` (CN `sinalacs-healthcheck`, `clientAuth`, 90 dias; chave **644**, lida pelo `curl` do healthcheck como uid 1000). As duas folhas de cliente são **distintas** (chave, CN e validade próprias): a do healthcheck pode ser regerada ou removida sem tocar na do Traefik. A folha `backend` continua `serverAuth` com o SAN de hoje; `server` idem. + +- [ ] **Step 1: Teste que falha** — `dev_key_permissions_test.sh` ganha: `runtime/certs/traefik-client.key` em `600`; `runtime/backend/healthcheck.key` em `644`; `openssl x509 -in -noout -ext extendedKeyUsage` contém `TLS Web Client Authentication` para as duas folhas de cliente e **não** contém para `backend.crt` (Review Focus 3). Mensagens de erro no padrão do arquivo. + +Run: `./scripts/qa/dev_key_permissions_test.sh` +Expected: FALHA — `traefik-client.key ausente…`. + +- [ ] **Step 2: Implementar no `init.sh`** — generalizar `emitir_folha` (5º parâmetro), chamar quatro vezes (`server`, `backend`, `traefik-client`, `healthcheck`), `chmod 600 "$certs_dir/traefik-client.key"`, `chmod 644 "$backend_dir/healthcheck.crt" "$backend_dir/healthcheck.key"`, e imprimir ao fim `openssl x509 … -ext extendedKeyUsage` das duas folhas de cliente. Atualizar o cabeçalho ("SÃO QUATRO folhas desde a #90…") e o comentário de permissões. + +- [ ] **Step 3: Reaplicar o init com a stack de pé, sem apagar a CA** + +Run: `docker compose up -d --force-recreate traefik-init && ls -l infra/docker/traefik/runtime/certs/traefik-client.* infra/docker/traefik/runtime/backend/healthcheck.* && ./scripts/qa/dev_key_permissions_test.sh` +Expected: os quatro arquivos existem com os modos acima; `OK — …`. A linha `CA de desenvolvimento do RPC reaproveitada` aparece no log do `traefik-init` (a CA **não** foi regerada: `sync_dev_ca.sh` não precisa rodar). + +- [ ] **Step 4: Commit** + +```bash +git add infra/docker/traefik/init.sh scripts/qa/dev_key_permissions_test.sh +git commit -m "feat(traefik-init): folhas clientAuth para o Traefik e para o healthcheck, assinadas pela CA do RPC (#90)" +``` + +### Task 7: Traefik apresenta o certificado; Compose exige a CA e o healthcheck se autentica + +**Files:** +- Modify: `infra/docker/traefik/dynamic/tls.yaml` (`http.serversTransports.backend-tls`) +- Modify: `docker-compose.yml` (serviço `serverpod`: `environment`, `healthcheck`, comentários; serviço `traefik`: nada muda nos mounts — `runtime/certs` já está em `/certs`) +- Modify: `scripts/qa/e2e_stack.sh:45-50` (lista de arquivos exigidos depois do `traefik-init`) + +**Interfaces:** +- Produces (`tls.yaml`): + +```yaml + backend-tls: + serverName: sinalacs-serverpod + rootCAs: + - /certs/ca.crt + # #90: o backend exige certificado de cliente; este é o do Traefik, + # emitido pelo traefik-init com a CA do RPC (clientAuth). + certificates: + - certFile: /certs/traefik-client.crt + keyFile: /certs/traefik-client.key +``` + +- Produces (`docker-compose.yml`, `serverpod`): `API_TLS_CLIENT_CA_PATH: /app/rpc-certs/ca.crt`; healthcheck `curl -fsS … --cacert /app/rpc-certs/ca.crt --cert /app/rpc-certs/healthcheck.crt --key /app/rpc-certs/healthcheck.key https://localhost:8080/health/check`. + +- [ ] **Step 1: Ordem que prova cada metade.** Primeiro só o backend (Step 2): a stack tem de **quebrar** — Traefik sem certificado devolve 5xx em `https://localhost/health/check` e o container fica `unhealthy`. É a prova de que o `require` vale contra o Traefik real. Só então o Traefik e o healthcheck (Step 3) consertam. + +- [ ] **Step 2: Backend exige** — `API_TLS_CLIENT_CA_PATH` no `environment`, e recriar o backend com a imagem da Task 2: + +Run: `docker compose up -d --build serverpod && sleep 25 && curl -sS --cacert infra/docker/traefik/runtime/certs/ca.crt -o /dev/null -w '%{http_code}\n' https://localhost/health/check; docker inspect -f '{{.State.Health.Status}}' sinalacs-serverpod` +Expected: `502` ou `500` (o Traefik registra `remote error: tls: certificate required` ou `unexpected EOF` em `docker compose logs traefik`) e `unhealthy`/`starting`. **Esse vermelho é o esperado neste passo.** Também: `docker compose logs --tail 5 serverpod` não mostra erro de boot. + +- [ ] **Step 3: Traefik apresenta e o healthcheck se autentica** — editar `tls.yaml` e o `healthcheck` do Compose; reescrever os comentários do `docker-compose.yml` que hoje dizem "TLS 1.3 de mão única"/"não exige certificado" (bloco `API_TLS_*`, label `server.port`, healthcheck) e o comentário do `tls.yaml` sobre o Grupo E. + +Run: `docker compose up -d --force-recreate traefik serverpod && for _ in $(seq 1 30); do [[ "$(docker inspect -f '{{.State.Health.Status}}' sinalacs-serverpod)" == healthy ]] && break; sleep 2; done; docker inspect -f '{{.State.Health.Status}}' sinalacs-serverpod; curl -sS --cacert infra/docker/traefik/runtime/certs/ca.crt https://localhost/health/check` +Expected: `healthy` e o corpo `{"__className__":"ServiceHealth","status":"ok",…}` — **agora** só possível porque o Traefik apresentou `traefik-client.crt`. + +- [ ] **Step 4: O comando-evidência do diagnóstico agora é recusado** + +Run: o bloco `docker run … curlimages/curl …` da seção "Diagnóstico e reprodução". +Expected: nenhuma linha JSON; `HTTP 000`; `curl: (35|56) …`. + +- [ ] **Step 5: `e2e_stack.sh`** — acrescentar `healthcheck.crt`, `healthcheck.key` (em `runtime/backend/`) e `traefik-client.crt`, `traefik-client.key` (em `runtime/certs/`) ao laço que confere o que o `traefik-init` produziu (`:45-50`), com a mensagem `falta $f depois do traefik-init`. + +Run: `bash -n scripts/qa/e2e_stack.sh && grep -c 'healthcheck\.\|traefik-client\.' scripts/qa/e2e_stack.sh` +Expected: sintaxe ok; `4` ocorrências (ou 2 linhas com os pares). + +- [ ] **Step 6: Commit** + +```bash +git add infra/docker/traefik/dynamic/tls.yaml docker-compose.yml scripts/qa/e2e_stack.sh +git commit -m "feat(traefik): apresenta certificado de cliente ao backend; healthcheck autenticado; backend exige a CA do RPC (#90)" +``` + +### Task 8: Grupo E do `tls_invariants.sh` — negativo, positivo e as duas estáticas + +**Files:** +- Modify: `scripts/qa/tls_invariants.sh:124-185` (Grupo E) + +**Interfaces:** +- As sondas E1–E3, E5 e E6 passam a rodar de **fora** do container do backend — de um container efêmero na rede do Compose, que é o processo do modelo de ameaça da issue — com a imagem que a stack já construiu (`sinalacs/server:local` traz o mesmo `curl 8.22` do healthcheck; `--entrypoint curl`, roda como uid 1000). Helper: + +```bash +rede="$(docker inspect "$backend_container" -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')" +# sonda_8080 — monta runtime/backend em /c (CA + folha do healthcheck) e $tmp em /f +sonda_8080() { + # --user com o uid do host: no runner do CI o usuário é 1001 e o $tmp (mktemp, + # 700) não seria legível pelo uid 1000 da imagem; os arquivos de runtime/backend + # são 644 e continuam legíveis. chmod 644 nos arquivos de $tmp, como o + # mtls_invariants.sh já faz. + docker run --rm --network "$rede" --entrypoint curl --user "$(id -u):$(id -g)" \ + -v "$PWD/infra/docker/traefik/runtime/backend:/c:ro" -v "$tmp:/f:ro" \ + sinalacs/server:local -sS -v --max-time 5 -X POST -H 'Content-Type: application/json' -d '{}' \ + -w '\nHTTP %{http_code}\n' "$@" 2>&1 +} +``` + +- Asserções (cada `ok`/`falha` no padrão do arquivo): + - **E1** texto claro: `sonda_8080 http://sinalacs-serverpod:8080/health/check` sem `"status"`. + - **E2** (controle positivo, **com** certificado): `--tlsv1.3 --cacert /c/ca.crt --cert /c/healthcheck.crt --key /c/healthcheck.key https://sinalacs-serverpod:8080/health/check` → contém `"status"` **e** `TLSv1.3` **e** `HTTP 200`. Sem E2 verde, E5 e E6 são declarados `falha` com a mensagem "sem controle positivo, a recusa seria verdade por acidente". + - **E3** TLS 1.2 **com** certificado → sem `"status"` (hoje E3 não apresenta certificado; depois da #90 seria recusado por dois motivos e o teste não distinguiria). + - **E5 (novo, o caso negativo da issue):** mesma linha de E2 **sem** `--cert/--key` → saída **sem** `"status"`, **sem** nenhuma linha que comece por `< HTTP/` e **com** `HTTP 000`. Mensagem de falha: "a 8080 interna atendeu um cliente sem certificado — o `requireClientCertificate` do `mtls_api_server.dart` ou a `API_TLS_CLIENT_CA_PATH` deixaram de valer". + - **E6 (novo):** certificado de **outra** CA (gerado em `$tmp` com `openssl`, como `mtls_invariants.sh:28-35` faz, CN `sinalacs-traefik` de propósito) → mesma asserção de E5. Mensagem: "a 8080 interna aceitou certificado de outra CA — o contexto confia em mais do que a CA do RPC". + - **E4** (estática, estendida): além do que já cobra, `infra/docker/traefik/dynamic/tls.yaml` tem `certFile: /certs/traefik-client.crt` e `keyFile: /certs/traefik-client.key` **dentro** do bloco `backend-tls` (`awk '/backend-tls:/,/^[^ ]/'`), e `docker-compose.yml` tem `API_TLS_CLIENT_CA_PATH` no serviço `serverpod`. + - **E7 (novo, nos arquivos gerados):** `openssl x509 -in infra/docker/traefik/runtime/certs/traefik-client.crt -noout -ext extendedKeyUsage` contém `TLS Web Client Authentication`, e `openssl verify -CAfile infra/docker/traefik/runtime/certs/ca.crt …/traefik-client.crt` sai 0 — a folha que o Traefik apresenta é um certificado de cliente **desta** CA. + - **E8 (novo, DoS):** 20 sondas negativas seguidas (10 sem certificado, 10 com o de outra CA) e, **depois**, a sonda positiva de E2 ainda devolve `"status"` + `HTTP 200`, e `docker inspect -f '{{.RestartCount}}' sinalacs-serverpod` continua `0`. Mensagem de falha: "handshakes recusados derrubaram o listener da 8080 — o `handleError` do `SecureServerSocketAsServerSocket` ou o tratamento do SDK deixaram de valer". + - **E9 (novo, validade da folha do healthcheck):** `openssl x509 -in infra/docker/traefik/runtime/backend/healthcheck.crt -noout -checkend 604800` sai 0 (mais de 7 dias de validade) e a folha difere da do Traefik (`openssl x509 -noout -serial` diferentes). Mensagem de falha: "a folha do healthcheck vence em menos de 7 dias: `docker compose up -d --force-recreate traefik-init` a regera; sem isso o backend fica unhealthy sem dizer por quê". + - Comentário do grupo: o Grupo A é o controle positivo **do Traefik** (o 200 em `https://localhost/health/check` só existe porque o Traefik apresentou a folha); a seção da PoC sobre o `s_client` e o TLS 1.3 explica por que as negativas usam `curl` e "nenhuma linha HTTP". + +- [ ] **Step 1: Escrever o Grupo E novo e rodar contra a stack da Task 7** (os testes nascem depois da implementação da stack porque medem a stack; mas a ordem de escrita do script é antes de considerar a Task 7 "pronta": a Task 7 só fecha com este grupo verde). + +Run: `./scripts/qa/tls_invariants.sh` +Expected: `OK — as propriedades do RNF04 conferem.` com E1–E9 `ok` (grupos A–D e F inalterados e verdes). + +- [ ] **Step 2: Mutações (cada uma restaurada antes da seguinte)** — (a) comentar `certificates:` do `backend-tls` e `docker compose restart traefik` → Grupo A falha (5xx) e E4 acusa; (b) `API_TLS_CLIENT_CA_PATH` removido do Compose e `up -d serverpod` → o boot falha com a mensagem exata da Task 3 em `docker compose logs serverpod`, o container reinicia (`restart: unless-stopped`) e o Grupo A acusa `não está healthy` — é a prova do fail-fast da decisão 3a; (c) trocar `requireClientCertificate: true` por `false` em `mtls_api_server.dart`, rebuild → **E5 acusa** (é o teste do defeito desta issue; registrar a saída no `PROGRESS.md`); (d) trocar o `handleError` do wrapper por `.map((s) => s)` seguido de um `transform` que relança todo erro como `StateError` → rebuild, rodar E8 e o unitário de DoS: resultado **esperado** é continuarem verdes (a camada do SDK ignora `HandshakeException`) com uma linha `Asynchronous error` do relic no log para o caso do TCP abortado; qualquer vermelho aqui significa que o wrapper é a única proteção — registrar o que se mediu, nos dois casos, no `PROGRESS.md`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/qa/tls_invariants.sh +git commit -m "test(tls): Grupo E sonda a 8080 de fora da rede: sem certificado e de outra CA recusados, com certificado 200 (#90)" +``` + +### Task 9: Regressão — suíte do backend, `e2e.sh` e emulador + +**Files:** nenhum novo; registro em `PROGRESS.md` (Task 10). + +- [ ] **Step 1: Backend inteiro** + +Run: `docker compose --profile test up -d postgres-test && cd backend/sinalacs_server && dart test -j 1 2>&1 | tail -3` +Expected: todos verdes (a contagem sobe pelos testes das Tasks 2–5; anotar o número). + +- [ ] **Step 2: Bateria sem aparelho** (sobe a stack do zero, roda `tls_invariants.sh` e os dois `live_check.dart` via Traefik) + +Run: `./scripts/qa/e2e.sh --keep 2>&1 | tail -15 && ./scripts/qa/mtls_invariants.sh && ./scripts/qa/dev_key_permissions_test.sh` +Expected: `OK — as propriedades do RNF04 conferem.`, os dois `live_check` ok, `OK — o broker exige certificado…`, `OK — ca.key em 600…`. + +- [ ] **Step 3: Emulador — login e RPC real pelo Traefik** (o critério da issue). Subir o `sinalacs_ci36` (binário em `~/Android/Sdk/emulator/emulator`, **com janela**, conforme `PROGRESS.md`), esperar `adb devices` listar `emulator-5554`. + +Run: `./scripts/qa/e2e.sh --emulator --keep 2>&1 | tail -20` +Expected: `smoke_test.dart` do paciente (health → login OTP → triagem → alerta, tudo por `https://localhost:8443` → Traefik → backend com mTLS) e do ACS (login institucional → alerta MQTT + ACK → `visits.sync`) verdes. É o login e **várias** chamadas RPC reais passando pelo Traefik. + +- [ ] **Step 4: Jornada completa do ACS (opcional, recomendado)** + +Run: `./scripts/qa/acs_full_e2e.sh 2>&1 | tail -10` +Expected: verde; se o `flutter test` travar no teardown, `adb kill-server` (nota do `PROGRESS.md` da #88). + +- [ ] **Step 5: Voltar à stack de desenvolvimento** + +Run: `docker compose up -d && ./scripts/qa/tls_invariants.sh | tail -1` +Expected: `OK — …`. + +### Task 10: Documentação, auditoria do PRD, issue upstream e PR + +**Files:** +- Modify: `CLAUDE.md` (parágrafo "Already delivered…": "on the Traefik → backend hop (one-way TLS 1.3 … no mTLS, Serverpod cannot request a client certificate — #78)" → "mutual TLS 1.3 … the backend requires a client certificate signed by the RPC dev CA, Serverpod consumed through a pinned fork — #90"; e no "Still missing" tirar o mTLS interno), `backend/CLAUDE.md:39` (texto de `API_TLS_*`: três variáveis, fail-fast, fork, `mtls_api_server.dart`), `spec/stack.md` (**nota-ADR** `> **Traefik → backend com mTLS e fork pinado do Serverpod (2026-10-xx, #90):** …` ao lado da do broker em `:76`, com as três partes exigidas pela decisão 1a: **motivo** — `Server.start` fixo em `_app.serve` no 3.4.13, 4.0.4 e 4.1.0-rc.1, `relic_io` sem `requestClientCertificate`, `HttpServer.bindSecure` sem `requireClientCertificate`, e `requestClientCertificate` sozinho não recusa no handshake; **critério de saída** — remover o `dependency_overrides` e voltar ao pub.dev quando uma versão oficial expuser o gancho ou recusar no handshake por configuração, com o Grupo E como prova de equivalência; **custo de upgrade** — todo upgrade do Serverpod, em especial para 4.x, começa pelo rebase do patch no fork e pela atualização do SHA; reverte explicitamente o "sem fork" da #78), `docs/auditoria-prd/2026-10-15-lacunas-prd.md` (`:96` CRIPTO-TRAEFIK-BACKEND → **entregue** na stack local, evidência Grupo E; `:107` RNF04 segue **parcial** só por Postgres/Gorush/8082/8083/8081; `:231` item 4 atualizado), `PROGRESS.md` (entrada `#90` depois da `#78` em `:575`, com as provas, as quatro mutações da Task 8, o inventário da 8080 e os abertos: produção — no Render o TLS termina na borda e nada disto se aplica; rebase do fork a cada upgrade; rede interna como defesa em profundidade; o backend não loga o CN do certificado de cliente; Traefik roda como root e lê `ca.key` do mesmo mount, pré-existente), `.claude/skills/validacao-e2e/SKILL.md:17` ("the four groups" → os seis grupos, E com mTLS), `backend/DEPLOY.md` (uma frase: as três `API_TLS_*` ficam vazias no Render). + +- [ ] **Step 1: Editar os seis documentos.** Depois: `grep -rn -i 'mão única\|requestClientCertificate' --include='*.md' . | grep -v docs/superpowers/plans` só devolve menções históricas (a entrada da #78 no `PROGRESS.md` e a tabela de lacunas, agora com "superado pela #90"). + +Run: `python3 -I scripts/qa/cobertura_prd.py --final && ./scripts/qa/check_documentation_links.sh` +Expected: `ok` nos dois (a linha CRIPTO-TRAEFIK-BACKEND com status em negrito e evidência na coluna certa — ver `PROGRESS.md:1738` para o formato que o script exige). + +- [ ] **Step 2: Issue upstream** (decisão 2, aprovada). Título: `Allow supplying the API server's relic Adapter (needed for requireClientCertificate / mTLS)`. Corpo, em inglês: contexto (reverse proxy que precisa autenticar-se com certificado de cliente; `Server.start` chama `_app.serve` fixo — citar `main` e 3.4.13; `relic_io.bindHttpServer` não expõe `requestClientCertificate`; `HttpServer.bindSecure` do SDK não expõe `requireClientCertificate`); a **evidência** de que `requestClientCertificate: true` sozinho **não** recusa no handshake — colar a medida da PoC: handshake completo, `request.certificate == null`, recusa só na aplicação (401), enquanto `SecureServerSocket.bind(requireClientCertificate: true)` + `HttpServer.listenOn` recusa antes de qualquer byte HTTP — e dizer explicitamente que por isso uma flag `requestClientCertificate` em `SecurityContextConfig` **não** resolveria o caso; a proposta (link para o branch `upstream/api-server-adapter` contra o `main`: `apiServerAdapterFactory`, ~15 linhas, sem mudar o padrão), e a alternativa (`requireClientCertificate` plumbado por `relic_io` até o `SecureServerSocket`). Oferecer o PR a partir do branch; abri-lo como draft **só** se os mantenedores responderem que aceitam a direção (uma ação externa a mais; perguntar ao dono antes). + +```bash +gh issue create --repo serverpod/serverpod --title '…' --body-file /tmp/claude-1000/-home-rock-Documents-Dev-APPs-SinalACS/cab90aba-7955-4af2-a40f-8109919254cc/scratchpad/upstream-issue.md +gh issue comment 90 --body 'Issue upstream: . Fork pinado: hbgit/serverpod@ (backend/patches/README.md).' +``` + +Pôr o link no `PROGRESS.md` e em `backend/patches/README.md`. + +- [ ] **Step 3: Commit da documentação** + +```bash +git add CLAUDE.md backend/CLAUDE.md spec/stack.md docs/auditoria-prd/2026-10-15-lacunas-prd.md PROGRESS.md .claude/skills/validacao-e2e/SKILL.md backend/DEPLOY.md backend/patches/README.md +git commit -m "docs: mTLS Traefik ↔ backend entregue na stack local; fork pinado do Serverpod e issue upstream registrados (#90)" +``` + +- [ ] **Step 4: PR para `develop`** (branch `feat/issue-90-mtls-traefik-backend`, criado de `develop` antes da Task 1). Corpo: o comando-evidência antes/depois, a tabela de critérios abaixo, as mutações da Task 8, a decisão do fork com link para `backend/patches/README.md` e a issue upstream. Os 8 checks obrigatórios (`./scripts/qa/ci_invariants.sh --checks-obrigatorios`) têm de passar — atenção ao `backend-docker-build` (Dockerfile novo) e ao `serverpod-backend` (`dart pub get` com dependência `git`). + +## Critérios de aceite da issue → tarefa → prova + +| Critério (issue #90) | Tarefa | Prova | +|---|---|---| +| O Traefik apresenta um certificado de cliente assinado pela CA de dev do RPC (`serversTransport.certificates`) | Task 6 (emissão), Task 7 (`tls.yaml`) | E4 (estática: `certFile`/`keyFile` no `backend-tls`), E7 (`extendedKeyUsage` clientAuth + `openssl verify` contra a CA), Grupo A (200 via Traefik só possível com a folha), mutação (a) da Task 8 | +| Sem esse certificado, uma conexão à 8080 interna é recusada no handshake | Task 3, 4, 5 (backend exige), Task 7 (`API_TLS_CLIENT_CA_PATH`) | Unitário `sem certificado de cliente: nenhuma resposta HTTP` (Task 4); E5 na stack (Task 8); comando-evidência do diagnóstico passando a `HTTP 000`; mutação (c) | +| O Grupo E ganha o caso negativo e o positivo | Task 8 | E5/E6 (negativos: sem certificado, outra CA) e E2 (positivo com certificado, TLS 1.3, 200) | +| Não enfraquecer `serverName`/`rootCAs` | Restrição global | E4 continua exigindo `backend-tls@file` sem `insecureSkipVerify`; `serverName`/`rootCAs` intocados no diff do `tls.yaml` | +| Regressão: os apps continuam falando com o backend via Traefik | Task 9 | `e2e.sh --keep` (live_check paciente e ACS), `e2e.sh --emulator` (smoke dos dois apps no `emulator-5554`), `acs_full_e2e.sh` | +| Um cliente sem certificado não derruba o listener (o bloqueio não vira DoS) | Task 4 (wrapper com `handleError`), Task 8 | Unitário `rajada de handshakes falhos não derruba o listener`; E8 na stack; mutação (d) medida | +| Fail-fast: cert + chave sem CA não sobe em mão única | Task 3 | Unitário `só duas das três…` com a mensagem exata; mutação (b) no container | +| Cliente Flutter e `sinalacs_client` intocados pelo fork | Task 2 | `git diff --stat -- backend/sinalacs_client apps/*/pubspec.lock` vazio; `grep '^ serverpod:'` sem resultado | +| Fora do escopo intocado (Postgres, Gorush, 8082/8083, 8081) | Restrição global | `git diff develop -- docker-compose.yml` não toca `postgres`, `gorush`, `SERVERPOD_DATABASE_REQUIRE_SSL`, `8081:8080`; RNF04 segue parcial na auditoria por eles | +| Sem chave privada no git | Restrição global | `git status --porcelain infra/docker/traefik/runtime apps/*/assets/certs` vazio; `dev_key_permissions_test.sh` verde | + +## Checklist final + +- [x] Ponto de decisão respondido pelo dono (itens 1–4, aprovados com condições em 2026-10-27) e registrado no topo deste plano. +- [ ] Checkpoint de revisão independente feito depois da Task 5, achados tratados antes da Task 6. +- [ ] Nota-ADR em `spec/stack.md` com motivo, critério de saída e custo de upgrade do fork. +- [ ] `docker build --no-cache` sem credencial e `backend-docker-build` verdes (fork público). +- [ ] Fork `hbgit/serverpod@` existe, patch versionado em `backend/patches/`, `pubspec.lock` só com a entrada `serverpod` alterada. +- [ ] `dart analyze` limpo; `dart test -j 1` do backend verde (contagem anotada no `PROGRESS.md`). +- [ ] `./scripts/qa/tls_invariants.sh` 100 % `ok` (E1–E9), `mtls_invariants.sh` 4/4, `dev_key_permissions_test.sh` ok. +- [ ] Quatro mutações da Task 8 executadas — (a), (b), (c) acusadas; (d) medida — e saídas no `PROGRESS.md`. +- [ ] `e2e.sh --emulator` verde no `emulator-5554`; `acs_full_e2e.sh` verde (ou motivo registrado). +- [ ] Comando-evidência do diagnóstico: `HTTP 200` antes → `HTTP 000` depois, os dois registrados no `PROGRESS.md`. +- [ ] `cobertura_prd.py --final` e `check_documentation_links.sh` ok; CRIPTO-TRAEFIK-BACKEND **entregue (stack local)**; RNF04 **parcial** com a lista dos trechos fora do escopo. +- [ ] Issue upstream aberta e linkada na #90 (se aprovada); `#90` referenciada no `PROGRESS.md`. +- [ ] PR para `develop` com os 8 checks obrigatórios verdes; nenhuma atribuição de IA. From 7f909ea1a78dbe7775455ef17e975a3b1797dbc2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:03:23 -0400 Subject: [PATCH 02/12] build(serverpod): patch do fork pinado com apiServerAdapterFactory (#90) --- backend/patches/README.md | 50 ++++++ .../serverpod-3.4.13-api-server-adapter.patch | 159 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 backend/patches/README.md create mode 100644 backend/patches/serverpod-3.4.13-api-server-adapter.patch diff --git a/backend/patches/README.md b/backend/patches/README.md new file mode 100644 index 00000000..734871d5 --- /dev/null +++ b/backend/patches/README.md @@ -0,0 +1,50 @@ +# Patches do backend + +## `serverpod-3.4.13-api-server-adapter.patch` — fork pinado do Serverpod (#90) + +**Por quê.** O trecho Traefik → backend exige mTLS (RNF04, issue #90): o +backend só aceita quem apresenta certificado de cliente assinado pela CA do +RPC, recusado no handshake. O Serverpod 3.4.13 sobe o apiServer por +`RelicApp.serve` → `relic_io` → `HttpServer.bindSecure`, sem +`requestClientCertificate`; e mesmo esse flag não bastaria, porque o +`HttpServer.bindSecure` do SDK não repassa `requireClientCertificate` — com +`request` sozinho o handshake completa e a aplicação só vê +`request.certificate == null`. A recusa real exige +`SecureServerSocket.bind(requireClientCertificate: true)` embrulhado para +`HttpServer.listenOn`, e isso só é possível se a aplicação ligar o socket. +`Server.start` é fixo em `_app.serve(...)` no 3.4.13, no 4.0.4 e no +4.1.0-rc.1 (medido em 2026-10-27): não há versão para a qual subir. + +**O patch.** Um parâmetro nomeado opcional, `apiServerAdapterFactory`, em +`Serverpod(...)`, plumbado até `Server.start`: com ele, o servidor chama +`RelicApp.run(() => factory(address, port, securityContext))` em vez de +`serve`; sem ele, nada muda. Nenhuma semântica de mTLS entra no fork — ela +fica em `sinalacs_server/lib/src/runtime/mtls_api_server.dart`. + +**Onde está.** Fork público `hbgit/serverpod`: + +- branch `sinalacs/3.4.13-api-server-adapter` — a tag `3.4.13` + este commit; + é o que `backend/pubspec.yaml` consome por `dependency_overrides`, **pelo + SHA do commit, nunca pelo nome do branch**; +- branch `upstream/api-server-adapter` — o mesmo patch sobre o `main` + (linha 4.x), base da proposta upstream (issue linkada no `PROGRESS.md`). + +**Refazer o fork do zero.** + +```bash +git clone --branch 3.4.13 https://github.com/serverpod/serverpod.git +cd serverpod && git switch -c sinalacs/3.4.13-api-server-adapter +git am /backend/patches/serverpod-3.4.13-api-server-adapter.patch +cd packages/serverpod && dart pub get && dart analyze && dart test test/server test/util test/endpoint +``` + +**Ao subir o Serverpod.** Primeiro rebase do patch no fork (para 4.x a +base é o branch `upstream/api-server-adapter`, que já resolve o conflito de +`_port` → `port`), depois o SHA novo no `dependency_overrides`, depois o +resto. O build Docker busca o fork **anônimo** (repositório público; o +estágio de build não tem credencial) — ver `sinalacs_server/Dockerfile`. + +**Critério de saída.** No dia em que uma versão oficial expuser este gancho +ou recusar no handshake por configuração, remover o `dependency_overrides` +e este diretório; o Grupo E de `scripts/qa/tls_invariants.sh` é a prova de +equivalência. diff --git a/backend/patches/serverpod-3.4.13-api-server-adapter.patch b/backend/patches/serverpod-3.4.13-api-server-adapter.patch new file mode 100644 index 00000000..12d36c7b --- /dev/null +++ b/backend/patches/serverpod-3.4.13-api-server-adapter.patch @@ -0,0 +1,159 @@ +From 2a0b67e38532faff48f8f75b42b9f63e36a1cd82 Mon Sep 17 00:00:00 2001 +From: Herbert Rocha +Date: Tue, 27 Oct 2026 16:56:43 -0400 +Subject: [PATCH] feat(server): optional apiServerAdapterFactory to supply the + API server Adapter + +Serverpod binds the API server through RelicApp.serve, which only reaches the +bind options that HttpServer.bindSecure exposes. A reverse proxy that must +authenticate itself with a TLS client certificate needs +SecureServerSocket.bind(requireClientCertificate: true), which is out of +reach that way. The optional factory lets the application bind the socket +itself and hand the relic Adapter to Serverpod; without it nothing changes. +--- + packages/serverpod/lib/serverpod.dart | 1 + + .../server/api_server_adapter_factory.dart | 23 +++++++++++++++++ + packages/serverpod/lib/src/server/server.dart | 25 +++++++++++++++---- + .../serverpod/lib/src/server/serverpod.dart | 7 ++++++ + 4 files changed, 51 insertions(+), 5 deletions(-) + create mode 100644 packages/serverpod/lib/src/server/api_server_adapter_factory.dart + +diff --git a/packages/serverpod/lib/serverpod.dart b/packages/serverpod/lib/serverpod.dart +index f357b7d..1292db4 100644 +--- a/packages/serverpod/lib/serverpod.dart ++++ b/packages/serverpod/lib/serverpod.dart +@@ -34,5 +34,6 @@ export 'package:serverpod/src/cloud_storage/database_cloud_storage.dart'; + export 'package:serverpod/src/cache/cache_miss_handler.dart'; + + // Experimental features ++export 'src/server/api_server_adapter_factory.dart'; + export 'src/server/experimental_features.dart'; + export 'diagnostic_events.dart'; +diff --git a/packages/serverpod/lib/src/server/api_server_adapter_factory.dart b/packages/serverpod/lib/src/server/api_server_adapter_factory.dart +new file mode 100644 +index 0000000..a8a0376 +--- /dev/null ++++ b/packages/serverpod/lib/src/server/api_server_adapter_factory.dart +@@ -0,0 +1,23 @@ ++import 'dart:async'; ++import 'dart:io'; ++ ++import 'package:relic/relic.dart'; ++ ++/// Creates the relic [Adapter] the API server listens on. ++/// ++/// By default Serverpod binds the API server through `RelicApp.serve`, which ++/// covers HTTP and one-way HTTPS. Some bind options of `dart:io` are not ++/// reachable that way — for instance `SecureServerSocket.bind(…, ++/// requireClientCertificate: true)` for mutual TLS, which `HttpServer.bindSecure` ++/// does not expose. Supplying a factory lets the application bind the socket ++/// itself and hand the resulting [Adapter] to Serverpod; the default behavior ++/// is unchanged when no factory is given. ++/// ++/// [address] and [port] are the ones Serverpod would have bound to, and ++/// [securityContext] is `SecurityContextConfig.apiServer` (null for HTTP). ++typedef ApiServerAdapterFactory = ++ FutureOr Function( ++ InternetAddress address, ++ int port, ++ SecurityContext? securityContext, ++ ); +diff --git a/packages/serverpod/lib/src/server/server.dart b/packages/serverpod/lib/src/server/server.dart +index 9538cfb..124ea1f 100644 +--- a/packages/serverpod/lib/src/server/server.dart ++++ b/packages/serverpod/lib/src/server/server.dart +@@ -76,6 +76,10 @@ class Server implements RouterInjectable { + /// Security context if the server is running over https. + final io.SecurityContext? _securityContext; + ++ /// Optional factory for the relic [Adapter] the server listens on. When ++ /// null, the server binds through `RelicApp.serve`. ++ final ApiServerAdapterFactory? _apiServerAdapterFactory; ++ + /// Responsible for dispatching calls to the correct [Endpoint] methods. + final EndpointDispatch endpoints; + +@@ -115,6 +119,7 @@ class Server implements RouterInjectable { + String? name, + required this.caches, + io.SecurityContext? securityContext, ++ ApiServerAdapterFactory? apiServerAdapterFactory, + this.whitelistedExternalCalls, + required this.endpoints, + required this.httpResponseHeaders, +@@ -122,6 +127,7 @@ class Server implements RouterInjectable { + }) : name = name ?? 'Server $serverId', + _databasePoolManager = databasePoolManager, + _securityContext = securityContext, ++ _apiServerAdapterFactory = apiServerAdapterFactory, + _port = port; + + late final _app = RelicApp()..inject(this); +@@ -166,11 +172,20 @@ class Server implements RouterInjectable { + }) async { + _authenticationHandler = authenticationHandler; + try { +- final server = await _app.serve( +- address: io.InternetAddress.anyIPv6, +- port: _port, +- securityContext: _securityContext, +- ); ++ final adapterFactory = _apiServerAdapterFactory; ++ final server = adapterFactory == null ++ ? await _app.serve( ++ address: io.InternetAddress.anyIPv6, ++ port: _port, ++ securityContext: _securityContext, ++ ) ++ : await _app.run( ++ () => adapterFactory( ++ io.InternetAddress.anyIPv6, ++ _port, ++ _securityContext, ++ ), ++ ); + _actualPort = server.port; + _relicServer = server; + } catch (e, stackTrace) { +diff --git a/packages/serverpod/lib/src/server/serverpod.dart b/packages/serverpod/lib/src/server/serverpod.dart +index c4ed927..927d7a0 100644 +--- a/packages/serverpod/lib/src/server/serverpod.dart ++++ b/packages/serverpod/lib/src/server/serverpod.dart +@@ -385,6 +385,10 @@ class Serverpod { + /// Security context if the insights server is running over https. + final SecurityContextConfig? _securityContextConfig; + ++ /// Optional factory for the relic adapter of the API server; see ++ /// [ApiServerAdapterFactory]. Null keeps the default bind. ++ final ApiServerAdapterFactory? _apiServerAdapterFactory; ++ + /// Runtime parameters builder to apply to all sessions of the connection pool. + /// + /// Use the callback function to discover runtime parameters: +@@ -414,6 +418,7 @@ class Serverpod { + Headers? httpResponseHeaders, + Headers? httpOptionsResponseHeaders, + SecurityContextConfig? securityContextConfig, ++ ApiServerAdapterFactory? apiServerAdapterFactory, + ExperimentalFeatures? experimentalFeatures, + this.runtimeParametersBuilder, + }) : httpResponseHeaders = httpResponseHeaders ?? _defaultHttpResponseHeaders, +@@ -421,6 +426,7 @@ class Serverpod { + httpOptionsResponseHeaders ?? _defaultHttpOptionsResponseHeaders, + _configOverride = configOverride, + _securityContextConfig = securityContextConfig, ++ _apiServerAdapterFactory = apiServerAdapterFactory, + _healthConfig = healthConfig ?? const HealthConfig(), + _experimental = ExperimentalApi._( + config: config, +@@ -598,6 +604,7 @@ class Serverpod { + httpResponseHeaders: httpResponseHeaders, + httpOptionsResponseHeaders: httpOptionsResponseHeaders, + securityContext: _securityContextConfig?.apiServer, ++ apiServerAdapterFactory: _apiServerAdapterFactory, + ); + + endpoints.initializeEndpoints(server); +-- +2.56.0 + From 260bdb1d509ee43cb65a918a1b7a0064684ab01a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:09:10 -0400 Subject: [PATCH 03/12] build(backend): Serverpod pelo fork pinado; Dockerfile preserva o dependency_overrides (#90) --- backend/pubspec.lock | 11 ++++++----- backend/pubspec.yaml | 13 +++++++++++++ backend/sinalacs_server/Dockerfile | 19 ++++++++++--------- backend/sinalacs_server/pubspec.yaml | 3 +++ .../test/unit/serverpod_fork_test.dart | 13 +++++++++++++ 5 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 backend/sinalacs_server/test/unit/serverpod_fork_test.dart diff --git a/backend/pubspec.lock b/backend/pubspec.lock index 3d496d66..6696b473 100644 --- a/backend/pubspec.lock +++ b/backend/pubspec.lock @@ -354,12 +354,13 @@ packages: source: hosted version: "3.1.2" serverpod: - dependency: transitive + dependency: "direct overridden" description: - name: serverpod - sha256: "2b7325b998d55bc185d1dc973dddb122ac2d519306ae2c1c71457578dab85ea4" - url: "https://pub.dev" - source: hosted + path: "packages/serverpod" + ref: "2a0b67e38532faff48f8f75b42b9f63e36a1cd82" + resolved-ref: "2a0b67e38532faff48f8f75b42b9f63e36a1cd82" + url: "https://github.com/hbgit/serverpod.git" + source: git version: "3.4.13" serverpod_auth_core_client: dependency: transitive diff --git a/backend/pubspec.yaml b/backend/pubspec.yaml index e7dbfd3c..c79a3639 100644 --- a/backend/pubspec.yaml +++ b/backend/pubspec.yaml @@ -8,3 +8,16 @@ workspace: - sinalacs_client - sinalacs_server + +dependency_overrides: + # Fork pinado do Serverpod 3.4.13 com o gancho apiServerAdapterFactory (#90): + # o apiServer precisa exigir certificado de cliente (mTLS Traefik → backend) e + # o 3.4.13 do pub.dev liga o socket sem como pedir isso. Patch, motivo, + # critério de saída e como refazer: backend/patches/README.md. SHA, nunca + # branch. Só o sinalacs_server depende de `serverpod`; o sinalacs_client e os + # apps dependem de serverpod_client/serverpod_flutter do pub.dev. + serverpod: + git: + url: https://github.com/hbgit/serverpod.git + ref: 2a0b67e38532faff48f8f75b42b9f63e36a1cd82 + path: packages/serverpod diff --git a/backend/sinalacs_server/Dockerfile b/backend/sinalacs_server/Dockerfile index 42983981..3d7b7ef6 100644 --- a/backend/sinalacs_server/Dockerfile +++ b/backend/sinalacs_server/Dockerfile @@ -11,17 +11,18 @@ # falhava em `dart pub get` com "version solving failed". FROM dart:3.12.2 AS build -# Workspace mínimo, preservando as versões travadas do pubspec.lock. +# Workspace do backend, preservando as versões travadas do pubspec.lock. O +# pubspec.yaml da raiz é COPIADO, não gerado: ele carrega o dependency_overrides +# do fork pinado do Serverpod (#90, backend/patches/README.md), que um arquivo +# gerado aqui perderia — e o `dart pub get` voltaria ao 3.4.13 do pub.dev, sem o +# gancho que exige o certificado de cliente. O sinalacs_client não entra na +# imagem, então sai da lista de membros do workspace. O fork é público e o +# `dart pub get` o clona anônimo (a imagem dart:3.12.2 traz o git); este estágio +# não recebe credencial nenhuma, de propósito. WORKDIR /app -COPY pubspec.lock . +COPY pubspec.yaml pubspec.lock . COPY sinalacs_server sinalacs_server -RUN printf '%s\n' \ - 'name: _' \ - 'environment:' \ - ' sdk: ^3.8.0' \ - 'workspace:' \ - ' - sinalacs_server' \ - > pubspec.yaml +RUN sed -i '/^ - sinalacs_client$/d' pubspec.yaml RUN dart pub get # Compila o executável AOT. diff --git a/backend/sinalacs_server/pubspec.yaml b/backend/sinalacs_server/pubspec.yaml index 02e50da9..5ad056ae 100644 --- a/backend/sinalacs_server/pubspec.yaml +++ b/backend/sinalacs_server/pubspec.yaml @@ -18,6 +18,9 @@ dependencies: # roda fora do processo do Serverpod e abre a própria conexão. Já vinha # resolvido como transitiva do serverpod, então não move o pubspec.lock. postgres: ^3.5.12 + # Direta (já era transitiva do serverpod, 1.2.0; o lock não se move) porque + # lib/src/runtime/mtls_api_server.dart importa IOAdapter/Adapter (#90). + relic: ^1.2.0 uuid: ^4.5.0 dev_dependencies: diff --git a/backend/sinalacs_server/test/unit/serverpod_fork_test.dart b/backend/sinalacs_server/test/unit/serverpod_fork_test.dart new file mode 100644 index 00000000..408e1e10 --- /dev/null +++ b/backend/sinalacs_server/test/unit/serverpod_fork_test.dart @@ -0,0 +1,13 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:test/test.dart'; + +/// O fork pinado do Serverpod (#90, `backend/patches/README.md`) expõe o +/// gancho que o 3.4.13 do pub.dev não tem. Se o `dependency_overrides` cair, +/// este arquivo deixa de compilar — antes de o backend subir em TLS sem exigir +/// o certificado do Traefik. +void main() { + test('Serverpod exporta ApiServerAdapterFactory (fork pinado, #90)', () { + ApiServerAdapterFactory? f; + expect(f, isNull); + }); +} From 046a4c87e6b2380680d202974754b8b15939ae5d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:11:02 -0400 Subject: [PATCH 04/12] =?UTF-8?q?feat(tls):=20API=5FTLS=5FCLIENT=5FCA=5FPA?= =?UTF-8?q?TH=20obrigat=C3=B3ria=20junto=20com=20cert=20e=20chave;=20conte?= =?UTF-8?q?xto=20confia=20s=C3=B3=20na=20CA=20do=20RPC=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/config/api_tls_config.dart | 76 ++++++++++++++----- .../test/unit/api_tls_config_test.dart | 62 +++++++++++---- 2 files changed, 104 insertions(+), 34 deletions(-) diff --git a/backend/sinalacs_server/lib/src/config/api_tls_config.dart b/backend/sinalacs_server/lib/src/config/api_tls_config.dart index ee10ac0b..bd178c3e 100644 --- a/backend/sinalacs_server/lib/src/config/api_tls_config.dart +++ b/backend/sinalacs_server/lib/src/config/api_tls_config.dart @@ -1,43 +1,77 @@ import 'dart:io'; -/// TLS do apiServer do Serverpod: o trecho Traefik → backend (RNF04, issue #78). +/// TLS do apiServer do Serverpod: o trecho Traefik → backend (RNF04, issues +/// #78 e #90). /// -/// Lido de `API_TLS_CERT_PATH` e `API_TLS_KEY_PATH`, que vêm juntos ou não vêm. -/// São opcionais porque há dois caminhos de implantação com bordas diferentes: -/// na stack local o Traefik termina o TLS do cliente e abre um segundo TLS até -/// o backend, que precisa deste certificado; no Render (`backend/DEPLOY.md`) o -/// proxy da plataforma termina o TLS e fala HTTP com o container, e um listener -/// TLS aqui só quebraria o healthcheck dele. Sem as duas variáveis o servidor -/// segue em HTTP; com só uma, o boot falha, para um erro de digitação não cair -/// em texto claro sem ninguém notar. +/// Lido de `API_TLS_CERT_PATH`, `API_TLS_KEY_PATH` e `API_TLS_CLIENT_CA_PATH`, +/// que vêm as três juntas ou não vêm. São opcionais porque há dois caminhos de +/// implantação com bordas diferentes: na stack local o Traefik termina o TLS do +/// cliente e abre um segundo TLS até o backend, que precisa deste certificado; +/// no Render (`backend/DEPLOY.md`) o proxy da plataforma termina o TLS e fala +/// HTTP com o container, e um listener TLS aqui só quebraria o healthcheck +/// dele. Sem as três variáveis o servidor segue em HTTP; com um subconjunto, o +/// boot falha, para um erro de digitação — ou um `.env` anterior à #90, que só +/// tem as duas primeiras — não cair em TLS sem exigir o certificado do Traefik +/// sem ninguém notar. /// -/// É TLS de mão única, de propósito (decisão de 2026-10-16): o Serverpod 3.4.13 -/// sobe o socket pelo `relic_io`, que chama `HttpServer.bindSecure` sem -/// `requestClientCertificate`, então o processo não tem como exigir o -/// certificado do Traefik. O mTLS interno fica aberto no `PROGRESS.md`. +/// Desde a #90 o apiServer EXIGE certificado de cliente assinado pela CA de +/// `API_TLS_CLIENT_CA_PATH`, recusado no handshake. O contexto daqui só +/// descreve as chaves e a confiança; o bind com `requireClientCertificate` +/// está em `lib/src/runtime/mtls_api_server.dart`, porque nem o +/// `HttpServer.bindSecure` do SDK nem o `relic_io` expõem esse flag — o +/// Serverpod é consumido por um fork pinado que deixa a aplicação ligar o +/// socket (`backend/patches/README.md`). class ApiTlsConfig { - const ApiTlsConfig({required this.certificatePath, required this.keyPath}); + const ApiTlsConfig({ + required this.certificatePath, + required this.keyPath, + required this.clientCaPath, + }); final String certificatePath; final String keyPath; + /// CA que assina os certificados de cliente aceitos (a CA de desenvolvimento + /// do RPC, na stack local). É a ÚNICA raiz confiada pelo listener. + final String clientCaPath; + + static const _variaveis = [ + 'API_TLS_CERT_PATH', + 'API_TLS_KEY_PATH', + 'API_TLS_CLIENT_CA_PATH', + ]; + static String? _blankToNull(String? value) => (value == null || value.trim().isEmpty) ? null : value; static ApiTlsConfig? fromMap(Map environment) { - final cert = _blankToNull(environment['API_TLS_CERT_PATH']); - final key = _blankToNull(environment['API_TLS_KEY_PATH']); - if ((cert == null) != (key == null)) { - throw StateError('API_TLS_CERT_PATH e API_TLS_KEY_PATH devem vir juntos.'); + final valores = [for (final v in _variaveis) _blankToNull(environment[v])]; + final presentes = valores.whereType().length; + if (presentes == 0) return null; + if (presentes != _variaveis.length) { + throw StateError( + 'API_TLS_CERT_PATH, API_TLS_KEY_PATH e API_TLS_CLIENT_CA_PATH devem vir ' + 'juntas: o apiServer exige certificado de cliente (#90) e TLS sem essa ' + 'exigência não é mais uma configuração válida.', + ); } - if (cert == null || key == null) return null; - return ApiTlsConfig(certificatePath: cert, keyPath: key); + return ApiTlsConfig( + certificatePath: valores[0]!, + keyPath: valores[1]!, + clientCaPath: valores[2]!, + ); } /// O contexto do listener, com TLS 1.3 como mínimo: o padrão do `dart:io` é - /// 1.2. Arquivo ausente ou ilegível lança aqui, no boot — falha fechada. + /// 1.2. `withTrustedRoots: false` + `setTrustedCertificates(clientCa)`: só a + /// CA do RPC assina clientes aceitos — um certificado válido de qualquer + /// outra raiz é recusado. `setClientAuthorities` é o que o servidor anuncia + /// no pedido de certificado. Arquivo ausente ou ilegível lança aqui, no boot + /// — falha fechada. SecurityContext toSecurityContext() => SecurityContext(withTrustedRoots: false) ..useCertificateChain(certificatePath) ..usePrivateKey(keyPath) + ..setTrustedCertificates(clientCaPath) + ..setClientAuthorities(clientCaPath) ..minimumTlsProtocolVersion = TlsProtocolVersion.tls1_3; } diff --git a/backend/sinalacs_server/test/unit/api_tls_config_test.dart b/backend/sinalacs_server/test/unit/api_tls_config_test.dart index 747805f0..cdca6ca3 100644 --- a/backend/sinalacs_server/test/unit/api_tls_config_test.dart +++ b/backend/sinalacs_server/test/unit/api_tls_config_test.dart @@ -3,38 +3,70 @@ import 'dart:io'; import 'package:sinalacs_server/src/config/api_tls_config.dart'; import 'package:test/test.dart'; -/// TLS 1.3 do apiServer (RNF04, issue #78): o trecho Traefik → backend. +/// TLS 1.3 mútuo do apiServer (RNF04, issues #78 e #90): o trecho Traefik → +/// backend. /// /// As regras de leitura das variáveis são testadas pelo mapa; a versão mínima /// é medida de verdade — um `HttpServer.bindSecure` com o contexto produzido e /// o `openssl s_client` do host negociando 1.3 e 1.2 contra ele. Só a primeira -/// parte provaria que o código lê `API_TLS_*`, não que o 1.2 é recusado. +/// parte provaria que o código lê `API_TLS_*`, não que o 1.2 é recusado. A +/// exigência do certificado de cliente é medida em `mtls_api_server_test.dart`. +/// +/// Desde a #90 são TRÊS variáveis, juntas ou nenhuma: duas delas presentes é o +/// caso que mais importa recusar, porque é o que um `.env` anterior à #90 tem. +/// Sem a recusa, o servidor subiria em TLS de mão única — exatamente o estado +/// que a issue existe para fechar — sem ninguém notar. +const _asTres = ['API_TLS_CERT_PATH', 'API_TLS_KEY_PATH', 'API_TLS_CLIENT_CA_PATH']; + +Matcher _erroQueNomeiaAsTres() => throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('API_TLS_CERT_PATH'), + contains('API_TLS_KEY_PATH'), + contains('API_TLS_CLIENT_CA_PATH'), + contains('devem vir juntas'), + contains('#90'), + ), + ), +); + void main() { group('ApiTlsConfig.fromMap', () { - test('sem as duas variáveis devolve null (HTTP, caminho do Render)', () { + test('sem as três variáveis devolve null (HTTP, caminho do Render)', () { expect(ApiTlsConfig.fromMap({}), isNull); }); test('string vazia ou só de espaços conta como ausente', () { expect( - ApiTlsConfig.fromMap({'API_TLS_CERT_PATH': '', 'API_TLS_KEY_PATH': ' '}), + ApiTlsConfig.fromMap({ + 'API_TLS_CERT_PATH': '', + 'API_TLS_KEY_PATH': ' ', + 'API_TLS_CLIENT_CA_PATH': '', + }), isNull, ); }); - test('só o certificado é erro de boot que nomeia as duas', () { + test('só o certificado é erro de boot que nomeia as três', () { expect( () => ApiTlsConfig.fromMap({'API_TLS_CERT_PATH': '/x.crt'}), - throwsA( - isA().having( - (e) => e.message, - 'message', - allOf(contains('API_TLS_CERT_PATH'), contains('API_TLS_KEY_PATH')), - ), - ), + _erroQueNomeiaAsTres(), ); }); + test('só duas das três é erro de boot que nomeia as três (fail-fast, #90)', () { + for (final ausente in _asTres) { + final mapa = {for (final v in _asTres) if (v != ausente) v: '/x'}; + expect( + () => ApiTlsConfig.fromMap(mapa), + _erroQueNomeiaAsTres(), + reason: 'sem $ausente o boot tem de falhar, não cair em mão única', + ); + } + }); + test('só a chave também é erro de boot', () { expect( () => ApiTlsConfig.fromMap({'API_TLS_KEY_PATH': '/x.key'}), @@ -42,13 +74,15 @@ void main() { ); }); - test('as duas juntas produzem os caminhos', () { + test('as três juntas produzem os caminhos', () { final config = ApiTlsConfig.fromMap({ 'API_TLS_CERT_PATH': '/a.crt', 'API_TLS_KEY_PATH': '/a.key', + 'API_TLS_CLIENT_CA_PATH': '/ca.crt', })!; expect(config.certificatePath, '/a.crt'); expect(config.keyPath, '/a.key'); + expect(config.clientCaPath, '/ca.crt'); }); }); @@ -82,6 +116,7 @@ void main() { config = ApiTlsConfig( certificatePath: '${dir.path}/leaf.crt', keyPath: '${dir.path}/leaf.key', + clientCaPath: caPath, ); }); @@ -92,6 +127,7 @@ void main() { () => const ApiTlsConfig( certificatePath: '/nao/existe.crt', keyPath: '/nao/existe.key', + clientCaPath: '/nao/existe-ca.crt', ).toSecurityContext(), throwsA(anything), ); From b606276206e7f969afa2051d1c5ba5ee99e0d517 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:15:19 -0400 Subject: [PATCH 05/12] feat(tls): apiServer liga com requireClientCertificate pelo gancho do fork (#90) --- .../lib/src/runtime/mtls_api_server.dart | 135 +++++++++++++ .../test/unit/mtls_api_server_test.dart | 190 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart create mode 100644 backend/sinalacs_server/test/unit/mtls_api_server_test.dart diff --git a/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart b/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart new file mode 100644 index 00000000..e2bf378f --- /dev/null +++ b/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:relic/relic.dart'; + +/// O listener do apiServer com certificado de cliente OBRIGATÓRIO (mTLS +/// Traefik → backend, RNF04, issue #90). +/// +/// Este arquivo existe por três limitações medidas em 2026-10-27: +/// +/// 1. `HttpServer.bindSecure` do SDK só expõe `requestClientCertificate`, e com +/// ele o handshake COMPLETA mesmo sem certificado — a aplicação só vê +/// `request.certificate == null`. A recusa no handshake exige +/// `SecureServerSocket.bind(…, requireClientCertificate: true)`, que o +/// `bindSecure` não repassa. +/// 2. `SecureServerSocket` implementa `ServerSocketBase`, não +/// `ServerSocket`, e `HttpServer.listenOn` pede um `ServerSocket`. Daí o +/// embrulho [SecureServerSocketAsServerSocket]: o `_HttpServer` só usa +/// `listen`, `address`, `port` e `close` (SDK 3.12.2), e é isso que ele +/// delega. +/// 3. O `relic_io` e o Serverpod ligam o socket sozinhos (`RelicApp.serve`), +/// então o Serverpod é consumido por um fork pinado com o gancho +/// `apiServerAdapterFactory` (`backend/patches/README.md`), e +/// [mtlsApiServerAdapter] é a fábrica que esse gancho recebe. +/// +/// Sondas deste trecho: `openssl s_client` sem certificado imprime +/// `CONNECTION ESTABLISHED` / `TLSv1.3` MESMO recusado — no TLS 1.3 o cliente +/// termina o lado dele antes de o servidor validar o certificado —, então +/// nenhuma sonda pode afirmar recusa pelo texto do `s_client`; `curl` e +/// "nenhuma linha HTTP" é o critério (`test/unit/mtls_api_server_test.dart`, +/// Grupo E de `scripts/qa/tls_invariants.sh`). + +/// Um `SecureServerSocket` visto como `ServerSocket`, para o +/// `HttpServer.listenOn`. +/// +/// Um handshake que falha chega ao stream do socket servidor como ERRO +/// (`RawSecureServerSocket._onData` → `addError`). O `_HttpServer` do SDK já +/// ignora `HandshakeException` ali, e o relic loga o resto; este embrulho não +/// depende disso: descarta `TlsException` (inclui `HandshakeException`) e +/// `SocketException` do caminho de aceitação, conta-os em +/// [handshakesRecusados] e loga só o TIPO do erro — nunca endereço ou bytes +/// do peer (VAZ-01). Sem `cancelOnError`: um cliente sem certificado não pode +/// derrubar o listener, senão o bloqueio que a #90 pede viraria um DoS. +class SecureServerSocketAsServerSocket extends StreamView + implements ServerSocket { + SecureServerSocketAsServerSocket._(this._inner, this._contador, Stream stream) + : super(stream); + + factory SecureServerSocketAsServerSocket( + SecureServerSocket inner, { + void Function(String) log = print, + }) { + final contador = _Contador(); + final stream = inner.handleError( + (Object erro, StackTrace _) { + contador.valor++; + log('[tls] handshake recusado: ${erro.runtimeType}'); + }, + test: (erro) => erro is TlsException || erro is SocketException, + ); + return SecureServerSocketAsServerSocket._(inner, contador, stream); + } + + final SecureServerSocket _inner; + final _Contador _contador; + + /// Handshakes recusados (sem certificado, de outra CA, TLS 1.2, lixo no TCP) + /// desde o bind. Só para observação e teste. + int get handshakesRecusados => _contador.valor; + + @override + InternetAddress get address => _inner.address; + + @override + int get port => _inner.port; + + @override + Future close() async { + await _inner.close(); + return this; + } +} + +class _Contador { + int valor = 0; +} + +/// Liga o socket seguro exigindo certificado de cliente e o devolve já +/// embrulhado. [context] vem de `ApiTlsConfig.toSecurityContext()` (folha do +/// backend, CA de cliente confiada, mínimo TLS 1.3). +Future bindRequiringClientCertificateSocket( + InternetAddress address, + int port, + SecurityContext context, { + void Function(String) log = print, +}) async { + final secure = await SecureServerSocket.bind( + address, + port, + context, + requireClientCertificate: true, + ); + return SecureServerSocketAsServerSocket(secure, log: log); +} + +/// O `HttpServer` do apiServer sobre o socket de +/// [bindRequiringClientCertificateSocket]. +Future bindRequiringClientCertificate( + InternetAddress address, + int port, + SecurityContext context, { + void Function(String) log = print, +}) async => + HttpServer.listenOn( + await bindRequiringClientCertificateSocket(address, port, context, log: log), + ); + +/// A `ApiServerAdapterFactory` passada ao `Serverpod(…)` em `lib/server.dart` +/// quando `API_TLS_*` está definido. Sem contexto não há o que exigir: falha +/// fechada em vez de cair em HTTP por este caminho. +Future mtlsApiServerAdapter( + InternetAddress address, + int port, + SecurityContext? securityContext, +) async { + if (securityContext == null) { + throw StateError( + 'mtlsApiServerAdapter exige SecurityContext: sem API_TLS_* o apiServer ' + 'sobe em HTTP pelo caminho padrão do Serverpod, não por esta fábrica.', + ); + } + return IOAdapter( + await bindRequiringClientCertificate(address, port, securityContext), + ); +} diff --git a/backend/sinalacs_server/test/unit/mtls_api_server_test.dart b/backend/sinalacs_server/test/unit/mtls_api_server_test.dart new file mode 100644 index 00000000..c97dad78 --- /dev/null +++ b/backend/sinalacs_server/test/unit/mtls_api_server_test.dart @@ -0,0 +1,190 @@ +import 'dart:io'; + +import 'package:sinalacs_server/src/config/api_tls_config.dart'; +import 'package:sinalacs_server/src/runtime/mtls_api_server.dart'; +import 'package:test/test.dart'; + +/// mTLS do apiServer (RNF04, issue #90): o listener da 8080 EXIGE certificado +/// de cliente assinado pela CA do RPC, recusado no handshake. +/// +/// Medido de verdade: um listener ligado por `bindRequiringClientCertificate` +/// e o `curl` do host sondando com e sem certificado. As sondas negativas +/// afirmam a AUSÊNCIA de resposta HTTP (`HTTP 000`, sem `"status"`), nunca o +/// código de saída do curl (35 ou 56, conforme o OpenSSL) nem o texto do +/// `openssl s_client` — que no TLS 1.3 imprime `CONNECTION ESTABLISHED` mesmo +/// quando o servidor recusa o certificado em seguida (medido na PoC da #90). +void main() { + late Directory dir; + late String caPath, cliCrt, cliKey, foraCrt, foraKey; + late ApiTlsConfig config; + late HttpServer server; + + Future openssl(List args) async { + final r = await Process.run('openssl', args, workingDirectory: dir.path); + if (r.exitCode != 0) { + throw StateError('openssl ${args.first} falhou: ${r.stderr}'); + } + } + + Future folha(String nome, String ca, String eku, {String? san}) async { + await openssl(['req', '-newkey', 'rsa:2048', '-nodes', + '-keyout', '$nome.key', '-out', '$nome.csr', '-subj', '/CN=$nome']); + File('${dir.path}/$nome.ext').writeAsStringSync( + 'extendedKeyUsage=$eku\n${san == null ? '' : 'subjectAltName=$san\n'}', + ); + await openssl(['x509', '-req', '-days', '2', '-in', '$nome.csr', + '-CA', '$ca.crt', '-CAkey', '$ca.key', '-CAcreateserial', + '-extfile', '$nome.ext', '-out', '$nome.crt']); + } + + /// `curl` contra `server`, com os argumentos extras; devolve stdout+stderr e + /// o código de saída. `-w` acrescenta a linha `HTTP ` ao fim. + Future<(int, String)> curl(List extra, {int? port}) async { + final r = await Process.run('curl', [ + '-sS', '--max-time', '5', '--tlsv1.3', '--cacert', caPath, + '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{}', + '-w', '\nHTTP %{http_code}', ...extra, + 'https://localhost:${port ?? server.port}/health/check', + ]); + return (r.exitCode, '${r.stdout}${r.stderr}'); + } + + setUpAll(() async { + dir = Directory.systemTemp.createTempSync('mtls_api_server_test'); + await openssl(['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', + '-keyout', 'ca.key', '-out', 'ca.crt', '-subj', '/CN=ca-de-teste']); + await folha('localhost', 'ca', 'serverAuth', san: 'DNS:localhost,IP:127.0.0.1'); + await folha('traefik-de-teste', 'ca', 'clientAuth'); + await openssl(['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', + '-keyout', 'fora.key', '-out', 'fora.crt', '-subj', '/CN=ca-de-fora']); + // Mesmo CN que o cliente legítimo, de propósito: a recusa tem de vir da + // cadeia, não do nome. + await openssl(['req', '-newkey', 'rsa:2048', '-nodes', + '-keyout', 'fora-cli.key', '-out', 'fora-cli.csr', '-subj', '/CN=traefik-de-teste']); + await openssl(['x509', '-req', '-days', '2', '-in', 'fora-cli.csr', + '-CA', 'fora.crt', '-CAkey', 'fora.key', '-CAcreateserial', + '-extfile', 'traefik-de-teste.ext', '-out', 'fora-cli.crt']); + caPath = '${dir.path}/ca.crt'; + cliCrt = '${dir.path}/traefik-de-teste.crt'; + cliKey = '${dir.path}/traefik-de-teste.key'; + foraCrt = '${dir.path}/fora-cli.crt'; + foraKey = '${dir.path}/fora-cli.key'; + config = ApiTlsConfig( + certificatePath: '${dir.path}/localhost.crt', + keyPath: '${dir.path}/localhost.key', + clientCaPath: caPath, + ); + server = await bindRequiringClientCertificate( + InternetAddress.loopbackIPv4, 0, config.toSecurityContext(), + ); + server.listen((req) => req.response + ..statusCode = 200 + ..write('{"status":"ok"}') + ..close()); + }); + + tearDownAll(() async { + await server.close(force: true); + dir.deleteSync(recursive: true); + }); + + group('bindRequiringClientCertificate', () { + test('certificado assinado pela CA: 200 e corpo (controle positivo)', () async { + final (rc, out) = await curl(['--cert', cliCrt, '--key', cliKey]); + expect(rc, 0, reason: out); + expect(out, allOf(contains('"status"'), contains('HTTP 200'))); + }); + + test('sem certificado de cliente: nenhuma resposta HTTP', () async { + final (rc, out) = await curl([]); + expect(rc, isNot(0)); + expect(out, isNot(contains('"status"'))); + expect(out, contains('HTTP 000')); + }); + + test('certificado de outra CA (mesmo CN): nenhuma resposta HTTP', () async { + final (rc, out) = await curl(['--cert', foraCrt, '--key', foraKey]); + expect(rc, isNot(0)); + expect(out, isNot(contains('"status"'))); + expect(out, contains('HTTP 000')); + }); + + test('TLS 1.2 com certificado válido continua recusado', () async { + final (rc, out) = await curl( + ['--tlsv1.2', '--tls-max', '1.2', '--cert', cliCrt, '--key', cliKey], + ); + expect(rc, isNot(0)); + expect(out, isNot(contains('"status"'))); + }); + }); + + group('SecureServerSocketAsServerSocket', () { + test('rajada de handshakes falhos não derruba o listener (DoS)', () async { + final log = []; + final socket = await bindRequiringClientCertificateSocket( + InternetAddress.loopbackIPv4, 0, config.toSecurityContext(), + log: log.add, + ); + final proprio = HttpServer.listenOn(socket); + addTearDown(() => proprio.close(force: true)); + proprio.listen((req) => req.response + ..write('{"status":"ok"}') + ..close()); + + for (var i = 0; i < 20; i++) { + await curl([], port: proprio.port); + await curl(['--cert', foraCrt, '--key', foraKey], port: proprio.port); + } + // Conexão TCP que manda lixo e fecha no meio do handshake. + final s = await Socket.connect(InternetAddress.loopbackIPv4, proprio.port); + s.add([0x16, 0x03, 0x01, 0x00, 0x05, 0x6c, 0x69, 0x78, 0x6f]); + await s.flush(); + await s.close(); + + final (rc, out) = await curl( + ['--cert', cliCrt, '--key', cliKey], port: proprio.port, + ); + expect(rc, 0, reason: out); + expect(out, allOf(contains('"status"'), contains('HTTP 200'))); + expect(socket.handshakesRecusados, greaterThanOrEqualTo(40)); + expect(log, isNotEmpty); + // VAZ-01: só o tipo do erro, nada do peer. + expect(log, everyElement(isNot(contains('127.0.0.1')))); + expect(log, everyElement(contains('handshake recusado'))); + }); + + test('address, port e close delegam ao socket seguro', () async { + final socket = await bindRequiringClientCertificateSocket( + InternetAddress.loopbackIPv4, 0, config.toSecurityContext(), + ); + expect(socket.address, InternetAddress.loopbackIPv4); + expect(socket.port, greaterThan(0)); + await socket.close(); + // A porta ficou livre: um bind novo na mesma porta tem de funcionar. + final outro = await ServerSocket.bind(InternetAddress.loopbackIPv4, socket.port); + await outro.close(); + }); + }); + + group('mtlsApiServerAdapter', () { + test('sem SecurityContext falha fechado', () { + expect( + () => mtlsApiServerAdapter(InternetAddress.loopbackIPv4, 0, null), + throwsA(isA().having( + (e) => e.message, 'message', contains('SecurityContext'), + )), + ); + }); + + test('devolve um Adapter na porta ligada, que exige o certificado', () async { + final adapter = await mtlsApiServerAdapter( + InternetAddress.loopbackIPv4, 0, config.toSecurityContext(), + ); + addTearDown(() => adapter.close(force: true)); + expect(adapter.port, greaterThan(0)); + final (rc, out) = await curl([], port: adapter.port); + expect(rc, isNot(0)); + expect(out, isNot(contains('"status"'))); + }); + }); +} From 0dab19674be611ddf21e9a4651154592bae0fe1d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:18:11 -0400 Subject: [PATCH 06/12] feat(tls): Serverpod sobe o apiServer pelo adapter com mTLS quando API_TLS_* existe (#90) --- backend/sinalacs_server/lib/server.dart | 19 +++++++++++--- .../test/unit/server_wiring_test.dart | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 backend/sinalacs_server/test/unit/server_wiring_test.dart diff --git a/backend/sinalacs_server/lib/server.dart b/backend/sinalacs_server/lib/server.dart index b1b2aa60..6d7f20f1 100644 --- a/backend/sinalacs_server/lib/server.dart +++ b/backend/sinalacs_server/lib/server.dart @@ -12,19 +12,29 @@ import 'src/config/api_tls_config.dart'; import 'src/generated/endpoints.dart'; import 'src/generated/protocol.dart'; import 'src/runtime/alert_runtime.dart'; +import 'src/runtime/mtls_api_server.dart'; import 'src/runtime/notice_dispatch_loop.dart'; import 'src/web/routes/app_config_route.dart'; import 'src/web/routes/root.dart'; /// The starting point of the Serverpod server. void run(List args) async { - // TLS 1.3 no apiServer quando API_TLS_* está definido (RNF04, #78): é o - // trecho Traefik → backend da stack local. Sem as variáveis segue em HTTP, - // que é o caminho do Render (TLS na borda). Insights e webServer ficam em - // HTTP: nenhum dos dois é publicado nem roteado. + // TLS 1.3 MÚTUO no apiServer quando API_TLS_* está definido (RNF04, #78 e + // #90): é o trecho Traefik → backend da stack local, e o backend exige o + // certificado de cliente assinado pela CA de API_TLS_CLIENT_CA_PATH. Sem as + // variáveis segue em HTTP, que é o caminho do Render (TLS na borda). Insights + // e webServer ficam em HTTP: nenhum dos dois é publicado nem roteado. final apiTls = ApiTlsConfig.fromMap(Platform.environment); // Initialize Serverpod and connect it with your generated code. + // + // O contexto TLS e a fábrica do adapter saem do MESMO `apiTls`, de propósito: + // o contexto sozinho subiria em TLS sem exigir o certificado (o estado que a + // #90 fechou), e a fábrica sozinha não tem com que ligar. `test/unit/ + // server_wiring_test.dart` acusa se os dois forem separados. A fábrica + // (`mtls_api_server.dart`) liga o socket com `requireClientCertificate`, o + // que o Serverpod do pub.dev não permite — daí o fork pinado + // (`backend/patches/README.md`). final pod = Serverpod( args, Protocol(), @@ -32,6 +42,7 @@ void run(List args) async { securityContextConfig: apiTls == null ? null : SecurityContextConfig(apiServer: apiTls.toSecurityContext()), + apiServerAdapterFactory: apiTls == null ? null : mtlsApiServerAdapter, ); // O módulo serverpod_auth_idp não é inicializado: o acesso hoje é o token diff --git a/backend/sinalacs_server/test/unit/server_wiring_test.dart b/backend/sinalacs_server/test/unit/server_wiring_test.dart new file mode 100644 index 00000000..b87d0565 --- /dev/null +++ b/backend/sinalacs_server/test/unit/server_wiring_test.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +/// O TLS do apiServer e a exigência do certificado de cliente são ligados +/// JUNTOS em `lib/server.dart` (#90): o `SecurityContextConfig` e o +/// `apiServerAdapterFactory` saem do mesmo `apiTls`. Separá-los é o defeito que +/// este teste acusa — um `SecurityContextConfig` sem a fábrica subiria em TLS +/// sem exigir o certificado do Traefik, com todas as sondas de "está cifrado" +/// verdes. Lê o texto-fonte, como `endpoint_auth_posture_test.dart`. +void main() { + test('Serverpod recebe SecurityContextConfig e mtlsApiServerAdapter do mesmo apiTls', () { + final fonte = File('lib/server.dart').readAsStringSync(); + final construtor = RegExp(r'Serverpod\(\s*args,[\s\S]*?\);').firstMatch(fonte); + expect(construtor, isNotNull, reason: 'construção do Serverpod não encontrada'); + final bloco = construtor!.group(0)!.replaceAll(RegExp(r'\s+'), ' '); + expect( + bloco, + contains('SecurityContextConfig(apiServer: apiTls.toSecurityContext())'), + ); + expect( + bloco, + contains('apiServerAdapterFactory: apiTls == null ? null : mtlsApiServerAdapter'), + ); + }); +} From f9aa842c7f46b13feeab1d43aa2edbcd32486e9d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:24:16 -0400 Subject: [PATCH 07/12] feat(traefik-init): folhas clientAuth para o Traefik e para o healthcheck, assinadas pela CA do RPC (#90) --- infra/docker/traefik/init.sh | 80 ++++++++++++++++++++------ scripts/qa/dev_key_permissions_test.sh | 37 +++++++++++- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/infra/docker/traefik/init.sh b/infra/docker/traefik/init.sh index 027ed31f..c0599021 100644 --- a/infra/docker/traefik/init.sh +++ b/infra/docker/traefik/init.sh @@ -1,8 +1,11 @@ #!/bin/sh # -# Gera a CA de desenvolvimento do RPC e as duas folhas assinadas por ela -# (RNF04): a que o Traefik apresenta em :443 e, desde a issue #78, a que o -# backend apresenta ao Traefik na 8080 interna (runtime/backend/). +# Gera a CA de desenvolvimento do RPC e as quatro folhas assinadas por ela +# (RNF04): a que o Traefik apresenta em :443; desde a issue #78, a que o +# backend apresenta ao Traefik na 8080 interna (runtime/backend/); e, desde a +# issue #90, as duas folhas de CLIENTE do mTLS dessa 8080 — a que o Traefik +# apresenta ao backend (runtime/certs/traefik-client.*) e a que o HEALTHCHECK +# do container do backend apresenta a si mesmo (runtime/backend/healthcheck.*). # # CA PRÓPRIA, separada da do Mosquitto: aquela é consumida pelo broker, pelo # asset do app ACS e pela conexão de saída do backend — três coisas que hoje @@ -55,6 +58,11 @@ fi ca_days=3650 server_days=825 +# A folha do healthcheck é curta de propósito (#90): é regerada a cada subida, +# nada mais depende dela, e uma folha de cliente que vive pouco é uma folha de +# cliente que não vale muito se vazar do container. O Grupo E do +# tls_invariants.sh acusa quando faltam menos de 7 dias. +healthcheck_days=90 # Reaproveita a CA existente enquanto ela estiver legível e longe de vencer (30 # dias), como o init.sh do broker. Ver o cabeçalho sobre por que ela não é @@ -125,13 +133,23 @@ fi # `ca.crt`/`ca.key` de CAs diferentes cai no mesmo caminho (a chave é legível, a # assinatura é que não casa), e também tem de sair com diagnóstico. # -# São DUAS folhas desde a issue #78, as duas assinadas por esta CA: a que o -# Traefik apresenta aos apps em :443 (`server`) e a que o backend apresenta ao -# Traefik na 8080 interna (`backend`, RNF04 no trecho Traefik → backend). +# São QUATRO folhas desde a issue #90, todas assinadas por esta CA: duas de +# SERVIDOR — a que o Traefik apresenta aos apps em :443 (`server`) e a que o +# backend apresenta ao Traefik na 8080 interna (`backend`, #78) — e duas de +# CLIENTE — a que o Traefik apresenta ao backend (`traefik-client`) e a que o +# healthcheck do container do backend apresenta (`healthcheck`). O EKU é o que +# separa as duas famílias: o BoringSSL do backend recusa uma folha `serverAuth` +# apresentada como cliente, então reaproveitar a folha errada não "funciona por +# acaso" — quebra o Traefik inteiro, sem mensagem que diga por quê +# (scripts/qa/dev_key_permissions_test.sh confere o EKU das quatro). # -# emitir_folha +# emitir_folha +# eku: serverAuth | clientAuth +# dias: validade da folha +# san: subjectAltName; vazio omite a extensão (o OpenSSL recusa SAN vazio, e +# uma folha de cliente não tem nome a afirmar) emitir_folha() { - dir="$1"; nome="$2"; cn="$3"; folha_san="$4" + dir="$1"; nome="$2"; cn="$3"; eku="$4"; dias="$5"; folha_san="$6" key_tmp="$dir/$nome.key.tmp" crt_tmp="$dir/$nome.crt.tmp" csr="$dir/$nome.csr" @@ -147,18 +165,23 @@ emitir_folha() { # restrição nenhuma — era a única diferença entre esta folha e a do broker, # que o app já valida (`dart:io`/BoringSSL). ext_file="$dir/$nome.ext" - cat > "$ext_file" < "$ext_file" # Aqui NÃO tem `2>/dev/null`: o erro do OpenSSL é o diagnóstico, e engoli-lo # foi o que transformou uma falha de configuração num arquivo vazio. if ! openssl x509 -req -in "$csr" \ -CA "$certs_dir/ca.crt" -CAkey "$certs_dir/ca.key" -CAcreateserial \ - -out "$crt_tmp" -days "$server_days" -sha256 \ + -out "$crt_tmp" -days "$dias" -sha256 \ -extfile "$ext_file"; then echo "erro: falha ao assinar o certificado $nome com a CA em $certs_dir/ca.key." >&2 echo "erro: chave ilegível ou par ca.crt/ca.key de CAs diferentes. Nada foi substituído:" >&2 @@ -172,7 +195,7 @@ EXT rm -f "$csr" "$ext_file" } -emitir_folha "$certs_dir" server sinalacs-rpc "$san" +emitir_folha "$certs_dir" server sinalacs-rpc serverAuth "$server_days" "$san" # A folha do backend fica num diretório PRÓPRIO, que é o único montado no # container do backend: ele recebe a folha, a chave dela e a CA pública, e nunca @@ -182,10 +205,22 @@ emitir_folha "$certs_dir" server sinalacs-rpc "$san" # RPC_CERT_SAN_EXTRA: aparelho nenhum fala com o backend direto. backend_dir="$runtime_dir/backend" mkdir -p "$backend_dir" -emitir_folha "$backend_dir" backend sinalacs-serverpod \ +emitir_folha "$backend_dir" backend sinalacs-serverpod serverAuth "$server_days" \ "DNS:sinalacs-serverpod,DNS:serverpod,DNS:localhost,IP:127.0.0.1" cp "$certs_dir/ca.crt" "$backend_dir/ca.crt" +# As duas folhas de CLIENTE do mTLS da 8080 (#90). A do Traefik fica em +# runtime/certs/, que o Traefik já monta em /certs (é o `certificates` do +# serversTransport `backend-tls`). A do healthcheck fica em runtime/backend/, o +# único diretório que o container do backend monta: sem ela o `curl` do +# HEALTHCHECK seria recusado pelo próprio backend e o container nunca ficaria +# healthy. São folhas DISTINTAS, com chave, CN e validade próprias: a do +# healthcheck pode ser regerada ou removida sem tocar na do Traefik, e um dia um +# log do CN distinguiria as duas origens. Sem SAN: certificado de cliente não +# afirma nome de host. +emitir_folha "$certs_dir" traefik-client sinalacs-traefik clientAuth "$server_days" "" +emitir_folha "$backend_dir" healthcheck sinalacs-healthcheck clientAuth "$healthcheck_days" "" + # 644 nos certificados e nas chaves, como o init.sh do broker faz com os dele. O # `openssl` cria chave privada em 600 por conta própria, e 600 divergiria do # broker sem ganho: é certificado de DESENVOLVIMENTO, num diretório que o @@ -197,11 +232,20 @@ cp "$certs_dir/ca.crt" "$backend_dir/ca.crt" chmod 755 "$runtime_dir" "$certs_dir" "$backend_dir" chmod 644 "$certs_dir/ca.crt" "$certs_dir/server.crt" "$certs_dir/server.key" chmod 644 "$backend_dir/ca.crt" "$backend_dir/backend.crt" "$backend_dir/backend.key" +# A folha do healthcheck é lida pelo curl do HEALTHCHECK, como uid 1000: 644, +# pelo mesmo motivo da backend.key. +chmod 644 "$backend_dir/healthcheck.crt" "$backend_dir/healthcheck.key" # A chave da CA só assina folhas e quem a lê é este init.sh, como root: ninguém # mais precisa do bit de leitura (server.key segue em 644 pelo motivo acima). -chmod 600 "$certs_dir/ca.key" +# A chave de cliente do Traefik também: só o Traefik a lê, e ele roda como root +# (medido, `uid=0`). É o mesmo critério da ca.key, não uma exceção a ele. +chmod 600 "$certs_dir/ca.key" "$certs_dir/traefik-client.key" +chmod 644 "$certs_dir/traefik-client.crt" echo "CA do RPC e certificado do servidor prontos em $certs_dir (SAN: $san)." openssl x509 -in "$certs_dir/server.crt" -noout -subject -dates -ext subjectAltName echo "Certificado do backend (Traefik → backend) pronto em $backend_dir." openssl x509 -in "$backend_dir/backend.crt" -noout -subject -dates -ext subjectAltName +echo "Certificados de CLIENTE do mTLS da 8080 (#90): Traefik em $certs_dir, healthcheck em $backend_dir." +openssl x509 -in "$certs_dir/traefik-client.crt" -noout -subject -dates -ext extendedKeyUsage +openssl x509 -in "$backend_dir/healthcheck.crt" -noout -subject -dates -ext extendedKeyUsage diff --git a/scripts/qa/dev_key_permissions_test.sh b/scripts/qa/dev_key_permissions_test.sh index 8e290c80..54d4d50a 100755 --- a/scripts/qa/dev_key_permissions_test.sh +++ b/scripts/qa/dev_key_permissions_test.sh @@ -5,6 +5,9 @@ # As demais chaves (server.key, backend.key, acs-area-12.key) são lidas por outro # uid (o backend roda como uid 1000; o sync_dev_ca.sh, como o usuário do host) e # ficam em 644 — decisão e motivo em PROGRESS.md. A folha do backend (#78) também. +# Desde a #90 há duas folhas de CLIENTE: traefik-client.key (600, só o Traefik, que +# roda como root) e healthcheck.key (644, lida pelo curl do healthcheck como uid +# 1000) — e as duas têm de ter EKU clientAuth (ver o bloco no fim). # # Só confere o que a stack já gerou: sem runtime/, sai 0 avisando. set -euo pipefail @@ -31,5 +34,37 @@ if [[ -d "$bdir" ]]; then echo "backend.key ausente em $bdir (o traefik-init da #78 rodou?)" >&2; ruim=1 fi fi -[[ "$ruim" -eq 0 ]] && echo "OK — ca.key em 600, folha do backend em 644 e sem ca.key ao lado" +# #90 — as DUAS folhas de cliente que o traefik-init emite para o mTLS da 8080: +# a do Traefik (runtime/certs/, lida só pelo Traefik, que roda como root → 600) e +# a do healthcheck (runtime/backend/, lida pelo curl do HEALTHCHECK como uid +# 1000 → 644). As duas têm de ser certificado de CLIENTE (EKU clientAuth): uma +# folha serverAuth reaproveitada por descuido é recusada pelo BoringSSL do +# backend e o Traefik devolve 5xx para todo app, sem dizer por quê. E a folha do +# backend continua sendo só de servidor. +eku() { openssl x509 -in "$1" -noout -ext extendedKeyUsage 2>/dev/null; } +tdir=infra/docker/traefik/runtime/certs +if [[ -d "$tdir" ]]; then + if [[ -f "$tdir/traefik-client.key" ]]; then + m="$(stat -c %a "$tdir/traefik-client.key")" + [[ "$m" == 600 ]] || { echo "traefik-client.key em $m (esperado 600, só o Traefik, root, a lê): $tdir/traefik-client.key" >&2; ruim=1; } + eku "$tdir/traefik-client.crt" | grep -q 'TLS Web Client Authentication' \ + || { echo "traefik-client.crt não é certificado de cliente (EKU sem clientAuth)" >&2; ruim=1; } + else + echo "traefik-client.key ausente em $tdir (o traefik-init da #90 rodou?)" >&2; ruim=1 + fi +fi +if [[ -d "$bdir" ]]; then + if [[ -f "$bdir/healthcheck.key" ]]; then + m="$(stat -c %a "$bdir/healthcheck.key")" + [[ "$m" == 644 ]] || { echo "healthcheck.key em $m (esperado 644, o curl do healthcheck lê como uid 1000): $bdir/healthcheck.key" >&2; ruim=1; } + eku "$bdir/healthcheck.crt" | grep -q 'TLS Web Client Authentication' \ + || { echo "healthcheck.crt não é certificado de cliente (EKU sem clientAuth)" >&2; ruim=1; } + else + echo "healthcheck.key ausente em $bdir (o traefik-init da #90 rodou?)" >&2; ruim=1 + fi + if [[ -f "$bdir/backend.crt" ]] && eku "$bdir/backend.crt" | grep -q 'TLS Web Client Authentication'; then + echo "backend.crt virou certificado de cliente (EKU com clientAuth): a folha do servidor não pode autenticar-se como cliente" >&2; ruim=1 + fi +fi +[[ "$ruim" -eq 0 ]] && echo "OK — ca.key em 600, folha do backend em 644 e sem ca.key ao lado; folhas de cliente (#90) com clientAuth, traefik-client.key 600 e healthcheck.key 644" exit "$ruim" From 013b5d491d9847c5a4decc832696e95b1547f931 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 17:29:49 -0400 Subject: [PATCH 08/12] =?UTF-8?q?fix(tls):=20close=20do=20adapter=20fecha?= =?UTF-8?q?=20o=20socket=20seguro;=20erro=20de=20configura=C3=A7=C3=A3o=20?= =?UTF-8?q?TLS=20sai=20com=2078=20e=20nomeia=20a=20vari=C3=A1vel=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados do checkpoint de revisão: HttpServer.listenOn cria o servidor com _closeServer=false, então o shutdown deixava a 8080 ocupada e aceitando handshakes; e o StateError/TlsException do boot escapava do run() async void para o runZonedGuarded da #56, que loga só o tipo (e classifica a TlsException como transitória, saindo com código 0). --- backend/sinalacs_server/lib/server.dart | 18 ++++- .../lib/src/config/api_tls_config.dart | 73 +++++++++++++++++-- .../lib/src/runtime/mtls_api_server.dart | 26 ++++++- .../test/unit/api_tls_config_test.dart | 66 +++++++++++++++++ .../test/unit/mtls_api_server_test.dart | 14 ++++ .../test/unit/server_wiring_test.dart | 7 +- 6 files changed, 187 insertions(+), 17 deletions(-) diff --git a/backend/sinalacs_server/lib/server.dart b/backend/sinalacs_server/lib/server.dart index 6d7f20f1..6a697608 100644 --- a/backend/sinalacs_server/lib/server.dart +++ b/backend/sinalacs_server/lib/server.dart @@ -24,7 +24,17 @@ void run(List args) async { // certificado de cliente assinado pela CA de API_TLS_CLIENT_CA_PATH. Sem as // variáveis segue em HTTP, que é o caminho do Render (TLS na borda). Insights // e webServer ficam em HTTP: nenhum dos dois é publicado nem roteado. - final apiTls = ApiTlsConfig.fromMap(Platform.environment); + // + // `carregar` e não `fromMap`: `run` é `async void` dentro do `runZonedGuarded` + // da #56, e uma exceção que escapasse daqui viraria "erro sem dono" no log, + // sem a mensagem que nomeia as três variáveis — ou, para TlsException, um + // "processo mantido" que sai com código 0. Em falha de configuração o + // processo escreve o diagnóstico no stderr e sai com 78 (EX_CONFIG). + final apiTls = ApiTlsConfig.carregar( + Platform.environment, + log: stderr.writeln, + exitFn: exit, + ); // Initialize Serverpod and connect it with your generated code. // @@ -39,10 +49,10 @@ void run(List args) async { args, Protocol(), Endpoints(), - securityContextConfig: apiTls == null + securityContextConfig: apiTls.context == null ? null - : SecurityContextConfig(apiServer: apiTls.toSecurityContext()), - apiServerAdapterFactory: apiTls == null ? null : mtlsApiServerAdapter, + : SecurityContextConfig(apiServer: apiTls.context), + apiServerAdapterFactory: apiTls.context == null ? null : mtlsApiServerAdapter, ); // O módulo serverpod_auth_idp não é inicializado: o acesso hoje é o token diff --git a/backend/sinalacs_server/lib/src/config/api_tls_config.dart b/backend/sinalacs_server/lib/src/config/api_tls_config.dart index bd178c3e..b52b154e 100644 --- a/backend/sinalacs_server/lib/src/config/api_tls_config.dart +++ b/backend/sinalacs_server/lib/src/config/api_tls_config.dart @@ -66,12 +66,69 @@ class ApiTlsConfig { /// 1.2. `withTrustedRoots: false` + `setTrustedCertificates(clientCa)`: só a /// CA do RPC assina clientes aceitos — um certificado válido de qualquer /// outra raiz é recusado. `setClientAuthorities` é o que o servidor anuncia - /// no pedido de certificado. Arquivo ausente ou ilegível lança aqui, no boot - /// — falha fechada. - SecurityContext toSecurityContext() => SecurityContext(withTrustedRoots: false) - ..useCertificateChain(certificatePath) - ..usePrivateKey(keyPath) - ..setTrustedCertificates(clientCaPath) - ..setClientAuthorities(clientCaPath) - ..minimumTlsProtocolVersion = TlsProtocolVersion.tls1_3; + /// no pedido de certificado. + /// + /// Arquivo ausente, ilegível ou do tipo errado (uma chave no lugar da CA) + /// vira `StateError` que NOMEIA a variável e o tipo do erro do SDK — nunca + /// o conteúdo do arquivo. É `StateError`, e não a `TlsException`/ + /// `FileSystemException` original, de propósito: o `runZonedGuarded` da #56 + /// classifica `TlsException` como E/S transitória e deixaria o processo + /// "mantido" até o event loop esvaziar e sair com código 0, sem listener e + /// sem diagnóstico. Quem chama no boot é [carregar]. + SecurityContext toSecurityContext() { + final ctx = SecurityContext(withTrustedRoots: false); + _etapa('API_TLS_CERT_PATH', certificatePath, + () => ctx.useCertificateChain(certificatePath)); + _etapa('API_TLS_KEY_PATH', keyPath, () => ctx.usePrivateKey(keyPath)); + _etapa('API_TLS_CLIENT_CA_PATH', clientCaPath, () { + ctx + ..setTrustedCertificates(clientCaPath) + ..setClientAuthorities(clientCaPath); + }); + ctx.minimumTlsProtocolVersion = TlsProtocolVersion.tls1_3; + return ctx; + } + + static void _etapa(String variavel, String caminho, void Function() acao) { + try { + acao(); + } catch (e) { + throw StateError( + '$variavel aponta para um arquivo ausente, ilegível ou do tipo errado ' + '($caminho): ${e.runtimeType}.', + ); + } + } + + /// O que `lib/server.dart` chama no boot: lê o ambiente, monta o contexto e, + /// em qualquer falha de configuração, escreve o diagnóstico em [log] e chama + /// [exitFn] com 78 (`EX_CONFIG`). Existe porque `run()` é `async void` dentro + /// do `runZonedGuarded` da #56: uma exceção que escapasse dali viraria "erro + /// sem dono (StateError)" no log — a mensagem que nomeia as três variáveis + /// nunca apareceria (achado 2 da revisão do checkpoint da #90). Sem as três + /// variáveis devolve `(null, null)` em silêncio: é o caminho HTTP do Render. + static ApiTlsBoot carregar( + Map environment, { + required void Function(String) log, + required Never Function(int) exitFn, + }) { + try { + final config = fromMap(environment); + if (config == null) return const ApiTlsBoot(config: null, context: null); + return ApiTlsBoot(config: config, context: config.toSecurityContext()); + } on StateError catch (e) { + log('[tls] configuração do apiServer inválida — o servidor não sobe: ' + '${e.message}'); + exitFn(78); + } + } +} + +/// Resultado de [ApiTlsConfig.carregar]: os dois nulos (HTTP) ou os dois +/// preenchidos (mTLS). Nunca um só. +class ApiTlsBoot { + const ApiTlsBoot({required this.config, required this.context}); + + final ApiTlsConfig? config; + final SecurityContext? context; } diff --git a/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart b/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart index e2bf378f..c5225a80 100644 --- a/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart +++ b/backend/sinalacs_server/lib/src/runtime/mtls_api_server.dart @@ -115,6 +115,27 @@ Future bindRequiringClientCertificate( await bindRequiringClientCertificateSocket(address, port, context, log: log), ); +/// `IOAdapter` cujo `close` fecha também o socket seguro. +/// +/// `HttpServer.listenOn` cria o servidor com `_closeServer = false` (SDK +/// 3.12.2, `_http/http_impl.dart`): `HttpServer.close` NÃO fecha o +/// `ServerSocket` que recebeu. Sem isto, o shutdown do Serverpod +/// (`RelicServer.close` → `HttpServer.close`) deixaria a 8080 aceitando +/// handshakes de um servidor morto e a porta ocupada (achado 1 da revisão do +/// checkpoint da #90; `close do adapter libera a porta` em +/// `test/unit/mtls_api_server_test.dart`). +class _MtlsIOAdapter extends IOAdapter { + _MtlsIOAdapter(super.server, this._socket); + + final SecureServerSocketAsServerSocket _socket; + + @override + Future close({bool force = false}) async { + await super.close(force: force); + await _socket.close(); + } +} + /// A `ApiServerAdapterFactory` passada ao `Serverpod(…)` em `lib/server.dart` /// quando `API_TLS_*` está definido. Sem contexto não há o que exigir: falha /// fechada em vez de cair em HTTP por este caminho. @@ -129,7 +150,8 @@ Future mtlsApiServerAdapter( 'sobe em HTTP pelo caminho padrão do Serverpod, não por esta fábrica.', ); } - return IOAdapter( - await bindRequiringClientCertificate(address, port, securityContext), + final socket = await bindRequiringClientCertificateSocket( + address, port, securityContext, ); + return _MtlsIOAdapter(HttpServer.listenOn(socket), socket); } diff --git a/backend/sinalacs_server/test/unit/api_tls_config_test.dart b/backend/sinalacs_server/test/unit/api_tls_config_test.dart index cdca6ca3..5c3e7528 100644 --- a/backend/sinalacs_server/test/unit/api_tls_config_test.dart +++ b/backend/sinalacs_server/test/unit/api_tls_config_test.dart @@ -86,6 +86,49 @@ void main() { }); }); + group('ApiTlsConfig.carregar (boot)', () { + Never sair(int codigo) => throw _Saiu(codigo); + + test('sem variável nenhuma: HTTP, sem log, sem sair', () { + final log = []; + final boot = ApiTlsConfig.carregar({}, log: log.add, exitFn: sair); + expect(boot.config, isNull); + expect(boot.context, isNull); + expect(log, isEmpty); + }); + + test('só duas das três: escreve a mensagem com as três variáveis e sai 78', () { + final log = []; + expect( + () => ApiTlsConfig.carregar( + {'API_TLS_CERT_PATH': '/x.crt', 'API_TLS_KEY_PATH': '/x.key'}, + log: log.add, exitFn: sair, + ), + throwsA(isA<_Saiu>().having((e) => e.codigo, 'codigo', 78)), + ); + expect(log.join('\n'), allOf( + contains('API_TLS_CERT_PATH'), contains('API_TLS_KEY_PATH'), + contains('API_TLS_CLIENT_CA_PATH'), contains('devem vir juntas'), + )); + }); + + test('arquivo inexistente: nomeia a variável e sai 78 (não cai em HTTP)', () { + final log = []; + expect( + () => ApiTlsConfig.carregar( + { + 'API_TLS_CERT_PATH': '/nao/existe.crt', + 'API_TLS_KEY_PATH': '/nao/existe.key', + 'API_TLS_CLIENT_CA_PATH': '/nao/existe-ca.crt', + }, + log: log.add, exitFn: sair, + ), + throwsA(isA<_Saiu>().having((e) => e.codigo, 'codigo', 78)), + ); + expect(log.join('\n'), contains('API_TLS_CERT_PATH')); + }); + }); + group('ApiTlsConfig.toSecurityContext', () { late Directory dir; late String caPath; @@ -133,6 +176,24 @@ void main() { ); }); + test('arquivo ilegível nomeia a variável e não vaza o conteúdo', () { + // Uma chave privada no lugar da CA: o SDK lança TlsException, que o + // runZonedGuarded da #56 classificaria como transitório e deixaria o + // processo sair com código 0 (revisão do checkpoint, achado 2). + expect( + () => ApiTlsConfig( + certificatePath: '${dir.path}/leaf.crt', + keyPath: '${dir.path}/leaf.key', + clientCaPath: '${dir.path}/leaf.key', + ).toSecurityContext(), + throwsA(isA().having( + (e) => e.message, 'message', + allOf(contains('API_TLS_CLIENT_CA_PATH'), contains('TlsException'), + isNot(contains('PRIVATE KEY'))), + )), + ); + }); + test('servidor com o contexto aceita TLS 1.3 e recusa TLS 1.2', () async { final server = await HttpServer.bindSecure( InternetAddress.loopbackIPv4, @@ -165,3 +226,8 @@ void main() { }); }); } + +class _Saiu implements Exception { + _Saiu(this.codigo); + final int codigo; +} diff --git a/backend/sinalacs_server/test/unit/mtls_api_server_test.dart b/backend/sinalacs_server/test/unit/mtls_api_server_test.dart index c97dad78..d6bb6cfd 100644 --- a/backend/sinalacs_server/test/unit/mtls_api_server_test.dart +++ b/backend/sinalacs_server/test/unit/mtls_api_server_test.dart @@ -176,6 +176,20 @@ void main() { ); }); + test('close do adapter libera a porta (fecha o SecureServerSocket, não só o HttpServer)', () async { + final adapter = await mtlsApiServerAdapter( + InternetAddress.loopbackIPv4, 0, config.toSecurityContext(), + ); + final porta = adapter.port; + await adapter.close(force: true); + // `HttpServer.listenOn` cria o servidor com `_closeServer = false`: o + // close dele NÃO fecha o socket de baixo. Se a porta continuar ocupada, o + // shutdown gracioso do Serverpod deixaria a 8080 aceitando handshakes de + // um servidor morto (revisão do checkpoint, achado 1). + final livre = await ServerSocket.bind(InternetAddress.loopbackIPv4, porta); + await livre.close(); + }); + test('devolve um Adapter na porta ligada, que exige o certificado', () async { final adapter = await mtlsApiServerAdapter( InternetAddress.loopbackIPv4, 0, config.toSecurityContext(), diff --git a/backend/sinalacs_server/test/unit/server_wiring_test.dart b/backend/sinalacs_server/test/unit/server_wiring_test.dart index b87d0565..5985012b 100644 --- a/backend/sinalacs_server/test/unit/server_wiring_test.dart +++ b/backend/sinalacs_server/test/unit/server_wiring_test.dart @@ -4,7 +4,8 @@ import 'package:test/test.dart'; /// O TLS do apiServer e a exigência do certificado de cliente são ligados /// JUNTOS em `lib/server.dart` (#90): o `SecurityContextConfig` e o -/// `apiServerAdapterFactory` saem do mesmo `apiTls`. Separá-los é o defeito que +/// `apiServerAdapterFactory` saem do mesmo `apiTls.context`, que +/// `ApiTlsConfig.carregar` montou (ou encerrou o processo com 78). Separá-los é o defeito que /// este teste acusa — um `SecurityContextConfig` sem a fábrica subiria em TLS /// sem exigir o certificado do Traefik, com todas as sondas de "está cifrado" /// verdes. Lê o texto-fonte, como `endpoint_auth_posture_test.dart`. @@ -16,11 +17,11 @@ void main() { final bloco = construtor!.group(0)!.replaceAll(RegExp(r'\s+'), ' '); expect( bloco, - contains('SecurityContextConfig(apiServer: apiTls.toSecurityContext())'), + contains('SecurityContextConfig(apiServer: apiTls.context)'), ); expect( bloco, - contains('apiServerAdapterFactory: apiTls == null ? null : mtlsApiServerAdapter'), + contains('apiServerAdapterFactory: apiTls.context == null ? null : mtlsApiServerAdapter'), ); }); } From e61981b4dabc7fa6bd49e2434e4656b13cedcc0c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 18:04:56 -0400 Subject: [PATCH 09/12] feat(traefik): apresenta certificado de cliente ao backend; healthcheck autenticado; backend exige a CA do RPC (#90) --- docker-compose.yml | 30 +++++++++++++++++++-------- infra/docker/traefik/dynamic/tls.yaml | 19 ++++++++++++----- scripts/qa/e2e_stack.sh | 9 +++++++- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 66f3111b..3083c857 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -179,17 +179,28 @@ services: # Prazo (s) para reconhecer um alerta vermelho antes do escalonamento # (issue #44). Faixa válida 30..3600; fora dela o servidor não sobe. RED_ALERT_ESCALATION_SECONDS: "${RED_ALERT_ESCALATION_SECONDS:-300}" - # TLS 1.3 na 8080 interna (RNF04, issue #78): o Traefik fala com o backend - # por TLS verificado, não mais em texto claro. A folha é assinada pela CA - # do RPC e gerada pelo traefik-init em runtime/backend/. Fora da stack - # local (Render) as duas ficam vazias e o servidor segue em HTTP atrás do - # TLS da borda; só uma delas definida é erro de boot. + # TLS 1.3 MÚTUO na 8080 interna (RNF04, issues #78 e #90): o Traefik fala + # com o backend por TLS verificado nos dois sentidos. A folha do backend é + # assinada pela CA do RPC e gerada pelo traefik-init em runtime/backend/; + # a CA (API_TLS_CLIENT_CA_PATH) é a que assina os certificados de CLIENTE + # aceitos — sem um deles o handshake é recusado, antes de qualquer byte + # HTTP (o Grupo E do scripts/qa/tls_invariants.sh prova). Fora da stack + # local (Render) as três ficam vazias e o servidor segue em HTTP atrás do + # TLS da borda; um subconjunto delas é erro de boot (sai com 78 e nomeia + # as três): TLS sem exigir o certificado não é mais uma configuração + # válida. API_TLS_CERT_PATH: /app/rpc-certs/backend.crt API_TLS_KEY_PATH: /app/rpc-certs/backend.key - # O HEALTHCHECK do Dockerfile fala http (é o do Render); aqui a 8080 é TLS, - # então a stack local o sobrepõe com os mesmos tempos. + API_TLS_CLIENT_CA_PATH: /app/rpc-certs/ca.crt + # O HEALTHCHECK do Dockerfile fala http (é o do Render); aqui a 8080 é TLS + # mútuo, então a stack local o sobrepõe com os mesmos tempos e com a folha + # de CLIENTE do healthcheck (#90): distinta da do Traefik, CN + # sinalacs-healthcheck, 90 dias, regerada pelo traefik-init em + # runtime/backend/. Sem ela o próprio backend recusa o curl e o container + # nunca fica healthy — e o Traefik tira um container unhealthy da rota + # (404 em tudo), sem mensagem que diga por quê. healthcheck: - test: ["CMD-SHELL", "curl -fsS -X POST -H 'Content-Type: application/json' -d '{}' --cacert /app/rpc-certs/ca.crt https://localhost:8080/health/check || exit 1"] + test: ["CMD-SHELL", "curl -fsS -X POST -H 'Content-Type: application/json' -d '{}' --cacert /app/rpc-certs/ca.crt --cert /app/rpc-certs/healthcheck.crt --key /app/rpc-certs/healthcheck.key https://localhost:8080/health/check || exit 1"] interval: 30s timeout: 3s start_period: 20s @@ -211,7 +222,8 @@ services: # A porta INTERNA do container, que continua sendo 8080 — sem publicação # no host. Desde a issue #78 o processo escuta TLS 1.3 ali (API_TLS_*), e # o Traefik disca por https verificando a folha pelo transporte - # `backend-tls` do dynamic/tls.yaml. + # `backend-tls` do dynamic/tls.yaml; desde a #90 esse transporte também + # apresenta o certificado de cliente do Traefik, que o backend exige. - traefik.http.services.sinalacs.loadbalancer.server.port=8080 - traefik.http.services.sinalacs.loadbalancer.server.scheme=https - traefik.http.services.sinalacs.loadbalancer.serverstransport=backend-tls@file diff --git a/infra/docker/traefik/dynamic/tls.yaml b/infra/docker/traefik/dynamic/tls.yaml index b35d0fb8..ab1d1398 100644 --- a/infra/docker/traefik/dynamic/tls.yaml +++ b/infra/docker/traefik/dynamic/tls.yaml @@ -23,14 +23,23 @@ tls: http: serversTransports: - # Traefik → backend (RNF04, issue #78): TLS verificado contra a mesma CA do - # RPC que assina a folha do backend (runtime/backend/, gerada pelo - # traefik-init). O Traefik disca pelo IP do container, então o nome - # verificado é fixado aqui e tem de estar no SAN daquela folha. Sem - # `insecureSkipVerify` em lugar nenhum: o Grupo E do tls_invariants.sh cobra. + # Traefik → backend (RNF04, issues #78 e #90): TLS MÚTUO. O Traefik verifica + # a folha do backend contra a CA do RPC (runtime/backend/, gerada pelo + # traefik-init) — disca pelo IP do container, então o nome verificado é + # fixado aqui e tem de estar no SAN daquela folha — e APRESENTA o seu + # próprio certificado de cliente (`certificates`), que o backend exige no + # handshake; sem ele a 8080 recusa antes de qualquer byte HTTP e o Traefik + # devolve 5xx para todos os apps. Sem `insecureSkipVerify` em lugar nenhum: + # o Grupo E do tls_invariants.sh cobra, e cobra também este `certificates`. # O mínimo 1.3 deste trecho é imposto pelo próprio backend # (ApiTlsConfig.toSecurityContext). backend-tls: serverName: sinalacs-serverpod rootCAs: - /certs/ca.crt + # #90: a folha de CLIENTE do Traefik, emitida pelo traefik-init com a CA + # do RPC (EKU clientAuth, CN sinalacs-traefik; chave 600, só o Traefik, + # que roda como root, a lê). + certificates: + - certFile: /certs/traefik-client.crt + keyFile: /certs/traefik-client.key diff --git a/scripts/qa/e2e_stack.sh b/scripts/qa/e2e_stack.sh index 62f374c7..dc5fcf2b 100755 --- a/scripts/qa/e2e_stack.sh +++ b/scripts/qa/e2e_stack.sh @@ -46,7 +46,14 @@ case "${1:-}" in # runtime/backend/ vazio e o backend entraria em laço de restart. A CA já foi # conferida acima, então o init só reaproveita a CA e regera as folhas. dc up traefik-init >"$log" 2>&1 || falhou 'o traefik-init não gerou as folhas do RPC' - for f in infra/docker/traefik/runtime/backend/backend.crt infra/docker/traefik/runtime/backend/backend.key; do + # Desde a #90 o init emite também as duas folhas de CLIENTE do mTLS da 8080: + # a do Traefik (runtime/certs/) e a do healthcheck (runtime/backend/). Sem a + # primeira o Traefik é recusado pelo backend (5xx em tudo); sem a segunda o + # container nunca fica healthy — e o laço de espera abaixo estouraria sem + # dizer por quê. + for f in infra/docker/traefik/runtime/backend/backend.crt infra/docker/traefik/runtime/backend/backend.key \ + infra/docker/traefik/runtime/backend/healthcheck.crt infra/docker/traefik/runtime/backend/healthcheck.key \ + infra/docker/traefik/runtime/certs/traefik-client.crt infra/docker/traefik/runtime/certs/traefik-client.key; do [[ -f "$f" ]] || falhou "falta $f depois do traefik-init" done dc up -d --build --force-recreate --no-deps serverpod gorush traefik mosquitto >"$log" 2>&1 \ From 07a4bec5025dccbaff0e778afe2afeceea820a56 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 18:13:30 -0400 Subject: [PATCH 10/12] test(tls): Grupo E sonda a 8080 de fora da rede: sem certificado e de outra CA recusados, com certificado 200 (#90) --- scripts/qa/tls_invariants.sh | 176 ++++++++++++++++++++++++++++------- 1 file changed, 144 insertions(+), 32 deletions(-) diff --git a/scripts/qa/tls_invariants.sh b/scripts/qa/tls_invariants.sh index c35c56e1..0157d461 100755 --- a/scripts/qa/tls_invariants.sh +++ b/scripts/qa/tls_invariants.sh @@ -12,7 +12,7 @@ # ./scripts/qa/tls_invariants.sh # # Espera a stack de pé (`./scripts/qa/e2e.sh --keep` a deixa assim). O grupo D não -# precisa dela; os outros três exigem. +# precisa dela, nem as asserções estáticas E4, E7 e E9; os demais exigem. # # Cada exigência vem com o CONTROLE POSITIVO que a impede de passar por acidente. # "O TLS 1.2 falha" também é verdade com a stack no chão, e "nada publicado na @@ -144,51 +144,163 @@ else falha "o measure_latency não recusou o host em texto claro como o esperado: exit $rc, stdout com ${#ml_out} byte(s), stderr: $ml_err" fi -echo '== Grupo E: o Traefik fala com o backend por TLS 1.3, e só por ele ==' -# O trecho que o Grupo C não vê: a 8080 INTERNA, na rede do Compose. Ela não é -# publicada, então as sondas rodam dentro do próprio container do backend, com o -# `curl` que a imagem já traz para o HEALTHCHECK. Mesmo desenho dos grupos B e F: -# o 1.3 completando (E2) é o controle positivo do texto claro (E1) e do 1.2 (E3) -# recusados — sem ele, um container parado faria os dois passarem. -backend_ca='/app/rpc-certs/ca.crt' -if ! docker exec "$backend_container" true >/dev/null 2>&1; then - falha "não deu para entrar no container $backend_container — suba a stack antes de ler o Grupo E" +echo '== Grupo E: o Traefik fala com o backend por TLS 1.3 MÚTUO, e só por ele ==' +# O trecho que o Grupo C não vê: a 8080 INTERNA, na rede do Compose. Desde a #90 +# o backend EXIGE certificado de cliente assinado pela CA do RPC, recusado no +# handshake — e o processo do modelo de ameaça da issue é "qualquer container na +# rede do Compose". Por isso as sondas rodam de FORA do container do backend, num +# container efêmero nessa rede, com a imagem que a stack já construiu +# (sinalacs/server:local traz o mesmo curl do HEALTHCHECK; `--entrypoint curl`). +# `--user` com o uid do host: no runner do CI o usuário é 1001 e o $tmp (mktemp, +# 700) não seria legível pelo uid 1000 da imagem; runtime/backend/ é 644. +# +# Desenho dos grupos B e F: E2 (com certificado, TLS 1.3, 200) é o controle +# positivo de TODAS as recusas (E1, E3, E5, E6) — sem ele, um container parado +# faria todas passarem. E o Grupo A é o controle positivo DO TRAEFIK: o 200 em +# https://localhost/health/check só existe porque o Traefik apresentou a folha +# traefik-client.crt (E4/E7) — sem ela, 5xx em tudo. +# +# As recusas afirmam a AUSÊNCIA de resposta HTTP (sem `< HTTP/`, sem `"status"`, +# `HTTP 000`), nunca o código de saída do curl (35 ou 56, conforme o OpenSSL) nem +# o texto do `openssl s_client`: no TLS 1.3 o cliente conclui o lado dele antes +# de o servidor validar o certificado, e o `s_client` imprime +# `CONNECTION ESTABLISHED` MESMO recusado (medido na PoC da #90). +rpc_backend_dir='infra/docker/traefik/runtime/backend' +rpc_certs_dir='infra/docker/traefik/runtime/certs' +e_tmp="$(mktemp -d)" +trap 'rm -f "$ml_err_file"; rm -rf "$e_tmp"' EXIT +rede="$(docker inspect "$backend_container" -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null)" +# sonda_8080 : runtime/backend em /c (CA + folha do healthcheck), +# $e_tmp em /f (o certificado "de fora", gerado abaixo). +sonda_8080() { + docker run --rm --network "$rede" --entrypoint curl --user "$(id -u):$(id -g)" \ + -v "$PWD/$rpc_backend_dir:/c:ro" -v "$e_tmp:/f:ro" \ + sinalacs/server:local -sS -v --max-time 5 -X POST -H 'Content-Type: application/json' -d '{}' \ + -w '\nHTTP %{http_code}\n' "$@" 2>&1 +} +# "recusado" = nenhuma linha HTTP chegou. `HTTP 000` é o -w do curl sem resposta. +recusado() { ! printf '%s' "$1" | grep -qE '^< HTTP/|"status"' && printf '%s' "$1" | grep -q 'HTTP 000'; } +com_cert=(--tlsv1.3 --cacert /c/ca.crt --cert /c/healthcheck.crt --key /c/healthcheck.key) +url_8080="https://${backend_container}:8080/health/check" + +if [[ -z "$rede" ]] || ! docker image inspect sinalacs/server:local >/dev/null 2>&1; then + falha "não deu para achar a rede do $backend_container ou a imagem sinalacs/server:local — suba a stack antes de ler o Grupo E" else - e_claro="$(docker exec "$backend_container" curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' -d '{}' \ - http://localhost:8080/health/check 2>&1)" - if printf '%s' "$e_claro" | grep -q '"status"'; then + e1="$(sonda_8080 "http://${backend_container}:8080/health/check")" + if printf '%s' "$e1" | grep -q '"status"'; then falha 'a 8080 interna atende em texto claro — o `API_TLS_*` do serviço serverpod deixou de valer' else - ok 'a 8080 interna não entrega o backend a um pedido em texto claro' + ok 'E1 — a 8080 interna não entrega o backend a um pedido em texto claro' fi - e13="$(docker exec "$backend_container" curl -sS -v --max-time 5 --tlsv1.3 --cacert "$backend_ca" \ - -X POST -H 'Content-Type: application/json' -d '{}' https://localhost:8080/health/check 2>&1)" - if printf '%s' "$e13" | grep -q '"status"' && printf '%s' "$e13" | grep -q 'TLSv1.3'; then - ok 'a 8080 interna responde por TLS 1.3 com a cadeia verificada pela CA do RPC' + e2="$(sonda_8080 "${com_cert[@]}" "$url_8080")" + e2_ok=0 + if printf '%s' "$e2" | grep -q '"status"' && printf '%s' "$e2" | grep -q 'TLSv1.3' && printf '%s' "$e2" | grep -q 'HTTP 200'; then + e2_ok=1 + ok 'E2 — com certificado de cliente da CA do RPC, a 8080 interna responde 200 por TLS 1.3 (controle positivo)' else - falha "a 8080 interna não respondeu por TLS 1.3 verificado: $(printf '%s' "$e13" | grep -E '^\* |status' | tail -3 | tr '\n' ' ')" + falha "E2 — a 8080 interna não respondeu ao cliente COM certificado por TLS 1.3: $(printf '%s' "$e2" | grep -E '^\* |status|HTTP ' | tail -3 | tr '\n' ' ')" fi - if docker exec "$backend_container" curl -sS -o /dev/null --max-time 5 --tlsv1.2 --tls-max 1.2 --cacert "$backend_ca" \ - -X POST -H 'Content-Type: application/json' -d '{}' https://localhost:8080/health/check >/dev/null 2>&1; then - falha 'a 8080 interna aceitou TLS 1.2 — o `minimumTlsProtocolVersion` do ApiTlsConfig deixou de valer' + e3="$(sonda_8080 --tlsv1.2 --tls-max 1.2 --cacert /c/ca.crt --cert /c/healthcheck.crt --key /c/healthcheck.key "$url_8080")" + if printf '%s' "$e3" | grep -q '"status"'; then + falha 'E3 — a 8080 interna aceitou TLS 1.2 (com certificado válido) — o `minimumTlsProtocolVersion` do ApiTlsConfig deixou de valer' else - ok 'a 8080 interna recusa TLS 1.2' + ok 'E3 — a 8080 interna recusa TLS 1.2 mesmo com certificado válido' + fi + + # Certificado "de fora": CA própria e uma folha dela com o MESMO CN do Traefik, + # de propósito — a recusa tem de vir da cadeia, não do nome. + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -keyout "$e_tmp/fora-ca.key" -out "$e_tmp/fora-ca.crt" \ + -subj '/CN=ca-de-fora' >/dev/null 2>&1 + openssl req -newkey rsa:2048 -nodes -keyout "$e_tmp/fora.key" -out "$e_tmp/fora.csr" -subj '/CN=sinalacs-traefik' >/dev/null 2>&1 + printf 'extendedKeyUsage=clientAuth\n' >"$e_tmp/fora.ext" + openssl x509 -req -days 2 -in "$e_tmp/fora.csr" -CA "$e_tmp/fora-ca.crt" -CAkey "$e_tmp/fora-ca.key" \ + -CAcreateserial -extfile "$e_tmp/fora.ext" -out "$e_tmp/fora.crt" >/dev/null 2>&1 + chmod 755 "$e_tmp"; chmod 644 "$e_tmp"/* + + if [[ "$e2_ok" -eq 0 ]]; then + falha 'E5/E6 — sem o controle positivo E2, a recusa seria verdade por acidente (stack no chão recusa tudo)' + else + e5="$(sonda_8080 --tlsv1.3 --cacert /c/ca.crt "$url_8080")" + if recusado "$e5"; then + ok 'E5 — SEM certificado de cliente, a 8080 interna recusa no handshake (nenhuma linha HTTP) — o caso negativo da #90' + else + falha "E5 — a 8080 interna atendeu um cliente SEM certificado — o requireClientCertificate do mtls_api_server.dart ou a API_TLS_CLIENT_CA_PATH deixaram de valer: $(printf '%s' "$e5" | grep -E '^< HTTP|status|HTTP ' | tail -2 | tr '\n' ' ')" + fi + + e6="$(sonda_8080 --tlsv1.3 --cacert /c/ca.crt --cert /f/fora.crt --key /f/fora.key "$url_8080")" + if recusado "$e6"; then + ok 'E6 — certificado de OUTRA CA (mesmo CN do Traefik) é recusado no handshake' + else + falha "E6 — a 8080 interna aceitou certificado de outra CA — o contexto confia em mais do que a CA do RPC: $(printf '%s' "$e6" | grep -E 'status|HTTP ' | tail -2 | tr '\n' ' ')" + fi + + # E8 — o bloqueio não pode virar DoS: handshakes recusados chegam ao stream do + # listener como erro; 20 recusas seguidas e, depois, o cliente legítimo ainda + # recebe 200, sem reinício do processo. + for _ in $(seq 1 10); do + sonda_8080 --tlsv1.3 --cacert /c/ca.crt "$url_8080" >/dev/null 2>&1 + sonda_8080 --tlsv1.3 --cacert /c/ca.crt --cert /f/fora.crt --key /f/fora.key "$url_8080" >/dev/null 2>&1 + done + e8="$(sonda_8080 "${com_cert[@]}" "$url_8080")" + reinicios="$(docker inspect -f '{{.RestartCount}}' "$backend_container" 2>/dev/null)" + if printf '%s' "$e8" | grep -q '"status"' && printf '%s' "$e8" | grep -q 'HTTP 200' && [[ "$reinicios" == 0 ]]; then + ok 'E8 — 20 handshakes recusados seguidos não derrubam o listener: o cliente com certificado ainda recebe 200 e RestartCount=0' + else + falha "E8 — handshakes recusados derrubaram o listener da 8080 (RestartCount=$reinicios) — o handleError do SecureServerSocketAsServerSocket ou o tratamento do SDK deixaram de valer: $(printf '%s' "$e8" | grep -E 'status|HTTP ' | tail -2 | tr '\n' ' ')" + fi fi fi -# Estática, não precisa da stack: o Traefik só autentica o backend se verificar a -# cadeia. Um `insecureSkipVerify` deixaria todas as sondas acima verdes — o -# trecho continua cifrado — com o backend sem autenticação nenhuma. -# Só linhas de configuração: o comentário do dynamic/tls.yaml cita a palavra. +# E4 — estática, não precisa da stack: o Traefik só autentica o backend se +# verificar a cadeia, e só é autenticado por ele se apresentar a folha. Um +# `insecureSkipVerify` deixaria as sondas acima verdes — o trecho continua +# cifrado — com o backend sem autenticação nenhuma. Só linhas de configuração: +# o comentário do dynamic/tls.yaml cita a palavra. +transporte="$(awk '/^ backend-tls:/{f=1;next} f&&/^ [^ ]/{f=0} f' infra/docker/traefik/dynamic/tls.yaml)" if grep -rniE '^[^#]*insecureSkipVerify' infra/docker/traefik/dynamic docker-compose.yml >/dev/null 2>&1; then - falha 'há `insecureSkipVerify` na configuração do Traefik — o trecho até o backend deixou de verificar a cadeia' -elif grep -q 'loadbalancer.server.scheme=https' docker-compose.yml \ - && grep -q 'loadbalancer.serverstransport=backend-tls@file' docker-compose.yml; then - ok 'o Traefik disca o backend por https pelo transporte backend-tls (rootCAs), sem insecureSkipVerify' + falha 'E4 — há `insecureSkipVerify` na configuração do Traefik — o trecho até o backend deixou de verificar a cadeia' +elif ! grep -q 'loadbalancer.server.scheme=https' docker-compose.yml \ + || ! grep -q 'loadbalancer.serverstransport=backend-tls@file' docker-compose.yml; then + falha 'E4 — o router sinalacs não usa `server.scheme=https` com `serverstransport=backend-tls@file` no docker-compose.yml' +elif ! printf '%s' "$transporte" | grep -q 'serverName: sinalacs-serverpod' \ + || ! printf '%s' "$transporte" | grep -q '/certs/ca.crt'; then + falha 'E4 — o transporte backend-tls perdeu `serverName`/`rootCAs` — o Traefik deixou de autenticar o backend' +elif ! printf '%s' "$transporte" | grep -q 'certFile: /certs/traefik-client.crt' \ + || ! printf '%s' "$transporte" | grep -q 'keyFile: /certs/traefik-client.key'; then + falha 'E4 — o transporte backend-tls não apresenta `certificates` (traefik-client.crt/key) — o Traefik não se autentica no backend (#90)' +elif ! grep -qE '^\s+API_TLS_CLIENT_CA_PATH:' docker-compose.yml; then + falha 'E4 — o serviço serverpod não define API_TLS_CLIENT_CA_PATH — o backend não exige certificado de cliente (#90)' +else + ok 'E4 — o Traefik disca o backend por https pelo transporte backend-tls (serverName, rootCAs, certificates), sem insecureSkipVerify, e o backend exige a CA' +fi + +# E7 — nos arquivos gerados: a folha que o Traefik apresenta é um certificado de +# CLIENTE desta CA. Uma folha serverAuth reaproveitada é recusada pelo BoringSSL +# do backend e o Traefik devolve 5xx em tudo, sem dizer por quê. +if [[ ! -f "$rpc_certs_dir/traefik-client.crt" ]]; then + falha "E7 — $rpc_certs_dir/traefik-client.crt não existe — o traefik-init da #90 não rodou" +elif ! openssl x509 -in "$rpc_certs_dir/traefik-client.crt" -noout -ext extendedKeyUsage 2>/dev/null | grep -q 'TLS Web Client Authentication'; then + falha 'E7 — traefik-client.crt não tem EKU clientAuth' +elif ! openssl verify -CAfile "$rpc_ca" "$rpc_certs_dir/traefik-client.crt" >/dev/null 2>&1; then + falha 'E7 — traefik-client.crt não é assinado pela CA do RPC' +else + ok 'E7 — traefik-client.crt é certificado de cliente (clientAuth) assinado pela CA do RPC' +fi + +# E9 — a folha do healthcheck é curta (90 dias, #90) e distinta da do Traefik. +# Vencida, o backend recusa o próprio healthcheck, o container fica unhealthy e +# o Traefik o tira da rota — sem mensagem que diga por quê. +hc="$rpc_backend_dir/healthcheck.crt" +if [[ ! -f "$hc" ]]; then + falha "E9 — $hc não existe — o traefik-init da #90 não rodou" +elif ! openssl x509 -in "$hc" -noout -checkend 604800 >/dev/null 2>&1; then + falha 'E9 — a folha do healthcheck vence em menos de 7 dias: `docker compose up -d --force-recreate traefik-init serverpod` a regera; sem isso o backend fica unhealthy sem dizer por quê' +elif [[ "$(openssl x509 -in "$hc" -noout -serial 2>/dev/null)" == "$(openssl x509 -in "$rpc_certs_dir/traefik-client.crt" -noout -serial 2>/dev/null)" ]]; then + falha 'E9 — a folha do healthcheck é a mesma do Traefik — têm de ser distintas (CN, chave e validade próprias)' else - falha 'o router sinalacs não usa `server.scheme=https` com `serverstransport=backend-tls@file` no docker-compose.yml' + ok 'E9 — a folha do healthcheck vale por mais de 7 dias e é distinta da do Traefik' fi echo '== Grupo F: o broker MQTT fala TLS 1.3, e só ele ==' From d5202ca581f90a518172bcf3c78d8e9790bdd014 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 18:40:27 -0400 Subject: [PATCH 11/12] =?UTF-8?q?docs:=20mTLS=20Traefik=20=E2=86=94=20back?= =?UTF-8?q?end=20entregue=20na=20stack=20local;=20fork=20pinado=20do=20Ser?= =?UTF-8?q?verpod=20e=20issue=20upstream=20registrados=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/validacao-e2e/SKILL.md | 2 +- CLAUDE.md | 2 +- PROGRESS.md | 1 + backend/CLAUDE.md | 2 +- backend/DEPLOY.md | 9 +++++---- backend/patches/README.md | 3 ++- docs/auditoria-prd/2026-10-15-lacunas-prd.md | 6 +++--- spec/stack.md | 2 ++ 8 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.claude/skills/validacao-e2e/SKILL.md b/.claude/skills/validacao-e2e/SKILL.md index f58d5393..c94e5faf 100644 --- a/.claude/skills/validacao-e2e/SKILL.md +++ b/.claude/skills/validacao-e2e/SKILL.md @@ -14,7 +14,7 @@ description: Como validar a conexão real dos apps Flutter com o backend do Sina ./scripts/qa/e2e.sh --emulator --full # + a bateria integration_test inteira dos três apps ``` -The script brings up Docker Compose, waits for the healthcheck, applies the seed, runs `scripts/dev/sync_dev_ca.sh` (copies the **two** development CAs — the broker's and the RPC's — into `apps/acs/assets/certs/` and `apps/patient/assets/certs/`, which are gitignored and regenerated), runs `scripts/qa/tls_invariants.sh` (the four groups that measure RNF04/L-08 on the live stack: HTTPS with the dev CA, TLS 1.3 only, no cleartext path to the RPC, and the script-side https host rule), and then runs each app's `tool/live_check.dart`. Those scripts run on the plain Dart VM — no emulator — using the apps' own network code: the patient one covers health/login/triage/alert/idempotency, and the ACS one covers the full cycle including the MQTT/TLS subscription and `visits.sync`. Both tools read the RPC CA from `infra/docker/traefik/runtime/certs/ca.crt` themselves, and neither takes an https host for granted — a missing CA stops the leg with the path in the message instead of reporting "the backend did not answer". +The script brings up Docker Compose, waits for the healthcheck, applies the seed, runs `scripts/dev/sync_dev_ca.sh` (copies the **two** development CAs — the broker's and the RPC's — into `apps/acs/assets/certs/` and `apps/patient/assets/certs/`, which are gitignored and regenerated), runs `scripts/qa/tls_invariants.sh` (the six groups that measure RNF04/L-08 on the live stack: HTTPS with the dev CA, TLS 1.3 only, no cleartext path to the RPC, the script-side https host rule, the Traefik → backend hop with mutual TLS (Group E, #90: probes from another container on the Compose network — no client certificate and a foreign CA are refused at the handshake, the RPC CA's certificate gets 200, and a burst of refused handshakes does not take the listener down) and the broker on TLS 1.3 only), and then runs each app's `tool/live_check.dart`. Those scripts run on the plain Dart VM — no emulator — using the apps' own network code: the patient one covers health/login/triage/alert/idempotency, and the ACS one covers the full cycle including the MQTT/TLS subscription and `visits.sync`. Both tools read the RPC CA from `infra/docker/traefik/runtime/certs/ca.crt` themselves, and neither takes an https host for granted — a missing CA stops the leg with the path in the message instead of reporting "the backend did not answer". `integration_test/` in each app holds the on-device version, excluded from `flutter test` by construction. Run it against an already-running emulator with `--dart-define=SINALACS_HOST=https://10.0.2.2/` (and, for the ACS, `SINALACS_MQTT_HOST=10.0.2.2` plus `SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD"` — without the password the suite fails in `setUpAll` with the command you need). `scripts/qa/e2e.sh --emulator` already passes all three. diff --git a/CLAUDE.md b/CLAUDE.md index a3b8e0e3..9ab7d44a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS 1.3 on the RPC via Traefik (RNF04), on the Traefik → backend hop (one-way TLS 1.3 with the chain verified; no mTLS, Serverpod cannot request a client certificate — #78) and on the broker (TLS 1.3 only, password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh` and `scripts/qa/tls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), the ACS refresh token (rotating, idle window 2 h, absolute cap 8 h from the password+TOTP login, family revoked on reuse/other device/inactive account; the app keeps no password) bound to the Keystore: an AES-256-GCM + RSA-OAEP envelope whose RSA key requires user authentication on every use, so the token is decrypted only through `BiometricPrompt` + `CryptoObject` on cold start (API 30+ also accepts the device PIN; on API 30+ a newly enrolled fingerprint does **not** invalidate the key; proved on the API 36 emulator only, `scripts/qa/acs_keystore_bound_e2e.sh`), plus the 30 s background lock, which stays an interface gate over the token held in RAM and "Sair e encerrar o turno", `FLAG_SECURE` on the ACS window in every build (only a debug build made with `./scripts/dev/run_acs.sh --build --captura` opens it, by a compile-time constant that release refuses), release builds that refuse the debug key unless explicitly allowed (a release keystore is still to be provisioned), the UBS call button, the per-owner offline visit queue (schema v7, legacy rows quarantined and uploaded with unknown authorship, deferred upload token, wipe only when everything is sent) the closing of the `login()` race (`BackendClient._exclusive`), the ACS's resolution of a visit sync conflict (#79: persisted conflict with the server copy, side-by-side screen, keep mine / edit and send / keep the server's, `visits.resolveConflict`), the remote device wipe ordered by the backoffice (#81: the coordinator of the UBS or the admin orders it from the ACS's "Aparelhos" dialog, the app wipes at startup, resume, network return or refresh, the SQLCipher key is rotated by event and never by calendar; the device's per-device MQTT client certificate is still missing, tracked by the continuation issue to be opened), the telemetry of the PRD §1.3 events (#83: `alert_triggered`, `alert_received_acs`, `sync_attempt`, `sync_result`, `visit_registered` in `telemetry_events`, patient only as an HMAC pseudonym, offline-queued on the ACS device and sent by `telemetry.ingest`, report by `bin/telemetry_report.dart`; DPO review pending), plus the backoffice account management (#43: listing, ACS create/link/activate/deactivate and password/MFA reset, staff MFA reset with the activation code emitted by the screen, and the coordinator's audit scoped to his UBS — every operation audited, refusals included). Still missing: invalidation of the token key by a newly enrolled fingerprint on API 30+ and a proof of the Keystore binding on API 24–29 (the SQLCipher key and the deferred upload token stay unbound by design), a refresh token for the patient (OTP stays 1 h), iOS, a physical-device proof on Android ≤ 8.1 (API 24–27) (the emulator on API 24 and 27 showed no theme breakage; see `PROGRESS.md`, section Tema em API 24–27), background (OS-level) upload of visits left on the device (WorkManager is outside `spec/stack.md`), unblocking of a queue whose 7-day upload token expired or was refused (it needs the owner to log in again and blocks the wipe) and LGPD purge of an owner who never returns (see `PROGRESS.md`, section Fila de visitas por dono e corrida do login), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), creating coordinator accounts and assigning their `ubsId` through the UI (a coordinator still exists only in the seeds and by SQL; #43 covers the ACS lifecycle and the MFA resets), the ACS's own password change (a lost password is reset by the coordination), the audit trail's append-only enforcement against the database superuser (since #82 a per-statement trigger refuses `UPDATE`/`DELETE`/`TRUNCATE` on `audit_logs`, `audit_exports` and `audit_chain_checkpoints` and `PUBLIC` lacks those privileges, but the backend still connects as the superuser/owner, which bypasses both — role separation is #96), a real file download of the CSV export (it goes through the clipboard), the owner's acceptance of the green "Cadeia íntegra" exception to `spec/ui_design.md` and the data-protection officer's review of the export (`PROGRESS.md`, section #45), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). The data-subject request handling (#42) is delivered; the validation of its anonymization rules with the data-protection officer is still open (`PROGRESS.md`). +SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS 1.3 on the RPC via Traefik (RNF04), on the Traefik → backend hop (mutual TLS 1.3 since #90: the Traefik verifies the backend's leaf and presents its own client certificate through `serversTransport.certificates`, and the backend requires a client certificate signed by the RPC dev CA, refusing anyone without one at the handshake — Serverpod is consumed through a pinned fork, `hbgit/serverpod`, because 3.4.13 and 4.x bind the API server with no way to require a client certificate; `backend/patches/README.md`) and on the broker (TLS 1.3 only, password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh` and `scripts/qa/tls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), the ACS refresh token (rotating, idle window 2 h, absolute cap 8 h from the password+TOTP login, family revoked on reuse/other device/inactive account; the app keeps no password) bound to the Keystore: an AES-256-GCM + RSA-OAEP envelope whose RSA key requires user authentication on every use, so the token is decrypted only through `BiometricPrompt` + `CryptoObject` on cold start (API 30+ also accepts the device PIN; on API 30+ a newly enrolled fingerprint does **not** invalidate the key; proved on the API 36 emulator only, `scripts/qa/acs_keystore_bound_e2e.sh`), plus the 30 s background lock, which stays an interface gate over the token held in RAM and "Sair e encerrar o turno", `FLAG_SECURE` on the ACS window in every build (only a debug build made with `./scripts/dev/run_acs.sh --build --captura` opens it, by a compile-time constant that release refuses), release builds that refuse the debug key unless explicitly allowed (a release keystore is still to be provisioned), the UBS call button, the per-owner offline visit queue (schema v7, legacy rows quarantined and uploaded with unknown authorship, deferred upload token, wipe only when everything is sent) the closing of the `login()` race (`BackendClient._exclusive`), the ACS's resolution of a visit sync conflict (#79: persisted conflict with the server copy, side-by-side screen, keep mine / edit and send / keep the server's, `visits.resolveConflict`), the remote device wipe ordered by the backoffice (#81: the coordinator of the UBS or the admin orders it from the ACS's "Aparelhos" dialog, the app wipes at startup, resume, network return or refresh, the SQLCipher key is rotated by event and never by calendar; the device's per-device MQTT client certificate is still missing, tracked by the continuation issue to be opened), the telemetry of the PRD §1.3 events (#83: `alert_triggered`, `alert_received_acs`, `sync_attempt`, `sync_result`, `visit_registered` in `telemetry_events`, patient only as an HMAC pseudonym, offline-queued on the ACS device and sent by `telemetry.ingest`, report by `bin/telemetry_report.dart`; DPO review pending), plus the backoffice account management (#43: listing, ACS create/link/activate/deactivate and password/MFA reset, staff MFA reset with the activation code emitted by the screen, and the coordinator's audit scoped to his UBS — every operation audited, refusals included). Still missing: invalidation of the token key by a newly enrolled fingerprint on API 30+ and a proof of the Keystore binding on API 24–29 (the SQLCipher key and the deferred upload token stay unbound by design), a refresh token for the patient (OTP stays 1 h), iOS, a physical-device proof on Android ≤ 8.1 (API 24–27) (the emulator on API 24 and 27 showed no theme breakage; see `PROGRESS.md`, section Tema em API 24–27), background (OS-level) upload of visits left on the device (WorkManager is outside `spec/stack.md`), unblocking of a queue whose 7-day upload token expired or was refused (it needs the owner to log in again and blocks the wipe) and LGPD purge of an owner who never returns (see `PROGRESS.md`, section Fila de visitas por dono e corrida do login), per-device client-certificate provisioning in production (mTLS, on the broker and on the Traefik → backend hop, is delivered only on the local stack; the backend does not log the client certificate's CN), creating coordinator accounts and assigning their `ubsId` through the UI (a coordinator still exists only in the seeds and by SQL; #43 covers the ACS lifecycle and the MFA resets), the ACS's own password change (a lost password is reset by the coordination), the audit trail's append-only enforcement against the database superuser (since #82 a per-statement trigger refuses `UPDATE`/`DELETE`/`TRUNCATE` on `audit_logs`, `audit_exports` and `audit_chain_checkpoints` and `PUBLIC` lacks those privileges, but the backend still connects as the superuser/owner, which bypasses both — role separation is #96), a real file download of the CSV export (it goes through the clipboard), the owner's acceptance of the green "Cadeia íntegra" exception to `spec/ui_design.md` and the data-protection officer's review of the export (`PROGRESS.md`, section #45), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). The data-subject request handling (#42) is delivered; the validation of its anonymization rules with the data-protection officer is still open (`PROGRESS.md`). Two core flows: - **Paciente (patient)**: passwordless auth (OTP), urgency alert, structured triage, request status tracking, "Meus Dados" (LGPD). diff --git a/PROGRESS.md b/PROGRESS.md index 86d9985d..5022271e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -573,6 +573,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa se autenticar de novo (matrícula + senha + código) mais ou menos a cada 15 minutos**, porque ainda não existe refresh token e o código TOTP é de uso único: `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória e falha de forma não recuperável, e `AcsBackend.onSessionExpired` faz o painel EMPILHAR a tela de reautenticação por cima (uma só por vez). O painel não é desmontado: fila de alertas já recebidos por MQTT (o broker não reentrega o que foi confirmado com PUBACK), feed MQTT (autentica por senha+certificado, não pelo JWT), fila de visitas e formulários em andamento sobrevivem; login do MESMO usuário só dá `pop`, de OUTRO usuário descarta o painel (RNF06). Confirmar um alerta com a sessão vencida mostra a mensagem e o alerta segue sem confirmação. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo atual, depois de esperar o passo avançar); `test/session_reauth_test.dart` (5: alerta e formulário preservados, ACK com mensagem, outro usuário, sem rota dupla). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). - **mTLS do broker (2026-10-02).** Entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de desenvolvimento `assets/certs/acs_client.*`, copiado por `sync_dev_ca.sh`, gitignorado), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); o release com a chave de dev é barrado pelo Gradle (`-Psinalacs.allowDevClientKey=true` só para teste local). - **TLS 1.3 no broker e no Traefik → backend (2026-10-16, issue #78).** Plano: `docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md`. (1) **Broker:** `tls_version tlsv1.3` no listener 8883; o 1.2 com certificado de cliente válido é recusado (`SSL alert number 70`), e o backend e o ACS reconectam em 1.3. (2) **Traefik → backend:** o backend escuta TLS 1.3 na 8080 interna quando `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` estão definidas (`lib/src/config/api_tls_config.dart`; só uma das duas é erro de boot; nenhuma = HTTP, caminho do Render). A folha é emitida pelo `traefik-init` em `runtime/backend/` com a CA do RPC (o backend nunca enxerga `ca.key`); o Traefik verifica a cadeia pelo `serversTransport` `backend-tls` (`rootCAs` + `serverName: sinalacs-serverpod`), e o healthcheck do compose passou a `https`. **Decisão:** TLS de mão única, **sem mTLS**, porque o Serverpod 3.4.13 sobe o socket pelo `relic_io` (`HttpServer.bindSecure` sem `requestClientCertificate`); sidecar e fork foram descartados (fora do `spec/stack.md`). Provas: Grupos E e F de `scripts/qa/tls_invariants.sh` (15/15; mutação: `serverName` errado dá 500 com `x509: certificate is valid for … not nome-errado`, e `insecureSkipVerify` é acusado), `mtls_invariants.sh` (4/4), `test/unit/api_tls_config_test.dart` (7; sem o mínimo 1.3 o teste acusa o 1.2), `dev_key_permissions_test.sh`, e no `emulator-5554` (API 36): `e2e.sh --emulator`, `acs_full_e2e.sh` e `admin_alertas_escalonar_e2e.sh`. **Aberto:** mTLS Traefik ↔ backend; produção (no Render o TLS termina na borda); aparelho físico e API 24–29 (o 1.3 só foi medido no emulador API 36). **O RNF04 não fechou inteiro:** na stack local seguem em texto claro backend → Postgres (`SERVERPOD_DATABASE_REQUIRE_SSL: "false"`), backend → Gorush (`http://gorush:8088`), Insights (8083) e webServer (8082) internos e o dashboard do Traefik (8081, só dev) — fora do escopo da #78. `e2e_stack.sh up` passou a rodar o `traefik-init` antes da subida com `--no-deps` (num `runtime/` anterior à #78 o backend entrava em laço de restart sem a folha; reproduzido e corrigido). +- **mTLS Traefik → backend (2026-10-27, issue #90).** Plano: `docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md`. Fecha o "Aberto: mTLS Traefik ↔ backend" da #78. **Diagnóstico reproduzido:** um container qualquer na rede `sinalacs_default`, sem certificado de cliente, completava o handshake TLS 1.3 com `sinalacs-serverpod:8080` e recebia o `health/check` (HTTP 200; o servidor nunca enviava `Request CERT`). **Bloqueio confirmado e medido:** `Server.start` do Serverpod chama `RelicApp.serve` fixo no 3.4.13, no 4.0.4 e no 4.1.0-rc.1 (upgrade não resolve); o `relic_io` (1.2.0 e 2.0.0-rc.2) não expõe `requestClientCertificate`; e o `HttpServer.bindSecure` do SDK só *pede* o certificado — com `requestClientCertificate: true` o handshake **completa** sem certificado e a aplicação só vê `request.certificate == null` (seria 401, não recusa). A recusa no handshake exige `SecureServerSocket.bind(requireClientCertificate: true)`, que o `bindSecure` não repassa, embrulhado num `ServerSocket` para `HttpServer.listenOn`. Nenhuma issue upstream sobre o tema (serverpod, relic, dart-lang/sdk). **Decisão (dono, 2026-10-27; ADR em `spec/stack.md`), revertendo o "sem fork" da #78:** Serverpod consumido por **fork pinado por SHA** (`hbgit/serverpod@2a0b67e`, branch `sinalacs/3.4.13-api-server-adapter`, `dependency_overrides` em `backend/pubspec.yaml`, patch versionado em `backend/patches/`), com um gancho genérico `apiServerAdapterFactory` sem semântica de mTLS; o mesmo patch sobre o `main` (`upstream/api-server-adapter`) é a proposta upstream (issue https://github.com/serverpod/serverpod/issues/5923). Critério de saída e custo de upgrade no ADR e em `backend/patches/README.md`. **Entregue:** `API_TLS_CLIENT_CA_PATH` obrigatória junto com `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` (`ApiTlsConfig.carregar`: subconjunto → mensagem com as três no stderr e **exit 78**; TLS de mão única deixou de ser configuração válida); `lib/src/runtime/mtls_api_server.dart` liga o socket com `requireClientCertificate: true`, embrulho `SecureServerSocketAsServerSocket` com `handleError` (recusas contadas e logadas só pelo tipo, VAZ-01) e `_MtlsIOAdapter` cujo `close` fecha também o socket (o `listenOn` do SDK não fecha); `traefik-init` emite duas folhas `clientAuth` distintas — `traefik-client` (825 d, chave 600, só o Traefik, root) e `healthcheck` (CN `sinalacs-healthcheck`, **90 dias**, chave 644, para o `HEALTHCHECK` do container) —; o Traefik apresenta a sua pelo `serversTransport.certificates`; o healthcheck do Compose usa a dele. **Provas:** `test/unit/api_tls_config_test.dart` (11), `mtls_api_server_test.dart` (9: sem certificado e de outra CA com o mesmo CN → nenhuma linha HTTP; com certificado 200; TLS 1.2 recusado; 40 handshakes falhos + TCP com lixo e depois 200; close libera a porta), `server_wiring_test.dart`, `serverpod_fork_test.dart`; suíte do backend 1573/1573; Grupo E do `tls_invariants.sh` reescrito para sondar **de fora** (container efêmero na rede do Compose, imagem `sinalacs/server:local`): E1–E9 verdes (E5 sem certificado e E6 outra CA recusados; E2 positivo; E8 rajada de 20 recusas sem derrubar o listener, `RestartCount=0`; E4 cobra `certificates` e `API_TLS_CLIENT_CA_PATH`; E7 EKU clientAuth + `openssl verify`; E9 folha do healthcheck > 7 dias e distinta), 20/20 no total; `dev_key_permissions_test.sh` cobre as folhas novas. **Mutações medidas:** (a) sem `certificates` → 502 via Traefik e E4 acusa; (b) sem `API_TLS_CLIENT_CA_PATH` → exit 78 com a mensagem exata no log, container em restart, Grupo A acusa; (c) `requireClientCertificate: false` → E5 e E6 acusam (200 sem certificado); (d) wrapper relançando como `StateError` → E2/E5/E8 seguem verdes e o relic loga `ERROR: Bad state` por recusa (a camada do SDK protege; o `handleError` é defesa em profundidade). **Emulador (`emulator-5554`, API 36, 2026-10-27):** `e2e.sh --emulator --keep` verde (tls_invariants 20/20, `live_check` do paciente e do ACS, smoke dos dois apps pelo Traefik com mTLS atrás). `acs_full_e2e.sh`: **5/6 em duas execuções** — os cinco que sincronizam visitas pelo Traefik (rajada de 100, conflito #79, MFA + refresh, fila por dono) passam; o 1º teste (`login real, seletor por microárea, visita e sincronização`) falha num `tap` **local** em `sync_visits` que não acerta o widget (hit test cai numa superfície Material sobre o botão — a SnackBar "visita salva"), antes de qualquer rede; `apps/` é idêntico ao `develop` neste branch e o último verde registrado desse script é de 2026-10-17 (#81), anterior às mudanças de #83/#86 em `app.dart` (2026-10-25) — **regressão pré-existente do e2e, fora da #90**, a investigar em issue própria. **Revisão independente do núcleo** (checkpoint depois da Task 5): 2 achados importantes corrigidos (close do adapter; diagnóstico do boot) — o `StateError` do `run()` `async void` caía no `runZonedGuarded` da #56 só com o tipo, e uma `TlsException` de arquivo malformado era classificada como transitória e saía com **código 0**; agora `carregar` nomeia a variável e sai 78. **Observações medidas:** o Traefik tira um container `unhealthy` da rota (404, não 5xx) — por isso o healthcheck precisa da folha; `openssl s_client -brief` imprime `CONNECTION ESTABLISHED` no TLS 1.3 mesmo recusado (as sondas usam curl e "nenhuma linha HTTP"). **Aberto:** produção (no Render o TLS termina na borda; nada disto se aplica); rebase do fork a cada upgrade do Serverpod; o backend não loga o CN do certificado de cliente; rede Docker interna como defesa em profundidade (alternativa C, issue de continuação); log de recusa sem agregação (uma linha por handshake recusado) e rótulo único para `SocketException` de `accept()`; Traefik roda como root e lê `ca.key` do mesmo mount (pré-existente). RNF04 segue **parcial** por Postgres, Gorush, 8082/8083 e 8081, fora do escopo. - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Pendências do ACS (2026-10-02) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index eb513271..4abbfa93 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -36,7 +36,7 @@ serverpod create-migration Run the server directly: `dart run backend/sinalacs_server/bin/main.dart`. Serverpod reads `config/*.yaml` plus `config/passwords.yaml`, and every setting can be overridden by environment variable: `SERVERPOD_DATABASE_HOST`/`_PORT`/`_NAME`/`_USER`/`_PASSWORD`/`_REQUIRE_SSL`, `SERVERPOD_APPLY_MIGRATIONS` (applies migrations at boot — this is what replaced the manual `psql` steps), `SERVERPOD_REDIS_ENABLED` (Redis is optional and off), and `SERVERPOD_INSIGHTS_SERVER_PORT` (remapped to 8083 in `docker-compose.yml`, because Serverpod's default 8081 is the Traefik dashboard here). On the API server specifically, `config/development.yaml` keeps `port: 8080` — the process listens on 8080 **inside** the container, over TLS 1.3 on the local stack since #78 (see `API_TLS_*` below) and in cleartext when those are unset (Render) — while `publicHost`/`publicPort`/`publicScheme` are `localhost`/`443`/`https`. Those three describe the **declared** public address (the encrypted URL Traefik terminates), not the listener and not the address actually in use: no client here reads them, since both apps build the client from `--dart-define`, and the route that would serve them (`AppConfigRoute` in `lib/server.dart`) lives on the webServer (8082), which the compose neither publishes nor routes. And note **which** config file actually serves: the image runs in production mode — the Dockerfile sets `ENV runmode=production` and the entrypoint is `./server --mode=$runmode` — so the running backend reads `config/production.yaml`, not the `development.yaml` quoted above; there, the `apiServer` block keeps Serverpod's placeholder `publicHost: api.examplepod.com` with `publicPort: 443`, `publicScheme: https` and `port: 8080`. The paragraph above is therefore true about the file, but the process in the container does **not** declare `localhost:443` — it declares a hostname nobody resolves. Only Traefik publishes a port for the RPC — `443`, per RNF04/L-08; cleartext 8080 used to be published on the host and no longer is. -MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fromEnvironment()` in `backend/sinalacs_server/lib/src/config/app_config.dart`: `MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`/`MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` (certificado de cliente do mTLS do broker; só um dos dois definido é erro de boot, e fora de `development` com `MQTT_USE_TLS=true` os dois são obrigatórios), plus `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` (read by `ApiTlsConfig` in `lib/src/config/api_tls_config.dart`, not by `AppConfig`: the apiServer's own TLS, minimum 1.3, for the Traefik → backend hop (RNF04, #78); both or neither, one alone fails boot; unset means HTTP, the Render path; one-way TLS only, because Serverpod 3.4.13 binds through `relic_io` without `requestClientCertificate`), `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `APP_ENV`, `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off), `CPF_HASH_PEPPER` (RF01 — the key of the HMAC over the CPF and over the OTP code; the server and the seed writers must share it) and `SMS_GATEWAY` (the OTP delivery channel: it defaults to `log` in `development`, is **required** elsewhere, and `log` — the only gateway implemented today — is refused outside `development` by name, since it sends no SMS at all; the development gateway writes the code to the server log, and nothing else of the signature). +MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fromEnvironment()` in `backend/sinalacs_server/lib/src/config/app_config.dart`: `MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`/`MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` (certificado de cliente do mTLS do broker; só um dos dois definido é erro de boot, e fora de `development` com `MQTT_USE_TLS=true` os dois são obrigatórios), plus `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH`/`API_TLS_CLIENT_CA_PATH` (read by `ApiTlsConfig.carregar` in `lib/src/config/api_tls_config.dart`, not by `AppConfig`: the apiServer's own **mutual** TLS, minimum 1.3, for the Traefik → backend hop (RNF04, #78 and #90); all three or none — a proper subset, such as a `.env` from before #90 with only the first two, writes a message naming the three to stderr and exits with 78 instead of coming up in one-way TLS; unset means HTTP, the Render path. The listener is bound by `lib/src/runtime/mtls_api_server.dart` with `SecureServerSocket.bind(requireClientCertificate: true)` wrapped for `HttpServer.listenOn`, because `HttpServer.bindSecure` only *requests* the certificate and completes the handshake without one; Serverpod is consumed through the pinned fork `hbgit/serverpod` whose `apiServerAdapterFactory` lets the application supply the relic adapter — see `backend/patches/README.md` for the why, the exit criterion and the upgrade cost), `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `APP_ENV`, `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off), `CPF_HASH_PEPPER` (RF01 — the key of the HMAC over the CPF and over the OTP code; the server and the seed writers must share it) and `SMS_GATEWAY` (the OTP delivery channel: it defaults to `log` in `development`, is **required** elsewhere, and `log` — the only gateway implemented today — is refused outside `development` by name, since it sends no SMS at all; the development gateway writes the code to the server log, and nothing else of the signature). Outside `development`, boot fails fast when `JWT_SECRET`, `AUDIT_CHAIN_SECRET` or `CPF_HASH_PEPPER` is absent, empty, blank, or equal to its own development fallback (`AppConfig.developmentJwtSecret` / `AppConfig.developmentAuditChainSecret` / `AppConfig.developmentCpfHashPepper`) — those fallbacks live in versioned code, so they're public, and `JWT_SECRET` signs tokens that carry role and micro-area, `AUDIT_CHAIN_SECRET` keys the `audit_logs` hash chain while `CPF_HASH_PEPPER` keys the HMAC over the CPF and over the OTP code. They are deliberately independent secrets — rotating one must not affect the others. The shared validation logic is `_resolveSecret`, covered by `test/unit/app_config_test.dart`; `AppConfig.fromMap()` exists so the rules are testable without touching `Platform.environment`. diff --git a/backend/DEPLOY.md b/backend/DEPLOY.md index 045d3ed1..550611b0 100644 --- a/backend/DEPLOY.md +++ b/backend/DEPLOY.md @@ -97,10 +97,11 @@ Configurar como variáveis de ambiente secretas (nunca commitadas): > O piloto, portanto, **depende de um proxy com TLS na frente**; publicar a porta > da API direto, sem essa borda, reabriria o caminho em texto claro. > -> Deixe `API_TLS_CERT_PATH` e `API_TLS_KEY_PATH` **sem definir** aqui: elas ligam -> o TLS 1.3 do próprio processo, que é o trecho Traefik → backend da stack local -> (issue #78), e o proxy do Render fala HTTP com o container. Só uma das duas -> definida impede o boot. +> Deixe `API_TLS_CERT_PATH`, `API_TLS_KEY_PATH` e `API_TLS_CLIENT_CA_PATH` **sem +> definir** aqui: as três ligam o TLS 1.3 **mútuo** do próprio processo, que é o +> trecho Traefik → backend da stack local (issues #78 e #90: o backend passa a +> exigir certificado de cliente), e o proxy do Render fala HTTP com o container. +> Um subconjunto delas impede o boot (sai com 78 e nomeia as três). > > O certificado de desenvolvimento deste repositório > (`infra/docker/traefik/runtime/certs/` — a folha é emitida pela CA de dev diff --git a/backend/patches/README.md b/backend/patches/README.md index 734871d5..bdb94eeb 100644 --- a/backend/patches/README.md +++ b/backend/patches/README.md @@ -27,7 +27,8 @@ fica em `sinalacs_server/lib/src/runtime/mtls_api_server.dart`. é o que `backend/pubspec.yaml` consome por `dependency_overrides`, **pelo SHA do commit, nunca pelo nome do branch**; - branch `upstream/api-server-adapter` — o mesmo patch sobre o `main` - (linha 4.x), base da proposta upstream (issue linkada no `PROGRESS.md`). + (linha 4.x), base da proposta upstream: + https://github.com/serverpod/serverpod/issues/5923. **Refazer o fork do zero.** diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index e9856263..cda91fb4 100644 --- a/docs/auditoria-prd/2026-10-15-lacunas-prd.md +++ b/docs/auditoria-prd/2026-10-15-lacunas-prd.md @@ -93,7 +93,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | CRIPTO-APP-LOCAL | SQLCipher com chave derivada de PIN/biometria | **parcial** | SQLCipher com chave aleatória de 32 bytes guardada no Keystore (`apps/acs/lib/core/security/database_key_store.dart:31`); o arquivo documenta por que não usa PBKDF2 de PIN (`:8`). | Divergência do PRD (§4.2.3 pede chave derivada de PIN/biometria por PBKDF2): atualizar o PRD com a decisão ou amarrar a chave à biometria. | | CRIPTO-APP-CACHE | SQLCipher com chave rotativa | **entregue** | Rotação por evento (#81, D6): `VisitDatabase.destroy` troca a chave no "Limpar este aparelho" e na limpeza remota; decisão de não rotacionar por calendário registrada em `spec/lgpd_design.md` §5.1; `device_wipe_test.dart`, e2e `acs_remote_wipe_e2e.sh` (chave trocada, banco reaberto vazio). | | | CRIPTO-APP-TRAEFIK | TLS 1.3 app ↔ Traefik | **entregue** | Traefik com `minVersion: VersionTLS13` (`infra/docker/traefik/dynamic/tls.yaml:22`); `scripts/qa/tls_invariants.sh` OK e `openssl s_client -tls1_2` na 443 recusado (2026-10-15). | Certificado Let's Encrypt em produção (hoje CA de dev). | -| CRIPTO-TRAEFIK-BACKEND | mTLS Traefik ↔ backend | **parcial** | #78 (2026-10-16): Traefik → backend em TLS 1.3 de mão única com a cadeia verificada: o backend escuta TLS na 8080 interna (`API_TLS_*`, `ApiTlsConfig`), o Traefik disca por `https` com o `serversTransport` `backend-tls` (`rootCAs` + `serverName`, sem `insecureSkipVerify`). Provado pelo Grupo E de `tls_invariants.sh`. | mTLS: bloqueado pelo Serverpod 3.4.13, que sobe o socket pelo `relic_io` (`HttpServer.bindSecure` sem `requestClientCertificate`); decisão registrada em `PROGRESS.md`. | +| CRIPTO-TRAEFIK-BACKEND | mTLS Traefik ↔ backend | **entregue** | #78 (2026-10-16) + #90 (2026-10-27), stack local: o backend exige certificado de cliente assinado pela CA do RPC na 8080 interna (`SecureServerSocket.bind(requireClientCertificate: true)` em `mtls_api_server.dart`, `API_TLS_CLIENT_CA_PATH`), recusado no handshake; o Traefik verifica a folha do backend (`rootCAs` + `serverName`) e apresenta a sua (`serversTransport.certificates`, `traefik-client.crt`). Grupo E de `tls_invariants.sh` (E1–E9: sem certificado e de outra CA recusados, com certificado 200, TLS 1.2 recusado, rajada de recusas não derruba o listener, folhas com EKU clientAuth); mutações (a)–(d) registradas em `PROGRESS.md`; `e2e.sh --emulator` verde. Serverpod por fork pinado (`backend/patches/README.md`, ADR em `spec/stack.md`). | Produção: no Render o TLS termina na borda e o container fala HTTP — nada disto se aplica; o backend não loga o CN do cliente; rede interna como defesa em profundidade (issue de continuação). | | CRIPTO-POSTGRES | Criptografia no PostgreSQL | **entregue** | AES-256-GCM na aplicação com `HEALTH_DATA_ENCRYPTION_KEY` (decisão §2.2.1 §6, que substituiu pgcrypto); ver INV-04. | Chave em gerenciador de segredos (Vault) em produção; hoje variável de ambiente. | | CRIPTO-AUDITORIA | Cadeia HMAC-SHA256 da auditoria | **entregue** | HMAC-SHA256 com `AUDIT_CHAIN_SECRET` fora do Postgres; `audit_chain_test.dart` passa. | Rotação do segredo invalida a história (limite conhecido, #45). | @@ -104,7 +104,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | RNF01 | Latência MQTT < 500 ms (p95) | **parcial** | Entrega de alerta p95 212 ms, 10/10 amostras (meta < 500 ms); `measure_latency.dart --samples 10 --visit-count 100` contra a stack local (2026-10-16 01:27 UTC). | Só stack local, 1 cliente sequencial, 10 amostras (p95 de 10 amostras é quase o máximo): repetir em produção, com carga e mais amostras. | | RNF02 | Sincronização offline > 99,5 % | **parcial** | Sync 10/10 lotes de 100 visitas sem perda (`measure_latency.dart --samples 10 --visit-count 100` contra a stack local (2026-10-16 01:27 UTC)); `offline_burst_test.dart` prova que nenhuma de 100 se perde em reinício. Com telemetria ativa (2026-10-25, commit d617df7): mesmo comando, 3 rodadas, 10/10 lotes sem perda, p95 de sync 123, 102 e 104 ms (linha de base 112 ms, teto +10 % = 123,2 ms). | Mensurável em campo; medido no piloto (`sync_attempt`/`sync_result` emitidos, #83). | | RNF03 | Criptografia AES-256 em repouso | **parcial** | Ver INV-04 e CRIPTO-*. | Ver CRIPTO-APP-CACHE. | -| RNF04 | TLS 1.3 em todas as comunicações | **parcial** | #78 (2026-10-16) fechou os três trechos da issue: RPC em TLS 1.3 obrigatório (Grupo B); broker MQTT só em TLS 1.3 na 8883 com mTLS (`tls_version tlsv1.3`, Grupo F; `mtls_invariants.sh` OK); Traefik → backend em TLS 1.3 verificado (Grupo E). Medido também no `emulator-5554` (API 36). | Seguem em texto claro na stack local, fora do escopo da #78: backend → Postgres (`SERVERPOD_DATABASE_REQUIRE_SSL: "false"`, 5432 publicado no host), backend → Gorush (`GORUSH_URL: http://gorush:8088`), Insights (8083) e webServer (8082) dentro do container, dashboard do Traefik (8081, só dev). Produção: no Render o TLS termina na borda e o container segue em HTTP (`backend/DEPLOY.md`); sem prova em aparelho físico nem em API 24–29; mTLS interno (CRIPTO-TRAEFIK-BACKEND). | +| RNF04 | TLS 1.3 em todas as comunicações | **parcial** | #78 (2026-10-16) fechou os três trechos da issue: RPC em TLS 1.3 obrigatório (Grupo B); broker MQTT só em TLS 1.3 na 8883 com mTLS (`tls_version tlsv1.3`, Grupo F; `mtls_invariants.sh` OK); Traefik → backend em TLS 1.3 verificado (Grupo E). Medido também no `emulator-5554` (API 36). | Seguem em texto claro na stack local, fora do escopo da #78: backend → Postgres (`SERVERPOD_DATABASE_REQUIRE_SSL: "false"`, 5432 publicado no host), backend → Gorush (`GORUSH_URL: http://gorush:8088`), Insights (8083) e webServer (8082) dentro do container, dashboard do Traefik (8081, só dev). Produção: no Render o TLS termina na borda e o container segue em HTTP (`backend/DEPLOY.md`); sem prova em aparelho físico nem em API 24–29. O mTLS interno (CRIPTO-TRAEFIK-BACKEND) foi entregue pela #90 (2026-10-27) e saiu desta lista. | | RNF05 | Acessibilidade WCAG 2.1 AA | **parcial** | Matriz de contraste e `meetsGuideline` nos três apps (`apps/*/test/contrast_tokens_test.dart`, `touch_targets_test.dart` no admin), avaliação em `spec/ux_accessibility_assessment.md` (WCAG 2.2 AA, superconjunto do 2.1); linha de base verde. | Foco visível no admin (A11Y-2.4.7) e métricas de interface (A11Y-LCP/FID/CLS) não medidas. | ### SLOs (§4.1) @@ -228,7 +228,7 @@ Critério: **P0** toca invariante ou o núcleo do MVP (§6.1) sem critério atin | 1 | P0 | **Resolvido (2026-10-16, #75).** Colunas de risco da triagem e do alerta em texto plano (`triage_sessions.resultRisk`/`resultDisplay`, `visits.riskLevelBefore`/`riskLevelAfter`, `alerts.riskLevel`) | INV-04, RNF03, LGPD-RF09 | código | Viola uma invariante; ou cifrar, ou registrar no PRD por que o risco precisa ficar legível para a fila. | | 2 | P0 | **Resolvido (2026-10-16, #76).** Paciente nunca vê Atendido: nenhum caminho leva o alerta a `resolved` | RF05, MVP-STATUS, M2.1 | código | Critério do núcleo do MVP (§6.1) não atingido; precisa da ação do ACS de encerrar o atendimento. | | 3 | P0 | Envio de OTP por SMS real (provedor + telefone do paciente) | RF01, MVP-LOGIN-60S, M2.1, R6 | código + externo | Sem SMS o paciente só entra com o ACS presente gerando um QR (R6): não há login por conta própria fora do ambiente de dev; a escolha de provedor/contrato é de produto. | -| 4 | P1 | ~~Broker MQTT aceita TLS 1.2; Traefik → backend em HTTP~~ — resolvido na #78 (2026-10-16): broker só 1.3, Traefik → backend em TLS 1.3 verificado; mTLS interno bloqueado pelo Serverpod | RNF04, M2.3, CRIPTO-TRAEFIK-BACKEND | infra | Risco Crítico; `tls_version tlsv1.3` no `mosquitto.conf` e o caso no `tls_invariants.sh` é pequeno; mTLS interno exige decisão. | +| 4 | P1 | ~~Broker MQTT aceita TLS 1.2; Traefik → backend em HTTP~~ — resolvido na #78 (2026-10-16): broker só 1.3, Traefik → backend em TLS 1.3 verificado; ~~mTLS interno bloqueado pelo Serverpod~~ — resolvido na #90 (2026-10-27) com fork pinado do Serverpod (decisão em `spec/stack.md`) | RNF04, M2.3, CRIPTO-TRAEFIK-BACKEND | infra | Risco Crítico; `tls_version tlsv1.3` no `mosquitto.conf` e o caso no `tls_invariants.sh` é pequeno; o mTLS interno exigiu a decisão do fork. | | 5 | P1 | Resolução de conflito de visita no ACS (comparar e escolher versão) | R1, DADOS-VERSION-OTIMISTA | código | Risco de impacto 5; entregue em 2026-10-16 (#79); antes, a visita em conflito ficava parada na fila para sempre. | | 6 | P1 | ~~Reserva por HTTP para o ACS receber alertas quando o MQTT cai~~ — resolvido na #80 (2026-10-17): `alerts.openForMicroArea` consultado a cada 30 s com o broker fora | R2, INV-03 | código | Risco de impacto 5 em rede 3G; antes, o ACS não tinha como listar alertas pendentes por HTTP. | | 7 | P1 | ~~Remote wipe~~ e certificado de cliente por aparelho — o remote wipe e a rotação da chave foram entregues na #81 (2026-10-17), mas R3 segue **parcial**; o certificado por aparelho segue na issue #94 | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; o certificado depende de uma lib X.509/CSR fora de `spec/stack.md`, de uma CA de aparelhos e de PKI de produção. | diff --git a/spec/stack.md b/spec/stack.md index e372610b..7d4b8af5 100644 --- a/spec/stack.md +++ b/spec/stack.md @@ -73,4 +73,6 @@ implementados de ponta a ponta: push segmentado (§3.2, RF14 — provado no emul primeiro plano e notificação persistente, para evitar a política mais restritiva de "background location" da Play Store. +> **Traefik → backend com mTLS e fork pinado do Serverpod (2026-10-27, #90):** o backend exige certificado de cliente assinado pela CA de desenvolvimento do RPC na 8080 interna (recusa no handshake; `API_TLS_CLIENT_CA_PATH` obrigatória junto com `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH`, TLS de mão única deixou de ser configuração válida) e o Traefik apresenta a folha `traefik-client.crt` pelo `serversTransport.certificates`. **Decisão de arquitetura, revertendo o "sem fork" da #78:** o Serverpod é consumido por um fork pinado por SHA (`hbgit/serverpod`, `dependency_overrides` em `backend/pubspec.yaml`, patch versionado em `backend/patches/`). *Motivo:* bloqueio upstream comprovado — `Server.start` chama `RelicApp.serve` fixo no 3.4.13, no 4.0.4 e no 4.1.0-rc.1; o `relic_io` não expõe `requestClientCertificate`; e o `HttpServer.bindSecure` do SDK não expõe `requireClientCertificate` (com `request` sozinho o handshake completa sem certificado, medido). O patch é um gancho genérico (`apiServerAdapterFactory`), sem semântica de mTLS, proposto upstream contra o `main`. *Critério de saída:* remover o `dependency_overrides` e voltar ao pub.dev quando uma versão oficial expuser o gancho ou recusar no handshake por configuração; o Grupo E de `scripts/qa/tls_invariants.sh` é a prova de equivalência. *Custo de upgrade:* todo upgrade do Serverpod, em especial para 4.x, começa pelo rebase do patch no fork e pela troca do SHA. Fora do escopo e ainda em texto claro na stack local: Postgres, Gorush, 8082/8083 e o painel 8081. + > **Broker com mTLS (2026-10-02):** `require_certificate true`; certificado de cliente por usuário MQTT (`backend`, `acs-area-12`), emitido pela CA de desenvolvimento pelo `mosquitto-init`; senha e ACL mantidos (sem `use_identity_as_username`); prova de recusa em `scripts/qa/mtls_invariants.sh`. Produção exige provisionamento por aparelho (aberto). From 70ad81c1b1c45566f18a1b3ff6a5fe339edbd780 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 27 Oct 2026 18:52:20 -0400 Subject: [PATCH 12/12] =?UTF-8?q?test(tls):=20E6=20n=C3=A3o=20passa=20sem?= =?UTF-8?q?=20apresentar=20o=20certificado=20de=20outra=20CA;=20PROGRESS?= =?UTF-8?q?=20corrige=20os=20suspeitos=20da=20regress=C3=A3o=20do=20acs=5F?= =?UTF-8?q?full=5Fe2e=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 2 +- scripts/qa/tls_invariants.sh | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5022271e..88f4e867 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -573,7 +573,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa se autenticar de novo (matrícula + senha + código) mais ou menos a cada 15 minutos**, porque ainda não existe refresh token e o código TOTP é de uso único: `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória e falha de forma não recuperável, e `AcsBackend.onSessionExpired` faz o painel EMPILHAR a tela de reautenticação por cima (uma só por vez). O painel não é desmontado: fila de alertas já recebidos por MQTT (o broker não reentrega o que foi confirmado com PUBACK), feed MQTT (autentica por senha+certificado, não pelo JWT), fila de visitas e formulários em andamento sobrevivem; login do MESMO usuário só dá `pop`, de OUTRO usuário descarta o painel (RNF06). Confirmar um alerta com a sessão vencida mostra a mensagem e o alerta segue sem confirmação. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo atual, depois de esperar o passo avançar); `test/session_reauth_test.dart` (5: alerta e formulário preservados, ACK com mensagem, outro usuário, sem rota dupla). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). - **mTLS do broker (2026-10-02).** Entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de desenvolvimento `assets/certs/acs_client.*`, copiado por `sync_dev_ca.sh`, gitignorado), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); o release com a chave de dev é barrado pelo Gradle (`-Psinalacs.allowDevClientKey=true` só para teste local). - **TLS 1.3 no broker e no Traefik → backend (2026-10-16, issue #78).** Plano: `docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md`. (1) **Broker:** `tls_version tlsv1.3` no listener 8883; o 1.2 com certificado de cliente válido é recusado (`SSL alert number 70`), e o backend e o ACS reconectam em 1.3. (2) **Traefik → backend:** o backend escuta TLS 1.3 na 8080 interna quando `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` estão definidas (`lib/src/config/api_tls_config.dart`; só uma das duas é erro de boot; nenhuma = HTTP, caminho do Render). A folha é emitida pelo `traefik-init` em `runtime/backend/` com a CA do RPC (o backend nunca enxerga `ca.key`); o Traefik verifica a cadeia pelo `serversTransport` `backend-tls` (`rootCAs` + `serverName: sinalacs-serverpod`), e o healthcheck do compose passou a `https`. **Decisão:** TLS de mão única, **sem mTLS**, porque o Serverpod 3.4.13 sobe o socket pelo `relic_io` (`HttpServer.bindSecure` sem `requestClientCertificate`); sidecar e fork foram descartados (fora do `spec/stack.md`). Provas: Grupos E e F de `scripts/qa/tls_invariants.sh` (15/15; mutação: `serverName` errado dá 500 com `x509: certificate is valid for … not nome-errado`, e `insecureSkipVerify` é acusado), `mtls_invariants.sh` (4/4), `test/unit/api_tls_config_test.dart` (7; sem o mínimo 1.3 o teste acusa o 1.2), `dev_key_permissions_test.sh`, e no `emulator-5554` (API 36): `e2e.sh --emulator`, `acs_full_e2e.sh` e `admin_alertas_escalonar_e2e.sh`. **Aberto:** mTLS Traefik ↔ backend; produção (no Render o TLS termina na borda); aparelho físico e API 24–29 (o 1.3 só foi medido no emulador API 36). **O RNF04 não fechou inteiro:** na stack local seguem em texto claro backend → Postgres (`SERVERPOD_DATABASE_REQUIRE_SSL: "false"`), backend → Gorush (`http://gorush:8088`), Insights (8083) e webServer (8082) internos e o dashboard do Traefik (8081, só dev) — fora do escopo da #78. `e2e_stack.sh up` passou a rodar o `traefik-init` antes da subida com `--no-deps` (num `runtime/` anterior à #78 o backend entrava em laço de restart sem a folha; reproduzido e corrigido). -- **mTLS Traefik → backend (2026-10-27, issue #90).** Plano: `docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md`. Fecha o "Aberto: mTLS Traefik ↔ backend" da #78. **Diagnóstico reproduzido:** um container qualquer na rede `sinalacs_default`, sem certificado de cliente, completava o handshake TLS 1.3 com `sinalacs-serverpod:8080` e recebia o `health/check` (HTTP 200; o servidor nunca enviava `Request CERT`). **Bloqueio confirmado e medido:** `Server.start` do Serverpod chama `RelicApp.serve` fixo no 3.4.13, no 4.0.4 e no 4.1.0-rc.1 (upgrade não resolve); o `relic_io` (1.2.0 e 2.0.0-rc.2) não expõe `requestClientCertificate`; e o `HttpServer.bindSecure` do SDK só *pede* o certificado — com `requestClientCertificate: true` o handshake **completa** sem certificado e a aplicação só vê `request.certificate == null` (seria 401, não recusa). A recusa no handshake exige `SecureServerSocket.bind(requireClientCertificate: true)`, que o `bindSecure` não repassa, embrulhado num `ServerSocket` para `HttpServer.listenOn`. Nenhuma issue upstream sobre o tema (serverpod, relic, dart-lang/sdk). **Decisão (dono, 2026-10-27; ADR em `spec/stack.md`), revertendo o "sem fork" da #78:** Serverpod consumido por **fork pinado por SHA** (`hbgit/serverpod@2a0b67e`, branch `sinalacs/3.4.13-api-server-adapter`, `dependency_overrides` em `backend/pubspec.yaml`, patch versionado em `backend/patches/`), com um gancho genérico `apiServerAdapterFactory` sem semântica de mTLS; o mesmo patch sobre o `main` (`upstream/api-server-adapter`) é a proposta upstream (issue https://github.com/serverpod/serverpod/issues/5923). Critério de saída e custo de upgrade no ADR e em `backend/patches/README.md`. **Entregue:** `API_TLS_CLIENT_CA_PATH` obrigatória junto com `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` (`ApiTlsConfig.carregar`: subconjunto → mensagem com as três no stderr e **exit 78**; TLS de mão única deixou de ser configuração válida); `lib/src/runtime/mtls_api_server.dart` liga o socket com `requireClientCertificate: true`, embrulho `SecureServerSocketAsServerSocket` com `handleError` (recusas contadas e logadas só pelo tipo, VAZ-01) e `_MtlsIOAdapter` cujo `close` fecha também o socket (o `listenOn` do SDK não fecha); `traefik-init` emite duas folhas `clientAuth` distintas — `traefik-client` (825 d, chave 600, só o Traefik, root) e `healthcheck` (CN `sinalacs-healthcheck`, **90 dias**, chave 644, para o `HEALTHCHECK` do container) —; o Traefik apresenta a sua pelo `serversTransport.certificates`; o healthcheck do Compose usa a dele. **Provas:** `test/unit/api_tls_config_test.dart` (11), `mtls_api_server_test.dart` (9: sem certificado e de outra CA com o mesmo CN → nenhuma linha HTTP; com certificado 200; TLS 1.2 recusado; 40 handshakes falhos + TCP com lixo e depois 200; close libera a porta), `server_wiring_test.dart`, `serverpod_fork_test.dart`; suíte do backend 1573/1573; Grupo E do `tls_invariants.sh` reescrito para sondar **de fora** (container efêmero na rede do Compose, imagem `sinalacs/server:local`): E1–E9 verdes (E5 sem certificado e E6 outra CA recusados; E2 positivo; E8 rajada de 20 recusas sem derrubar o listener, `RestartCount=0`; E4 cobra `certificates` e `API_TLS_CLIENT_CA_PATH`; E7 EKU clientAuth + `openssl verify`; E9 folha do healthcheck > 7 dias e distinta), 20/20 no total; `dev_key_permissions_test.sh` cobre as folhas novas. **Mutações medidas:** (a) sem `certificates` → 502 via Traefik e E4 acusa; (b) sem `API_TLS_CLIENT_CA_PATH` → exit 78 com a mensagem exata no log, container em restart, Grupo A acusa; (c) `requireClientCertificate: false` → E5 e E6 acusam (200 sem certificado); (d) wrapper relançando como `StateError` → E2/E5/E8 seguem verdes e o relic loga `ERROR: Bad state` por recusa (a camada do SDK protege; o `handleError` é defesa em profundidade). **Emulador (`emulator-5554`, API 36, 2026-10-27):** `e2e.sh --emulator --keep` verde (tls_invariants 20/20, `live_check` do paciente e do ACS, smoke dos dois apps pelo Traefik com mTLS atrás). `acs_full_e2e.sh`: **5/6 em duas execuções** — os cinco que sincronizam visitas pelo Traefik (rajada de 100, conflito #79, MFA + refresh, fila por dono) passam; o 1º teste (`login real, seletor por microárea, visita e sincronização`) falha num `tap` **local** em `sync_visits` que não acerta o widget (hit test cai numa superfície Material sobre o botão — a SnackBar "visita salva"), antes de qualquer rede; `apps/` é idêntico ao `develop` neste branch e o último verde registrado desse script é de 2026-10-17 (#81), anterior às mudanças de #83/#86 em `app.dart` (2026-10-25) — **regressão pré-existente do e2e, fora da #90**, a investigar em issue própria. **Revisão independente do núcleo** (checkpoint depois da Task 5): 2 achados importantes corrigidos (close do adapter; diagnóstico do boot) — o `StateError` do `run()` `async void` caía no `runZonedGuarded` da #56 só com o tipo, e uma `TlsException` de arquivo malformado era classificada como transitória e saía com **código 0**; agora `carregar` nomeia a variável e sai 78. **Observações medidas:** o Traefik tira um container `unhealthy` da rota (404, não 5xx) — por isso o healthcheck precisa da folha; `openssl s_client -brief` imprime `CONNECTION ESTABLISHED` no TLS 1.3 mesmo recusado (as sondas usam curl e "nenhuma linha HTTP"). **Aberto:** produção (no Render o TLS termina na borda; nada disto se aplica); rebase do fork a cada upgrade do Serverpod; o backend não loga o CN do certificado de cliente; rede Docker interna como defesa em profundidade (alternativa C, issue de continuação); log de recusa sem agregação (uma linha por handshake recusado) e rótulo único para `SocketException` de `accept()`; Traefik roda como root e lê `ca.key` do mesmo mount (pré-existente). RNF04 segue **parcial** por Postgres, Gorush, 8082/8083 e 8081, fora do escopo. +- **mTLS Traefik → backend (2026-10-27, issue #90).** Plano: `docs/superpowers/plans/2026-10-27-issue-90-mtls-traefik-backend.md`. Fecha o "Aberto: mTLS Traefik ↔ backend" da #78. **Diagnóstico reproduzido:** um container qualquer na rede `sinalacs_default`, sem certificado de cliente, completava o handshake TLS 1.3 com `sinalacs-serverpod:8080` e recebia o `health/check` (HTTP 200; o servidor nunca enviava `Request CERT`). **Bloqueio confirmado e medido:** `Server.start` do Serverpod chama `RelicApp.serve` fixo no 3.4.13, no 4.0.4 e no 4.1.0-rc.1 (upgrade não resolve); o `relic_io` (1.2.0 e 2.0.0-rc.2) não expõe `requestClientCertificate`; e o `HttpServer.bindSecure` do SDK só *pede* o certificado — com `requestClientCertificate: true` o handshake **completa** sem certificado e a aplicação só vê `request.certificate == null` (seria 401, não recusa). A recusa no handshake exige `SecureServerSocket.bind(requireClientCertificate: true)`, que o `bindSecure` não repassa, embrulhado num `ServerSocket` para `HttpServer.listenOn`. Nenhuma issue upstream sobre o tema (serverpod, relic, dart-lang/sdk). **Decisão (dono, 2026-10-27; ADR em `spec/stack.md`), revertendo o "sem fork" da #78:** Serverpod consumido por **fork pinado por SHA** (`hbgit/serverpod@2a0b67e`, branch `sinalacs/3.4.13-api-server-adapter`, `dependency_overrides` em `backend/pubspec.yaml`, patch versionado em `backend/patches/`), com um gancho genérico `apiServerAdapterFactory` sem semântica de mTLS; o mesmo patch sobre o `main` (`upstream/api-server-adapter`) é a proposta upstream (issue https://github.com/serverpod/serverpod/issues/5923). Critério de saída e custo de upgrade no ADR e em `backend/patches/README.md`. **Entregue:** `API_TLS_CLIENT_CA_PATH` obrigatória junto com `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` (`ApiTlsConfig.carregar`: subconjunto → mensagem com as três no stderr e **exit 78**; TLS de mão única deixou de ser configuração válida); `lib/src/runtime/mtls_api_server.dart` liga o socket com `requireClientCertificate: true`, embrulho `SecureServerSocketAsServerSocket` com `handleError` (recusas contadas e logadas só pelo tipo, VAZ-01) e `_MtlsIOAdapter` cujo `close` fecha também o socket (o `listenOn` do SDK não fecha); `traefik-init` emite duas folhas `clientAuth` distintas — `traefik-client` (825 d, chave 600, só o Traefik, root) e `healthcheck` (CN `sinalacs-healthcheck`, **90 dias**, chave 644, para o `HEALTHCHECK` do container) —; o Traefik apresenta a sua pelo `serversTransport.certificates`; o healthcheck do Compose usa a dele. **Provas:** `test/unit/api_tls_config_test.dart` (11), `mtls_api_server_test.dart` (9: sem certificado e de outra CA com o mesmo CN → nenhuma linha HTTP; com certificado 200; TLS 1.2 recusado; 40 handshakes falhos + TCP com lixo e depois 200; close libera a porta), `server_wiring_test.dart`, `serverpod_fork_test.dart`; suíte do backend 1573/1573; Grupo E do `tls_invariants.sh` reescrito para sondar **de fora** (container efêmero na rede do Compose, imagem `sinalacs/server:local`): E1–E9 verdes (E5 sem certificado e E6 outra CA recusados; E2 positivo; E8 rajada de 20 recusas sem derrubar o listener, `RestartCount=0`; E4 cobra `certificates` e `API_TLS_CLIENT_CA_PATH`; E7 EKU clientAuth + `openssl verify`; E9 folha do healthcheck > 7 dias e distinta), 20/20 no total; `dev_key_permissions_test.sh` cobre as folhas novas. **Mutações medidas:** (a) sem `certificates` → 502 via Traefik e E4 acusa; (b) sem `API_TLS_CLIENT_CA_PATH` → exit 78 com a mensagem exata no log, container em restart, Grupo A acusa; (c) `requireClientCertificate: false` → E5 e E6 acusam (200 sem certificado); (d) wrapper relançando como `StateError` → E2/E5/E8 seguem verdes e o relic loga `ERROR: Bad state` por recusa (a camada do SDK protege; o `handleError` é defesa em profundidade). **Emulador (`emulator-5554`, API 36, 2026-10-27):** `e2e.sh --emulator --keep` verde (tls_invariants 20/20, `live_check` do paciente e do ACS, smoke dos dois apps pelo Traefik com mTLS atrás). `acs_full_e2e.sh`: **5/6 em duas execuções** — os cinco que sincronizam visitas pelo Traefik (rajada de 100, conflito #79, MFA + refresh, fila por dono) passam; o 1º teste (`login real, seletor por microárea, visita e sincronização`) falha num `tap` **local** em `sync_visits` que não acerta o widget (hit test cai numa superfície Material sobre o botão — a SnackBar "visita salva"), antes de qualquer rede; `apps/` é idêntico ao `develop` neste branch; o último verde registrado desse script é o da #83 (2026-10-25, seção da telemetria), e depois dele `apps/acs` mudou em e70e0c3/2c2cf4c/9632d40 (#86, 2026-10-25, `app.dart` perto do `showSnackBar`) e 6b8c8dc (#88, 2026-10-27) — os suspeitos. **Regressão pré-existente do e2e, fora da #90**, a investigar em issue própria. **Revisão independente do núcleo** (checkpoint depois da Task 5): 2 achados importantes corrigidos (close do adapter; diagnóstico do boot) — o `StateError` do `run()` `async void` caía no `runZonedGuarded` da #56 só com o tipo, e uma `TlsException` de arquivo malformado era classificada como transitória e saía com **código 0**; agora `carregar` nomeia a variável e sai 78. **Observações medidas:** o Traefik tira um container `unhealthy` da rota (404, não 5xx) — por isso o healthcheck precisa da folha; `openssl s_client -brief` imprime `CONNECTION ESTABLISHED` no TLS 1.3 mesmo recusado (as sondas usam curl e "nenhuma linha HTTP"). **Aberto:** produção (no Render o TLS termina na borda; nada disto se aplica); rebase do fork a cada upgrade do Serverpod; o backend não loga o CN do certificado de cliente; rede Docker interna como defesa em profundidade (alternativa C, issue de continuação); log de recusa sem agregação (uma linha por handshake recusado) e rótulo único para `SocketException` de `accept()`; Traefik roda como root e lê `ca.key` do mesmo mount (pré-existente). RNF04 segue **parcial** por Postgres, Gorush, 8082/8083 e 8081, fora do escopo. - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Pendências do ACS (2026-10-02) diff --git a/scripts/qa/tls_invariants.sh b/scripts/qa/tls_invariants.sh index 0157d461..87c03ac6 100755 --- a/scripts/qa/tls_invariants.sh +++ b/scripts/qa/tls_invariants.sh @@ -218,9 +218,18 @@ else openssl x509 -req -days 2 -in "$e_tmp/fora.csr" -CA "$e_tmp/fora-ca.crt" -CAkey "$e_tmp/fora-ca.key" \ -CAcreateserial -extfile "$e_tmp/fora.ext" -out "$e_tmp/fora.crt" >/dev/null 2>&1 chmod 755 "$e_tmp"; chmod 644 "$e_tmp"/* + # A geração do certificado "de fora" tem de ser conferida: se ela falhar (ou o + # bind-mount de $e_tmp chegar vazio, daemon remoto/rootless), o curl sai com 58 + # ("could not load client certificate"), a saída tem `HTTP 000` e E6 passaria + # SEM ter apresentado certificado nenhum — falso verde na asserção que diz "o + # contexto confia só na CA do RPC" (revisão final da #90). + fora_ok=1 + [[ -s "$e_tmp/fora.crt" && -s "$e_tmp/fora.key" ]] || fora_ok=0 if [[ "$e2_ok" -eq 0 ]]; then falha 'E5/E6 — sem o controle positivo E2, a recusa seria verdade por acidente (stack no chão recusa tudo)' + elif [[ "$fora_ok" -eq 0 ]]; then + falha "E6 — não deu para gerar o certificado de outra CA em $e_tmp (openssl falhou); sem ele a sonda não prova nada" else e5="$(sonda_8080 --tlsv1.3 --cacert /c/ca.crt "$url_8080")" if recusado "$e5"; then @@ -230,7 +239,9 @@ else fi e6="$(sonda_8080 --tlsv1.3 --cacert /c/ca.crt --cert /f/fora.crt --key /f/fora.key "$url_8080")" - if recusado "$e6"; then + if printf '%s' "$e6" | grep -q 'curl: (58)'; then + falha "E6 — o curl não conseguiu carregar o certificado de outra CA (curl: (58)); a sonda não apresentou certificado e não prova nada: $(printf '%s' "$e6" | grep 'curl: (58)' | head -1)" + elif recusado "$e6"; then ok 'E6 — certificado de OUTRA CA (mesmo CN do Traefik) é recusado no handshake' else falha "E6 — a 8080 interna aceitou certificado de outra CA — o contexto confia em mais do que a CA do RPC: $(printf '%s' "$e6" | grep -E 'status|HTTP ' | tail -2 | tr '\n' ' ')"