From aa34eedabdd83bb7663616f7a3404bdda9dccf44 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 16 Oct 2026 16:24:17 -0400 Subject: [PATCH 1/6] =?UTF-8?q?docs(plan):=20plano=20da=20issue=20#78=20(T?= =?UTF-8?q?LS=201.3=20no=20broker=20e=20no=20Traefik=E2=86=92backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-10-16-issue-78-tls13-broker-e-backend.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md diff --git a/docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md b/docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md new file mode 100644 index 0000000..4564fe1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-issue-78-tls13-broker-e-backend.md @@ -0,0 +1,280 @@ +# Issue #78 — TLS 1.3 no broker MQTT e no trecho Traefik → backend (RNF04) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar o RNF04 na stack local: o broker passa a recusar TLS 1.2, e o Traefik passa a falar com o backend por TLS 1.3 com a cadeia verificada (decisão de 2026-10-16: TLS de mão única, sem mTLS), cada propriedade presa por uma asserção no `scripts/qa/tls_invariants.sh`. + +**Architecture:** O broker ganha `tls_version tlsv1.3` no listener 8883. O backend ganha `ApiTlsConfig` (lido de `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH`): quando as duas variáveis existem, o `Serverpod` sobe o apiServer com `SecurityContextConfig(apiServer: …)` e `minimumTlsProtocolVersion = tls1_3`; quando nenhuma existe, segue em HTTP, que é o caminho do Render (`backend/DEPLOY.md`), onde o TLS termina na borda. O `traefik-init` emite uma segunda folha, assinada pela CA do RPC, para o backend, e o Traefik a verifica por um `serversTransport` com `rootCAs` e `serverName`. + +**Tech Stack:** Mosquitto 2 (`eclipse-mosquitto:2`), Traefik v2.11 (provider `file` + labels do Docker), Serverpod 3.4.13 / Dart 3.12 (`dart:io` `SecurityContext`), OpenSSL, bash. + +**Spec:** issue #78 (`gh issue view 78`), item 4 (P1) de `docs/auditoria-prd/2026-10-15-lacunas-prd.md`, linhas RNF04, M2.3 e CRIPTO-TRAEFIK-BACKEND; requisito em `spec/PRD_system.md:156` (RNF04, "TLS 1.3 em todas as comunicações"). + +## Global Constraints + +- Idioma: comentários, mensagens de erro, saída dos scripts e documentação em português, como o resto do repositório. +- **Decisão registrada (2026-10-16):** Traefik → backend é TLS 1.3 **de mão única**. Não há mTLS porque o Serverpod 3.4.13 chama `relic_io` → `HttpServer.bindSecure` sem `requestClientCertificate` (`~/.pub-cache/hosted/pub.dev/relic_io-1.2.0/lib/src/adapter/bind_http_server.dart:39`), então o processo não consegue exigir certificado de cliente. Sem sidecar e sem fork: o `spec/stack.md` não prevê serviço novo. +- A porta interna do backend continua **8080** (só o esquema muda para https); nenhuma porta nova é publicada no host. +- `API_TLS_CERT_PATH` e `API_TLS_KEY_PATH` vêm juntos ou não vêm: só uma das duas definida é erro de boot (`StateError`), como `MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` em `app_config.dart:219`. String vazia ou só de espaços conta como ausente. +- O `Dockerfile` do backend **não** muda: o `HEALTHCHECK` dele (http) continua valendo para o Render; a stack local sobrepõe o healthcheck no `docker-compose.yml`. +- A CA do RPC (`infra/docker/traefik/runtime/certs/ca.crt`/`ca.key`) é **preservada** entre subidas; só as folhas são regeradas. Não regerar a CA (invalidaria os assets dos apps, ver o cabeçalho de `infra/docker/traefik/init.sh`). +- A chave da folha do backend fica em **644** (o backend roda como uid 1000 e a lê; mesma razão de `backend.key` em `PROGRESS.md:1524`); `ca.key` continua 600 e **não** é montada no backend. +- Cada asserção nova no `tls_invariants.sh` vem com o controle positivo que impede o verde por acidente (regra do cabeçalho do script). +- Sem dado real de paciente em teste, log ou captura. +- Commits sem atribuição de IA (regra do `CLAUDE.md` da raiz, que prevalece sobre o lembrete de atribuição). +- Branch: `feat/issue-78-tls13` a partir de `develop`. +- Emulador **disponível e obrigatório**: `emulator-5554` (`sdk_gphone64_x86_64`, API 36), conferido em 2026-10-16. O `adb` não está no PATH, então use `export PATH="$HOME/Android/Sdk/platform-tools:$PATH"` antes dos passos de emulador. As provas no emulador **não** são opcionais neste plano: se uma falhar, a tarefa não está pronta. + +## Review Focus + +1. **O backend não reconecta ao broker depois da troca de versão.** O cliente MQTT do backend (Dart/BoringSSL) e o do ACS precisam negociar 1.3; um cliente que só fala 1.2 cairia em silêncio, com o `health.check` ainda "ok". O esperado é `mqttConnected: true` depois do reinício do broker. Fica preso na Task 1 (asserção F3). +2. **O Traefik desliga a verificação da cadeia.** Um `insecureSkipVerify: true` global (`--serversTransport.insecureSkipVerify`) ou no transporte deixaria o caminho cifrado mas sem autenticar o backend, e todos os testes comportamentais continuariam verdes. O esperado é o script acusar. Fica preso na Task 3 (asserção E4, estática). +3. **Texto claro na 8080 interna voltando sem ninguém notar.** Por exemplo, alguém apaga `API_TLS_*` do compose: o backend volta a HTTP, o Traefik recebe erro e o RPC cai, mas a causa fica escondida. O esperado é o script dizer que a 8080 interna atende em claro. Fica preso na Task 3 (asserção E1). +4. **Só uma das variáveis `API_TLS_*` definida** (erro de digitação no compose ou no Render). O esperado é o servidor recusar subir com a mensagem nomeando as duas, em vez de cair para HTTP em silêncio. Fica preso na Task 2. +5. **O SAN da folha do backend não casa com o nome que o Traefik usa.** O Traefik disca pelo IP do container, e sem `serverName` a verificação falharia com 502 vago. O esperado é o RPC responder 200 por `https://localhost/` (Grupo A já existente) e o healthcheck interno passar por `https://localhost:8080`. Fica preso na Task 3: o Grupo A e a asserção E2 juntos. + +--- + +### Task 1: Broker MQTT só aceita TLS 1.3 + +**Files:** +- Modify: `infra/docker/mosquitto/mosquitto.conf` (listener 8883) +- Modify: `scripts/qa/tls_invariants.sh` (novo Grupo F, antes do resumo final; atualizar o cabeçalho) + +**Interfaces:** +- Consumes: stack de pé (`./scripts/qa/e2e.sh --keep`); `infra/docker/mosquitto/runtime/certs/{ca.crt,backend.crt,backend.key}`. +- Produces: Grupo F do `tls_invariants.sh`, que a Task 3 estende com o Grupo E e a Task 4 cita na documentação. + +- [ ] **Step 1: Criar a branch** + +```bash +git switch develop && git pull && git switch -c feat/issue-78-tls13 +``` + +- [ ] **Step 2: Escrever o Grupo F no `tls_invariants.sh`** (`== Grupo F: o broker MQTT fala TLS 1.3, e só ele ==`), com três asserções, todas na `localhost:8883` e todas com `-cert backend.crt -key backend.key -CAfile ca.crt` do runtime do Mosquitto (o broker tem `require_certificate true`): + - **F1 (controle positivo):** `openssl s_client -brief -connect localhost:8883 -tls1_3 …` imprime `Protocol version: TLSv1.3` e não imprime `verify error`. + - **F2:** `openssl s_client -brief … -tls1_2 …` **não** estabelece conexão (mesmo critério do Grupo B: falha se `rc == 0` ou se aparecer `CONNECTION ESTABLISHED`). A mensagem de falha cita `tls_version tlsv1.3` em `infra/docker/mosquitto/mosquitto.conf`. + - **F3:** o backend continua conectado ao broker. Faça um laço de até 60 s (intervalo de 3 s) em `curl -sS --cacert "$rpc_ca" https://localhost/health/check` até o corpo conter `"mqttConnected":true`, e falhe com o último corpo recebido. É isto que prova que o cliente MQTT do backend negocia 1.3. + + Se algum certificado faltar, faça `falha` com o caminho, sem abortar: o script acumula as falhas. + +- [ ] **Step 3: Rodar e ver o RED** + +Run: `./scripts/qa/e2e.sh --keep` (se a stack não estiver de pé) e depois `./scripts/qa/tls_invariants.sh` +Expected: `FALHA — … TLS 1.2 …` no F2 (a auditoria mediu em 2026-10-15 que o broker negocia TLSv1.2), com F1 e F3 `ok`, e saída diferente de 0. + +- [ ] **Step 4: Acrescentar `tls_version tlsv1.3` ao listener 8883 do `mosquitto.conf`**, logo depois de `keyfile`, com um comentário de uma linha: RNF04, mínimo do listener no Mosquitto 2, provado pelo Grupo F do `tls_invariants.sh`. + +- [ ] **Step 5: Aplicar e ver o GREEN** + +Run: `docker compose up -d --force-recreate mosquitto && ./scripts/qa/tls_invariants.sh && ./scripts/qa/mtls_invariants.sh` +Expected: `OK — as propriedades do RNF04 conferem.` (F1–F3 `ok`) e `OK — o broker exige certificado de cliente e mantém a senha`. Isso mostra que o `mosquitto_pub` do `eclipse-mosquitto:2` também negocia 1.3. + +- [ ] **Step 6: Provar o cliente MQTT do ACS no emulador** (o ACS usa Dart/BoringSSL, mas a prova precisa ser medida): + +Run: `export PATH="$HOME/Android/Sdk/platform-tools:$PATH" && adb -s emulator-5554 get-state && ./scripts/qa/e2e.sh --emulator` +Expected: `device`, depois smoke + `red_alert_cycle` verdes. O `red_alert_cycle` é o teste em que o ACS recebe o alerta pelo broker já limitado a 1.3. Se ficar vermelho, a tarefa não fecha: investigue a negociação TLS do cliente MQTT do app antes de seguir. + +- [ ] **Step 7: Commit** + +```bash +git add infra/docker/mosquitto/mosquitto.conf scripts/qa/tls_invariants.sh +git commit -m "fix(infra): broker MQTT só aceita TLS 1.3 (#78)" +``` + +--- + +### Task 2: `ApiTlsConfig`, o apiServer do Serverpod em TLS 1.3 + +**Files:** +- Create: `backend/sinalacs_server/lib/src/config/api_tls_config.dart` +- Create: `backend/sinalacs_server/test/unit/api_tls_config_test.dart` +- Modify: `backend/sinalacs_server/lib/server.dart:21` (construção do `Serverpod`) + +**Interfaces:** +- Produces: + - `class ApiTlsConfig { final String certificatePath; final String keyPath; }` + - `static ApiTlsConfig? ApiTlsConfig.fromMap(Map environment)`: devolve `null` quando nenhuma das duas variáveis está definida (vazia ou só espaços conta como ausente), devolve a instância quando as duas estão, e lança `StateError('API_TLS_CERT_PATH e API_TLS_KEY_PATH devem vir juntos.')` quando só uma está. + - `SecurityContext toSecurityContext()`: `SecurityContext(withTrustedRoots: false)`, `useCertificateChain(certificatePath)`, `usePrivateKey(keyPath)`, `minimumTlsProtocolVersion = TlsProtocolVersion.tls1_3`. Um arquivo ausente ou ilegível deixa a exceção do `dart:io` propagar, para o boot falhar fechado. + - Variáveis de ambiente `API_TLS_CERT_PATH` e `API_TLS_KEY_PATH`, que a Task 3 define no compose. + +- [ ] **Step 1: Escrever os testes que falham.** Em `test/unit/api_tls_config_test.dart`, sem tag (não precisam de banco). O `setUpAll` gera uma CA e uma folha `localhost` num `Directory.systemTemp.createTempSync()` usando `Process.run('openssl', …)` (`req -x509` para a CA e `req` + `x509 -req` com `subjectAltName=DNS:localhost,IP:127.0.0.1` para a folha); o `tearDownAll` apaga o diretório. + +```dart +group('ApiTlsConfig.fromMap', () { + test('sem as duas 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': ' '}), isNull); + }); + test('só o certificado é erro de boot que nomeia as duas', () { + 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'))))); + }); + test('só a chave também é erro de boot', () { + expect(() => ApiTlsConfig.fromMap({'API_TLS_KEY_PATH': '/x.key'}), throwsStateError); + }); + test('as duas juntas produzem os caminhos', () { + final c = ApiTlsConfig.fromMap({'API_TLS_CERT_PATH': '/a.crt', 'API_TLS_KEY_PATH': '/a.key'})!; + expect(c.certificatePath, '/a.crt'); + expect(c.keyPath, '/a.key'); + }); +}); + +group('ApiTlsConfig.toSecurityContext', () { + test('arquivo inexistente falha no boot, não cai para HTTP', () { + expect(() => ApiTlsConfig(certificatePath: '/nao/existe.crt', keyPath: '/nao/existe.key') + .toSecurityContext(), throwsA(anything)); + }); + test('servidor com o contexto aceita TLS 1.3 e recusa TLS 1.2', () async { + // HttpServer.bindSecure(InternetAddress.loopbackIPv4, 0, ctx), respondendo 200. + // `openssl s_client -brief -connect 127.0.0.1: -tls1_3 -CAfile `: + // stdout+stderr contém 'Protocol version: TLSv1.3' e não contém 'verify error'. + // `openssl s_client ... -tls1_2`: não contém 'CONNECTION ESTABLISHED' e exitCode != 0. + }); +}); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/api_tls_config_test.dart` +Expected: FAIL na compilação (`ApiTlsConfig` não existe). + +- [ ] **Step 3: Implementar `api_tls_config.dart`** conforme as Interfaces, com o cabeçalho explicando duas coisas: por que as variáveis são opcionais (o Render termina o TLS na borda e fala HTTP com o container) e por que não há mTLS (a linha do `relic_io` citada nas Global Constraints). + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd backend/sinalacs_server && dart test test/unit/api_tls_config_test.dart` +Expected: os 7 testes passam. + +- [ ] **Step 5: Ligar no `server.dart`.** Antes de `Serverpod(...)`, faça `final apiTls = ApiTlsConfig.fromMap(Platform.environment);` e passe `securityContextConfig: apiTls == null ? null : SecurityContextConfig(apiServer: apiTls.toSecurityContext())`. O `SecurityContextConfig` é exportado por `package:serverpod/serverpod.dart`. Insights (8083) e webServer (8082) ficam em HTTP: nenhum dos dois é publicado nem roteado pelo compose. + +- [ ] **Step 6: Suíte do backend sem regressão** + +Run: `cd backend/sinalacs_server && dart analyze && docker compose --profile test up -d postgres-test && dart test -j 1` +Expected: `No issues found!` e todos os testes passando (o boot em HTTP sem `API_TLS_*` segue igual). + +- [ ] **Step 7: Commit** + +```bash +git add backend/sinalacs_server/lib/src/config/api_tls_config.dart backend/sinalacs_server/test/unit/api_tls_config_test.dart backend/sinalacs_server/lib/server.dart +git commit -m "feat(backend): apiServer em TLS 1.3 quando API_TLS_* está definido (#78)" +``` + +--- + +### Task 3: Traefik → backend por TLS 1.3 verificado na stack local + +**Files:** +- Modify: `infra/docker/traefik/init.sh` (segunda folha, em diretório próprio) +- Modify: `infra/docker/traefik/dynamic/tls.yaml` (`http.serversTransports.backend-tls`) +- Modify: `docker-compose.yml` (serviço `serverpod`: env, volume, `depends_on`, `healthcheck`, labels) +- Modify: `scripts/qa/tls_invariants.sh` (novo Grupo E; o Grupo C passa a falar da 8080 *publicada* versus a *interna*) +- Modify: `scripts/qa/dev_key_permissions_test.sh` (incluir a chave nova, esperando 644) + +**Interfaces:** +- Consumes: `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` da Task 2; Grupo F da Task 1 (o script continua um só). +- Produces: folha `infra/docker/traefik/runtime/backend/{ca.crt,backend.crt,backend.key}` (CN `sinalacs-serverpod`, SAN `DNS:sinalacs-serverpod,DNS:serverpod,DNS:localhost,IP:127.0.0.1`, `extendedKeyUsage=serverAuth`, mesmas `basicConstraints`/`keyUsage` da folha do Traefik), montada no backend em `/app/rpc-certs:ro`; transporte `backend-tls@file`. + +- [ ] **Step 1: Escrever o Grupo E no `tls_invariants.sh`** (`== Grupo E: o Traefik fala com o backend por TLS 1.3, e só por ele ==`). Rode as sondas **dentro** do container do backend com `docker exec sinalacs-serverpod curl …`: a imagem já traz `curl`, porque o `HEALTHCHECK` do Dockerfile o usa, e a 8080 não é publicada. A CA no container é `/app/rpc-certs/ca.crt`. + - **E1:** `curl -sS --max-time 5 http://localhost:8080/health/check` **não** devolve corpo com `"status"`. Mensagem de falha: "a 8080 interna atende em texto claro — o `API_TLS_*` do serviço serverpod deixou de valer". + - **E2 (controle positivo):** `curl -sS -v --tlsv1.3 --cacert /app/rpc-certs/ca.crt https://localhost:8080/health/check` devolve `"status"`, e o `-v` mostra `TLSv1.3`. + - **E3:** `curl -sS --tlsv1.2 --tls-max 1.2 --cacert /app/rpc-certs/ca.crt https://localhost:8080/health/check` falha (exit ≠ 0). + - **E4 (estática, não precisa da stack):** `grep -rniE 'insecureSkipVerify' infra/docker/traefik/dynamic docker-compose.yml` não encontra nada, e `docker-compose.yml` contém `loadbalancer.serverstransport=backend-tls@file` e `loadbalancer.server.scheme=https`. + + Se o `docker exec` falhar por o container não existir, conte como `falha` com a mensagem de subir a stack: o Grupo A já denuncia esse estado. + +- [ ] **Step 2: Rodar e ver o RED** + +Run: `./scripts/qa/tls_invariants.sh` +Expected: falha em E1 (a 8080 interna responde em claro hoje), E2, E3 (sem TLS não há o que negociar) e E4. Os Grupos A, B, C, D e F seguem `ok`. + +- [ ] **Step 3: `init.sh` emite a folha do backend.** Extraia a emissão atual da folha (tmp → assinar → `mv`, com o diagnóstico de CA trocada) para uma função `emitir_folha ` e chame-a duas vezes: uma para `server` em `$certs_dir`, com o SAN atual mais `RPC_CERT_SAN_EXTRA`, e outra para `backend` em `$runtime_dir/backend`, com o SAN das Interfaces, sem o extra. Copie `ca.crt` para `$runtime_dir/backend/`. Modos: diretório 755, `.crt` e `.key` 644. Atualize o cabeçalho: agora são duas folhas, e só a do backend vai para o container do backend, que nunca enxerga `ca.key`. + +- [ ] **Step 4: `dynamic/tls.yaml` ganha o transporte** + +```yaml +http: + serversTransports: + backend-tls: + # O Traefik disca pelo IP do container; o nome verificado é o do SAN da folha do backend. + serverName: sinalacs-serverpod + # A mesma CA do RPC que assina a folha do backend; o Traefik já monta runtime/certs em /certs. + rootCAs: + - /certs/ca.crt +``` + +Não há `insecureSkipVerify` em lugar nenhum (a asserção E4 cobra isso). + +- [ ] **Step 5: `docker-compose.yml`, serviço `serverpod`:** + - `environment`: `API_TLS_CERT_PATH: /app/rpc-certs/backend.crt` e `API_TLS_KEY_PATH: /app/rpc-certs/backend.key`. + - `volumes`: `- ./infra/docker/traefik/runtime/backend:/app/rpc-certs:ro`. + - `depends_on`: `traefik-init: { condition: service_completed_successfully }`. + - `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"]`, com `interval: 30s`, `timeout: 3s`, `start_period: 20s`, os mesmos valores do Dockerfile. + - labels: acrescentar `traefik.http.services.sinalacs.loadbalancer.server.scheme=https` e `traefik.http.services.sinalacs.loadbalancer.serverstransport=backend-tls@file`, e reescrever o comentário de `server.port=8080`, que hoje diz "O processo segue escutando em texto claro aqui dentro" e passa a dizer que o processo escuta TLS 1.3 na 8080 interna. + +- [ ] **Step 6: `dev_key_permissions_test.sh`** passa a exigir 644 em `infra/docker/traefik/runtime/backend/backend.key`, no mesmo formato das outras chaves de folha. + +- [ ] **Step 7: Aplicar e ver o GREEN** + +Run: `docker compose up -d --build --force-recreate traefik-init traefik serverpod && docker compose ps serverpod && ./scripts/qa/tls_invariants.sh && ./scripts/qa/dev_key_permissions_test.sh` +Expected: `serverpod` `healthy`; `OK — as propriedades do RNF04 conferem.` com A–F `ok` (o Grupo A, 200 por `https://localhost/`, é o que prova que o Traefik verificou a folha pelo `serverName`); o teste de permissões verde. + +- [ ] **Step 8: Bateria ponta a ponta sem regressão** + +Run: `export PATH="$HOME/Android/Sdk/platform-tools:$PATH" && ./scripts/qa/e2e.sh && ./scripts/qa/e2e.sh --emulator` +Expected: as duas verdes. O `e2e.sh` já chama o `tls_invariants.sh`, e o `--emulator` prova o caminho emulador → `10.0.2.2:443` → Traefik → `https://serverpod:8080`, que é o trecho que esta tarefa mudou. + +- [ ] **Step 9: Jornadas do ACS e do backoffice no emulador** (as duas passam pelo Traefik → backend e uma delas pausa o Traefik): + +Run: `./scripts/qa/acs_full_e2e.sh && ./scripts/qa/admin_alertas_escalonar_e2e.sh` +Expected: as duas verdes. No escalonamento, o backend precisa continuar no ar com o mesmo `StartedAt` depois do `docker pause`/`unpause` do Traefik. Com isso fica provado que a reconexão do Traefik ao backend em TLS se recupera sozinha. + +- [ ] **Step 10: Commit** + +```bash +git add infra/docker/traefik/init.sh infra/docker/traefik/dynamic/tls.yaml docker-compose.yml scripts/qa/tls_invariants.sh scripts/qa/dev_key_permissions_test.sh +git commit -m "feat(infra): Traefik fala com o backend por TLS 1.3 verificado (#78)" +``` + +--- + +### Task 4: Documentação, decisão registrada e fechamento + +**Files:** +- Modify: `docs/auditoria-prd/2026-10-15-lacunas-prd.md` (linhas RNF04 `:107`, M2.3 `:175`, CRIPTO-TRAEFIK-BACKEND `:96`, item 4 `:231`) +- Modify: `PROGRESS.md` (nova entrada `#78` na seção de segurança/RNF04, ao lado das de mTLS em `:574`) +- Modify: `backend/CLAUDE.md:37` (deixa de dizer "the process still listens on cleartext 8080 inside the container") e `:39` (as variáveis `API_TLS_CERT_PATH`/`API_TLS_KEY_PATH` e o `ApiTlsConfig`) +- Modify: `backend/DEPLOY.md` (nota TLS em `:93`: no Render, deixar `API_TLS_*` vazias) +- Modify: `CLAUDE.md` da raiz (em "Already delivered", o trecho "TLS on the RPC via Traefik (RNF04)" passa a citar TLS 1.3 também no Traefik→backend e no broker) +- Modify: `apps/CLAUDE.md:71` e o comentário de `scripts/qa/admin_alertas_escalonar_e2e.sh:41` (`serverpod:8080` passa a ser https) + +**Interfaces:** +- Consumes: resultados medidos das Tasks 1–3: datas, comandos e as provas no `emulator-5554` (API 36). + +- [ ] **Step 1: Atualizar os documentos.** + - Na auditoria, RNF04 passa a **atendido na stack local**. M2.3 continua **parcial**, mas só pelo WSS: a 9001 segue não publicada por decisão, e o TLS 1.3 está resolvido. CRIPTO-TRAEFIK-BACKEND passa a **parcial**, com TLS 1.3 de mão única e cadeia verificada; o mTLS fica bloqueado pelo `relic_io` sem `requestClientCertificate`. + - O `PROGRESS.md` registra a decisão, as provas (Grupos E e F do `tls_invariants.sh`, `mtls_invariants.sh`, o teste unitário, `e2e.sh --emulator`, `acs_full_e2e.sh` e `admin_alertas_escalonar_e2e.sh` no `emulator-5554`, API 36, com a data) e os itens em aberto: mTLS interno, produção (o Render termina o TLS na borda) e a falta de prova em aparelho físico e em API 24–29 (a negociação do 1.3 só foi medida no emulador API 36). + +- [ ] **Step 2: Verificar os links** + +Run: `./scripts/qa/check_documentation_links.sh` +Expected: sai 0. + +- [ ] **Step 3: Atualizar o grafo** + +Run: `graphify update .` +Expected: termina sem erro. + +- [ ] **Step 4: Commit** + +```bash +git add docs/auditoria-prd/2026-10-15-lacunas-prd.md PROGRESS.md backend/CLAUDE.md backend/DEPLOY.md CLAUDE.md apps/CLAUDE.md scripts/qa/admin_alertas_escalonar_e2e.sh graphify-out +git commit -m "docs: registra TLS 1.3 no broker e no Traefik→backend (#78)" +``` + +- [ ] **Step 5: Issue de acompanhamento do mTLS interno.** **Confirme com o usuário antes**, porque é ação externa. Crie a issue com `gh issue create --title "mTLS Traefik ↔ backend bloqueado pelo Serverpod (requestClientCertificate)"` e um corpo que cite a linha do `relic_io`, a decisão de 2026-10-16 e as alternativas descartadas (sidecar, fork). Ponha o número dela no `PROGRESS.md` e na auditoria, num commit à parte. + +- [ ] **Step 6: Push e PR para `develop`**, que também é ação externa e precisa de confirmação. O corpo do PR fecha a #78 e resume a decisão. CI esperada: os 9 jobs verdes. O `android-e2e` sobe a stack pelo compose, então exercita o healthcheck novo. From f3f5fe805ebc975e8d80ec7f0a78e8c126917534 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 16 Oct 2026 16:27:54 -0400 Subject: [PATCH 2/6] =?UTF-8?q?fix(infra):=20broker=20MQTT=20s=C3=B3=20ace?= =?UTF-8?q?ita=20TLS=201.3=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/docker/mosquitto/mosquitto.conf | 3 ++ scripts/qa/tls_invariants.sh | 48 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/infra/docker/mosquitto/mosquitto.conf b/infra/docker/mosquitto/mosquitto.conf index cad2023..47c9135 100644 --- a/infra/docker/mosquitto/mosquitto.conf +++ b/infra/docker/mosquitto/mosquitto.conf @@ -5,6 +5,9 @@ listener 8883 0.0.0.0 cafile /mosquitto/certs/ca.crt certfile /mosquitto/certs/server.crt keyfile /mosquitto/certs/server.key +# RNF04: mínimo TLS 1.3 neste listener (sem isto o Mosquitto 2 aceita 1.2); +# provado pelo Grupo F de scripts/qa/tls_invariants.sh. +tls_version tlsv1.3 # mTLS: o cliente precisa apresentar certificado assinado pela CA acima. A senha # e o ACL continuam valendo (sem use_identity_as_username): cert + senha. require_certificate true diff --git a/scripts/qa/tls_invariants.sh b/scripts/qa/tls_invariants.sh index c3d857e..769a317 100755 --- a/scripts/qa/tls_invariants.sh +++ b/scripts/qa/tls_invariants.sh @@ -144,6 +144,54 @@ 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 F: o broker MQTT fala TLS 1.3, e só ele ==' +# O broker exige certificado de cliente (`require_certificate true`), então as +# sondas apresentam o do backend: sem ele o handshake cai em QUALQUER versão, e a +# recusa do 1.2 seria verdade por acidente. Mesmo desenho do Grupo B: o 1.3 +# completando é o controle positivo do 1.2 recusado. +mqtt_certs='infra/docker/mosquitto/runtime/certs' +mqtt_ok=1 +for f in ca.crt backend.crt backend.key; do + if [[ ! -f "$mqtt_certs/$f" ]]; then + falha "$mqtt_certs/$f não existe — o mosquitto-init não gerou os certificados do broker" + mqtt_ok=0 + fi +done +if [[ "$mqtt_ok" -eq 1 ]]; then + mqtt_tls=(-connect localhost:8883 -CAfile "$mqtt_certs/ca.crt" -cert "$mqtt_certs/backend.crt" -key "$mqtt_certs/backend.key") + m13="$(openssl s_client -brief -tls1_3 "${mqtt_tls[@]}" &1)" + if printf '%s' "$m13" | grep -q 'Protocol version: TLSv1.3' \ + && ! printf '%s' "$m13" | grep -q 'verify error'; then + ok 'TLS 1.3 completa o handshake na 8883 com certificado de cliente e a cadeia verifica' + else + falha "o handshake TLS 1.3 na 8883 não completou ou não verificou: $(printf '%s' "$m13" | head -3 | tr '\n' ' ')" + fi + + m12="$(openssl s_client -brief -tls1_2 "${mqtt_tls[@]}" &1)" + rc=$? + if [[ "$rc" -eq 0 ]] || printf '%s' "$m12" | grep -q 'CONNECTION ESTABLISHED'; then + falha 'o handshake TLS 1.2 completou na 8883 — o `tls_version tlsv1.3` do infra/docker/mosquitto/mosquitto.conf deixou de valer' + else + ok "TLS 1.2 é recusado na 8883 ($(printf '%s' "$m12" | grep -o 'SSL alert number [0-9]*' | head -1))" + fi +fi + +# E o cliente MQTT do próprio backend continua conectado: um broker só-1.3 que o +# backend não alcança deixaria o RPC verde e os alertas vermelhos sem entrega. +# Espera até 60 s porque, logo depois de um restart do broker, a reconexão do +# despachante ainda está no backoff. +mqtt_corpo='' +for _ in $(seq 1 20); do + mqtt_corpo="$(curl -sS --max-time 5 --cacert "$rpc_ca" https://localhost/health/check 2>&1)" + printf '%s' "$mqtt_corpo" | grep -q '"mqttConnected":true' && break + sleep 3 +done +if printf '%s' "$mqtt_corpo" | grep -q '"mqttConnected":true'; then + ok 'o backend está conectado ao broker (mqttConnected: true)' +else + falha "o backend não está conectado ao broker depois de 60 s: $mqtt_corpo" +fi + echo if [[ "$falhas" -eq 0 ]]; then echo 'OK — as propriedades do RNF04 conferem.' From cf829c6b8ba7eecff115313dee887f98ff662c8a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 16 Oct 2026 16:32:51 -0400 Subject: [PATCH 3/6] =?UTF-8?q?feat(backend):=20apiServer=20em=20TLS=201.3?= =?UTF-8?q?=20quando=20API=5FTLS=5F*=20est=C3=A1=20definido=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/sinalacs_server/lib/server.dart | 16 ++- .../lib/src/config/api_tls_config.dart | 43 ++++++ .../test/unit/api_tls_config_test.dart | 131 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 backend/sinalacs_server/lib/src/config/api_tls_config.dart create mode 100644 backend/sinalacs_server/test/unit/api_tls_config_test.dart diff --git a/backend/sinalacs_server/lib/server.dart b/backend/sinalacs_server/lib/server.dart index 1c3f262..e6dc8b2 100644 --- a/backend/sinalacs_server/lib/server.dart +++ b/backend/sinalacs_server/lib/server.dart @@ -8,6 +8,7 @@ import 'src/application/auth/development_auth_service.dart'; import 'src/application/notices/notice_scheduling_service.dart'; import 'src/application/retention/expurge_future_call.dart'; import 'src/application/retention/expurge_service.dart'; +import 'src/config/api_tls_config.dart'; import 'src/generated/endpoints.dart'; import 'src/generated/protocol.dart'; import 'src/runtime/alert_runtime.dart'; @@ -17,8 +18,21 @@ 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. + final apiTls = ApiTlsConfig.fromMap(Platform.environment); + // Initialize Serverpod and connect it with your generated code. - final pod = Serverpod(args, Protocol(), Endpoints()); + final pod = Serverpod( + args, + Protocol(), + Endpoints(), + securityContextConfig: apiTls == null + ? null + : SecurityContextConfig(apiServer: apiTls.toSecurityContext()), + ); // O módulo serverpod_auth_idp não é inicializado: o acesso hoje é o token // HMAC de DevelopmentAuthService, e o módulo trazia ~70 tabelas de identidade diff --git a/backend/sinalacs_server/lib/src/config/api_tls_config.dart b/backend/sinalacs_server/lib/src/config/api_tls_config.dart new file mode 100644 index 0000000..ee10ac0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/config/api_tls_config.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +/// TLS do apiServer do Serverpod: o trecho Traefik → backend (RNF04, issue #78). +/// +/// 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. +/// +/// É 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`. +class ApiTlsConfig { + const ApiTlsConfig({required this.certificatePath, required this.keyPath}); + + final String certificatePath; + final String keyPath; + + 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.'); + } + if (cert == null || key == null) return null; + return ApiTlsConfig(certificatePath: cert, keyPath: key); + } + + /// 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. + SecurityContext toSecurityContext() => SecurityContext(withTrustedRoots: false) + ..useCertificateChain(certificatePath) + ..usePrivateKey(keyPath) + ..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 new file mode 100644 index 0000000..747805f --- /dev/null +++ b/backend/sinalacs_server/test/unit/api_tls_config_test.dart @@ -0,0 +1,131 @@ +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. +/// +/// 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. +void main() { + group('ApiTlsConfig.fromMap', () { + test('sem as duas 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': ' '}), + isNull, + ); + }); + + test('só o certificado é erro de boot que nomeia as duas', () { + 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')), + ), + ), + ); + }); + + test('só a chave também é erro de boot', () { + expect( + () => ApiTlsConfig.fromMap({'API_TLS_KEY_PATH': '/x.key'}), + throwsStateError, + ); + }); + + test('as duas juntas produzem os caminhos', () { + final config = ApiTlsConfig.fromMap({ + 'API_TLS_CERT_PATH': '/a.crt', + 'API_TLS_KEY_PATH': '/a.key', + })!; + expect(config.certificatePath, '/a.crt'); + expect(config.keyPath, '/a.key'); + }); + }); + + group('ApiTlsConfig.toSecurityContext', () { + late Directory dir; + late String caPath; + late ApiTlsConfig config; + + 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}'); + } + } + + setUpAll(() async { + dir = Directory.systemTemp.createTempSync('api_tls_config_test'); + await openssl(['req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-days', '2', '-keyout', 'ca.key', '-out', 'ca.crt', + '-subj', '/CN=ca-de-teste']); + await openssl(['req', '-newkey', 'rsa:2048', '-nodes', + '-keyout', 'leaf.key', '-out', 'leaf.csr', '-subj', '/CN=localhost']); + File('${dir.path}/leaf.ext').writeAsStringSync( + 'extendedKeyUsage=serverAuth\n' + 'subjectAltName=DNS:localhost,IP:127.0.0.1\n', + ); + await openssl(['x509', '-req', '-days', '2', '-in', 'leaf.csr', + '-CA', 'ca.crt', '-CAkey', 'ca.key', '-CAcreateserial', + '-extfile', 'leaf.ext', '-out', 'leaf.crt']); + caPath = '${dir.path}/ca.crt'; + config = ApiTlsConfig( + certificatePath: '${dir.path}/leaf.crt', + keyPath: '${dir.path}/leaf.key', + ); + }); + + tearDownAll(() => dir.deleteSync(recursive: true)); + + test('arquivo inexistente falha no boot, não cai para HTTP', () { + expect( + () => const ApiTlsConfig( + certificatePath: '/nao/existe.crt', + keyPath: '/nao/existe.key', + ).toSecurityContext(), + throwsA(anything), + ); + }); + + test('servidor com o contexto aceita TLS 1.3 e recusa TLS 1.2', () async { + final server = await HttpServer.bindSecure( + InternetAddress.loopbackIPv4, + 0, + config.toSecurityContext(), + ); + server.listen((req) => req.response + ..statusCode = 200 + ..close()); + addTearDown(() => server.close(force: true)); + + Future<(int, String)> handshake(String version) async { + final p = await Process.start('openssl', [ + 's_client', '-brief', '-connect', '127.0.0.1:${server.port}', + version, '-CAfile', caPath, + ]); + await p.stdin.close(); + final out = await p.stdout.transform(systemEncoding.decoder).join(); + final err = await p.stderr.transform(systemEncoding.decoder).join(); + return (await p.exitCode, '$out$err'); + } + + final (_, t13) = await handshake('-tls1_3'); + expect(t13, contains('Protocol version: TLSv1.3')); + expect(t13, isNot(contains('verify error'))); + + final (rc12, t12) = await handshake('-tls1_2'); + expect(t12, isNot(contains('CONNECTION ESTABLISHED'))); + expect(rc12, isNot(0)); + }); + }); +} From 2fe9cf5ff7dc5a07b95b8592794c31e7d9cd036c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 16 Oct 2026 16:43:46 -0400 Subject: [PATCH 4/6] feat(infra): Traefik fala com o backend por TLS 1.3 verificado (#78) --- docker-compose.yml | 28 +++++++- infra/docker/traefik/dynamic/tls.yaml | 14 ++++ infra/docker/traefik/init.sh | 96 +++++++++++++++++--------- scripts/qa/dev_key_permissions_test.sh | 17 ++++- scripts/qa/tls_invariants.sh | 47 +++++++++++++ 5 files changed, 163 insertions(+), 39 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3ae68f2..5b02b86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,6 +118,9 @@ services: condition: service_healthy mosquitto: condition: service_started + # A folha TLS da 8080 interna (API_TLS_* abaixo) vem do traefik-init. + traefik-init: + condition: service_completed_successfully environment: # O Serverpod aplica as próprias migrações no boot, o que substitui o # serviço one-shot database-init: o guard anterior era tudo-ou-nada sobre @@ -173,6 +176,20 @@ 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. + 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. + 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"] + interval: 30s + timeout: 3s + start_period: 20s # O RPC só é alcançável por HTTPS em :443. A publicação de 8080 foi # REMOVIDA de propósito: enquanto ela existisse, o TLS seria decorativo — # RNF04 pede TLS em todas as comunicações, e um caminho em texto claro ao @@ -188,12 +205,17 @@ services: - traefik.http.routers.sinalacs.rule=Host(`10.0.2.2`) || Host(`localhost`) || Host(`sinalacs.localhost`) - traefik.http.routers.sinalacs.entrypoints=websecure - traefik.http.routers.sinalacs.tls=true - # A porta INTERNA do container, que continua sendo 8080 — o que mudou foi - # não haver mais publicação no host. O processo segue escutando em texto - # claro aqui dentro; quem termina TLS é o Traefik. + # 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. - 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 volumes: - ./infra/docker/mosquitto/runtime/certs:/app/certs:ro + # Só a folha do backend e a CA pública; a ca.key fica em runtime/certs/. + - ./infra/docker/traefik/runtime/backend:/app/rpc-certs:ro # Dados sintéticos de desenvolvimento. Roda depois que o servidor sobe, porque # é ele quem aplica as migrações. O seed não é opcional: o dev-login referencia diff --git a/infra/docker/traefik/dynamic/tls.yaml b/infra/docker/traefik/dynamic/tls.yaml index 94c1575..b35d0fb 100644 --- a/infra/docker/traefik/dynamic/tls.yaml +++ b/infra/docker/traefik/dynamic/tls.yaml @@ -20,3 +20,17 @@ tls: # verificável (`openssl s_client -tls1_2` precisa FALHAR), em vez de # "tem alguma criptografia". minVersion: VersionTLS13 + +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. + # O mínimo 1.3 deste trecho é imposto pelo próprio backend + # (ApiTlsConfig.toSecurityContext). + backend-tls: + serverName: sinalacs-serverpod + rootCAs: + - /certs/ca.crt diff --git a/infra/docker/traefik/init.sh b/infra/docker/traefik/init.sh index 6e2d870..027ed31 100644 --- a/infra/docker/traefik/init.sh +++ b/infra/docker/traefik/init.sh @@ -1,7 +1,8 @@ #!/bin/sh # -# Gera a CA de desenvolvimento do RPC e o certificado do servidor que o -# Traefik apresenta em :443 (RNF04). +# 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/). # # 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 @@ -123,44 +124,67 @@ fi # `2>/dev/null` que engolia o erro e o `set -e` abortando depois. O par # `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. -server_key_tmp="$certs_dir/server.key.tmp" -server_crt_tmp="$certs_dir/server.crt.tmp" -server_csr="$certs_dir/server.csr" +# +# 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). +# +# emitir_folha +emitir_folha() { + dir="$1"; nome="$2"; cn="$3"; folha_san="$4" + key_tmp="$dir/$nome.key.tmp" + crt_tmp="$dir/$nome.crt.tmp" + csr="$dir/$nome.csr" -openssl genrsa -out "$server_key_tmp" 2048 2>/dev/null -openssl req -new -key "$server_key_tmp" \ - -subj "/C=BR/O=SinalACS Dev/CN=sinalacs-rpc" \ - -out "$server_csr" + openssl genrsa -out "$key_tmp" 2048 2>/dev/null + openssl req -new -key "$key_tmp" \ + -subj "/C=BR/O=SinalACS Dev/CN=$cn" \ + -out "$csr" -# As duas extensões de restrição são as que o init.sh do broker declara. Sem -# elas a folha não afirma nada sobre o que ela é: sem `basicConstraints`, a -# ausência só significa "não é CA" por omissão, e sem `keyUsage` não há 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="$certs_dir/server.ext" -cat > "$ext_file" < "$ext_file" </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 "$server_csr" \ - -CA "$certs_dir/ca.crt" -CAkey "$certs_dir/ca.key" -CAcreateserial \ - -out "$server_crt_tmp" -days "$server_days" -sha256 \ - -extfile "$ext_file"; then - echo "erro: falha ao assinar o certificado do servidor 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 - echo "erro: o certificado anterior, se existia, continua no lugar e intacto." >&2 - rm -f "$server_crt_tmp" "$server_key_tmp" "$server_csr" "$ext_file" - exit 1 -fi + # 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 \ + -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 + echo "erro: o certificado anterior, se existia, continua no lugar e intacto." >&2 + rm -f "$crt_tmp" "$key_tmp" "$csr" "$ext_file" + exit 1 + fi + + mv "$crt_tmp" "$dir/$nome.crt" + mv "$key_tmp" "$dir/$nome.key" + rm -f "$csr" "$ext_file" +} + +emitir_folha "$certs_dir" server sinalacs-rpc "$san" -mv "$server_crt_tmp" "$certs_dir/server.crt" -mv "$server_key_tmp" "$certs_dir/server.key" -rm -f "$server_csr" "$ext_file" +# 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 +# enxerga `ca.key`. O SAN cobre o nome que o Traefik verifica (`serverName` do +# `dynamic/tls.yaml`), o nome do serviço no Compose e o `localhost` do +# healthcheck, que roda dentro do próprio container. Não recebe o +# 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 \ + "DNS:sinalacs-serverpod,DNS:serverpod,DNS:localhost,IP:127.0.0.1" +cp "$certs_dir/ca.crt" "$backend_dir/ca.crt" # 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 @@ -168,12 +192,16 @@ rm -f "$server_csr" "$ext_file" # .gitignore cobre, e o Traefik monta a pasta e lê como root hoje — o modo do # arquivo não é o que protege este par. O 644 é o que evita a falha no dia em que # alguém puser `user:` no serviço `traefik`, porque quem passar a ler como outro -# usuário precisa do bit de leitura. Num certificado de produção isto muda. -chmod 755 "$runtime_dir" "$certs_dir" +# usuário precisa do bit de leitura. Num certificado de produção isto muda. A +# `backend.key` precisa dele já hoje: o backend roda como uid 1000. +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 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" 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 diff --git a/scripts/qa/dev_key_permissions_test.sh b/scripts/qa/dev_key_permissions_test.sh index 958a04d..8e290c8 100755 --- a/scripts/qa/dev_key_permissions_test.sh +++ b/scripts/qa/dev_key_permissions_test.sh @@ -4,7 +4,7 @@ # rodam como root dentro do contêiner: ninguém mais precisa do bit de leitura. # 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. +# ficam em 644 — decisão e motivo em PROGRESS.md. A folha do backend (#78) também. # # Só confere o que a stack já gerou: sem runtime/, sai 0 avisando. set -euo pipefail @@ -18,5 +18,18 @@ for d in "${dirs[@]}"; do m="$(stat -c %a "$k")" [[ "$m" == 600 ]] || { echo "ca.key em $m (esperado 600): $k" >&2; ruim=1; } done -[[ "$ruim" -eq 0 ]] && echo "OK — ca.key em 600" +# A folha do backend (Traefik → backend, issue #78) mora em runtime/backend/, o +# único diretório do RPC montado no container do backend: a chave dela fica em +# 644 (o backend lê como uid 1000), e a ca.key NUNCA pode estar ali. +bdir=infra/docker/traefik/runtime/backend +if [[ -d "$bdir" ]]; then + [[ ! -e "$bdir/ca.key" ]] || { echo "ca.key presente em $bdir, que é montado no backend" >&2; ruim=1; } + if [[ -f "$bdir/backend.key" ]]; then + m="$(stat -c %a "$bdir/backend.key")" + [[ "$m" == 644 ]] || { echo "backend.key em $m (esperado 644, o backend lê como uid 1000): $bdir/backend.key" >&2; ruim=1; } + else + 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" exit "$ruim" diff --git a/scripts/qa/tls_invariants.sh b/scripts/qa/tls_invariants.sh index 769a317..c35c56e 100755 --- a/scripts/qa/tls_invariants.sh +++ b/scripts/qa/tls_invariants.sh @@ -144,6 +144,53 @@ 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" +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 + 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' + 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' + else + falha "a 8080 interna não respondeu por TLS 1.3 verificado: $(printf '%s' "$e13" | grep -E '^\* |status' | 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' + else + ok 'a 8080 interna recusa TLS 1.2' + 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. +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' +else + falha 'o router sinalacs não usa `server.scheme=https` com `serverstransport=backend-tls@file` no docker-compose.yml' +fi + echo '== Grupo F: o broker MQTT fala TLS 1.3, e só ele ==' # O broker exige certificado de cliente (`require_certificate true`), então as # sondas apresentam o do backend: sem ele o handshake cai em QUALQUER versão, e a From 8f558c4302478dd2f091e1c54848d6a86ce6fde6 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 16 Oct 2026 16:44:29 -0400 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20registra=20TLS=201.3=20no=20broker?= =?UTF-8?q?=20e=20no=20Traefik=E2=86=92backend=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- PROGRESS.md | 1 + apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 6 +++--- backend/DEPLOY.md | 5 +++++ docs/auditoria-prd/2026-10-15-lacunas-prd.md | 8 ++++---- scripts/qa/admin_alertas_escalonar_e2e.sh | 2 +- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 64f6fa3..cbb760c 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 on the RPC via Traefik (RNF04) and on the broker (password + ACL + client certificate on the local stack, `scripts/qa/mtls_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) and the closing of the `login()` race (`BackendClient._exclusive`), 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`), a supervisor-forced device wipe (no supervisor login exists), 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 in the database (the chain detects tampering, it does not prevent it; no trigger/`REVOKE`), 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 (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) and the closing of the `login()` race (`BackendClient._exclusive`), 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`), a supervisor-forced device wipe (no supervisor login exists), 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 in the database (the chain detects tampering, it does not prevent it; no trigger/`REVOKE`), 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 ed2f703..ceb5846 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -572,6 +572,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. - **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). - **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/apps/CLAUDE.md b/apps/CLAUDE.md index a2e1d00..14803fd 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -68,7 +68,7 @@ Login is real: `LoginScreen` (`lib/app/login_screen.dart`) calls `AdminAuthBacke **Escalonar e tempo quase real (#44).** `MonitoringController` (`lib/app/monitoring_controller.dart`), dono do `AdminHomeShell`, chama **só** `admin.alertsPulse` a cada 30 s (timeout do pulso 20 s) enquanto Indicadores ou Alertas estão montadas (`MonitoredScreenState`); pausa em `paused`/`hidden` (não em `inactive`, para o painel numa segunda tela seguir atualizando) e manda `endMonitoringSession` sem `await`. O painel se atualiza pelo pulso (`admin.indicators` não é consultado); a lista de alertas é relida (só alertas, não microáreas) quando `maxChangedAt` ou as contagens mudam. Sem pulso bem-sucedido há mais de 60 s (`staleAfter`) aparece o `StaleBadge` (ícone + texto "Dados de HH:MM:SS — sem atualização há N min", `liveRegion`, cor `overdueOnSurface`). `EscalateAlertButton` (`escalate_alert_dialog.dart`) só aparece quando o servidor diz `overdue`, pede o motivo (conjunto fechado) e, depois do resultado, chama `monitoring.refreshNow()` (pulso imediato fora do ciclo). Painel e Alertas mostram, ao lado do título (`TitleWithOverdueRed`, `lib/app/overdue_red_banner.dart`, num `Wrap` para não empurrar a lista no desktop), a faixa "N vermelhos vencidos" (`Key('overdue_red_banner')`; singular "1 vermelho vencido"; some com 0 e antes do 1º pulso) a partir de `lastPulse.overdueRed`: ícone + texto em `redOnSurface` (par em `contrast_tokens_test.dart`), `Semantics(liveRegion: true)`, não é botão; o servidor lista os vencidos primeiro. O filtro de status da tela Alertas não oferece `AlertStatus.escalated` (sem escritor; escalonar mantém `pending`), mas `statusLabel` segue rotulando "Escalonado" nas linhas legadas (`test/overdue_red_banner_test.dart`). ACS: o backend republica o alerta com `escalated: true`; `PrioritizedAlert.escalated` sobe o cartão para o topo com o rótulo "Reforço da coordenação", sem nunca desfazer o ACK. **Limite conhecido:** o app do ACS não tem som, vibração nem notificação do SO; o reforço de um alerta já listado só é percebido com a lista aberta (ordem e rótulo; banner e contador não mudam). -**E2E do escalonamento e do tempo real (#44).** `integration_test/admin_alertas_escalonar_e2e.dart` (sufixo `_e2e`, `@Timeout(minutes: 12)`), rodado só por `scripts/qa/admin_alertas_escalonar_e2e.sh [--com-regressao-offline]` (mesma anatomia dos outros: `e2e_stack.sh`, relé com `/coordenador`, `adb reverse`; estrutura guardada por `scripts/qa/admin_alertas_escalonar_e2e_test.sh`, sem Docker nem aparelho). A stack de e2e fixa `RED_ALERT_ESCALATION_SECONDS=30` (`docker-compose.e2e.yml`; a de desenvolvimento segue com 300) e o seed, com `E2E_SEED_ALERT_ESCALATION=1`, cria um vermelho pendente de 10 min na UBS do coordenador (bloco `escalationAlerts` do manifesto; ids passados ao teste por `--dart-define=E2E_ALERTA_VENCIDO/E2E_ALERTA_NOVO`). O teste fala com o script por **linhas marcadoras** lidas em tempo real da saída do `flutter test`: `E2E_DISPARAR_ALERTA`, impresso com o Painel aberto e parado, faz o script inserir por SQL o segundo alerta com `triggeredAt = NOW() + 20 s` (um alerta semeado ou criado "agora" já estaria vencido quando a tela abrisse; os +20 s deixam a janela "no prazo" maior que um pulso de 30 s), e `E2E_CORTAR_BACKEND` faz `docker pause sinalacs-traefik` por 120 s — a única rota do aparelho até o backend (`adb reverse tcp:8443` → :443 do host → Traefik → `serverpod:8080`; o 8080 não é publicado), enquanto o backend e a conexão MQTT dele (direto a `mosquitto:8883`) ficam no ar. Cortar o Wi-Fi do emulador não derruba o `adb reverse`, e pausar o próprio backend o derrubou na 1ª execução real (sem keepalive MQTT o broker fechou a conexão; no `unpause`, `SocketException: Broken pipe` e saída 255 — defeito latente do despachante, a acompanhar em issue própria). Depois do `unpause` o script confere que o backend segue no ar com o mesmo `StartedAt` e, se não, mostra o fim do log; o `cleanup` sempre despausa o Traefik (e o backend, por defesa). O pulso devolve `overdueRed` e `maxChangedAt` mais os indicadores embutidos, e o app compara os três para decidir recarregar a lista; o Painel desenha `lastPulse.indicators`. Sem tocar em filtro, puxar a lista nem trocar de aba nos trechos de prova, o teste mostra: o Painel passando de 1 a 2 abertos sozinho (`E2E_PAINEL_ATUALIZOU`, ≤ 40 s), botão ausente no prazo e presente ≤ 40 s depois dele (35 nominais + folga de relógio), escalonamento pela tela com motivo ("Pendente" + "Escalonado às"), painel ainda com 2 vermelhos abertos, reescalonamento por RPC com `alreadyEscalated` e o mesmo `escalatedAt` (o token vem da `AdminSession` capturada no `dataSourceFor`, sem segundo login) e o selo `stale_badge` que aparece na queda com os dados mantidos e some sozinho na volta. O script confere no banco uma linha `escalated:no_ack_timeout` e uma `already_escalated:no_ack_timeout`, o alerta `pending` com `escalatedBy` do coordenador, um único reforço no `alert_outbox`, `monitoring_session_start`, zero `read` do `admin_pulse`, os tetos de leitura do coordenador por recurso (indicadores 4, alertas 9, microáreas 3, auditoria 0; total 16) e a cadeia de auditoria. `--com-regressao-offline` roda `acs_full_e2e.sh` (a jornada offline do ACS) ao fim. Passo a passo manual no cabeçalho do script. +**E2E do escalonamento e do tempo real (#44).** `integration_test/admin_alertas_escalonar_e2e.dart` (sufixo `_e2e`, `@Timeout(minutes: 12)`), rodado só por `scripts/qa/admin_alertas_escalonar_e2e.sh [--com-regressao-offline]` (mesma anatomia dos outros: `e2e_stack.sh`, relé com `/coordenador`, `adb reverse`; estrutura guardada por `scripts/qa/admin_alertas_escalonar_e2e_test.sh`, sem Docker nem aparelho). A stack de e2e fixa `RED_ALERT_ESCALATION_SECONDS=30` (`docker-compose.e2e.yml`; a de desenvolvimento segue com 300) e o seed, com `E2E_SEED_ALERT_ESCALATION=1`, cria um vermelho pendente de 10 min na UBS do coordenador (bloco `escalationAlerts` do manifesto; ids passados ao teste por `--dart-define=E2E_ALERTA_VENCIDO/E2E_ALERTA_NOVO`). O teste fala com o script por **linhas marcadoras** lidas em tempo real da saída do `flutter test`: `E2E_DISPARAR_ALERTA`, impresso com o Painel aberto e parado, faz o script inserir por SQL o segundo alerta com `triggeredAt = NOW() + 20 s` (um alerta semeado ou criado "agora" já estaria vencido quando a tela abrisse; os +20 s deixam a janela "no prazo" maior que um pulso de 30 s), e `E2E_CORTAR_BACKEND` faz `docker pause sinalacs-traefik` por 120 s — a única rota do aparelho até o backend (`adb reverse tcp:8443` → :443 do host → Traefik → `https://serverpod:8080`, TLS 1.3 desde a #78; o 8080 não é publicado), enquanto o backend e a conexão MQTT dele (direto a `mosquitto:8883`) ficam no ar. Cortar o Wi-Fi do emulador não derruba o `adb reverse`, e pausar o próprio backend o derrubou na 1ª execução real (sem keepalive MQTT o broker fechou a conexão; no `unpause`, `SocketException: Broken pipe` e saída 255 — defeito latente do despachante, a acompanhar em issue própria). Depois do `unpause` o script confere que o backend segue no ar com o mesmo `StartedAt` e, se não, mostra o fim do log; o `cleanup` sempre despausa o Traefik (e o backend, por defesa). O pulso devolve `overdueRed` e `maxChangedAt` mais os indicadores embutidos, e o app compara os três para decidir recarregar a lista; o Painel desenha `lastPulse.indicators`. Sem tocar em filtro, puxar a lista nem trocar de aba nos trechos de prova, o teste mostra: o Painel passando de 1 a 2 abertos sozinho (`E2E_PAINEL_ATUALIZOU`, ≤ 40 s), botão ausente no prazo e presente ≤ 40 s depois dele (35 nominais + folga de relógio), escalonamento pela tela com motivo ("Pendente" + "Escalonado às"), painel ainda com 2 vermelhos abertos, reescalonamento por RPC com `alreadyEscalated` e o mesmo `escalatedAt` (o token vem da `AdminSession` capturada no `dataSourceFor`, sem segundo login) e o selo `stale_badge` que aparece na queda com os dados mantidos e some sozinho na volta. O script confere no banco uma linha `escalated:no_ack_timeout` e uma `already_escalated:no_ack_timeout`, o alerta `pending` com `escalatedBy` do coordenador, um único reforço no `alert_outbox`, `monitoring_session_start`, zero `read` do `admin_pulse`, os tetos de leitura do coordenador por recurso (indicadores 4, alertas 9, microáreas 3, auditoria 0; total 16) e a cadeia de auditoria. `--com-regressao-offline` roda `acs_full_e2e.sh` (a jornada offline do ACS) ao fim. Passo a passo manual no cabeçalho do script. **Auditoria completa (#45).** `AuditLogScreen` (`lib/app/audit_log_screen.dart`, extraída de `app.dart`, que a reexporta) registra o acesso antes de qualquer dado e mostra só o que o servidor diz: o app nunca calcula integridade. Filtros recolhíveis (busca, matrícula, papel, ação, tipo de recurso, resultado e período `from`/`to` por seletor de data), validação local com as regras do servidor (busca 2–64, matrícula ≤ 32, `^[a-z0-9_:]{1,64}$`), "Aplicar"/"Limpar", Enter aplica; "Carregar mais" por `nextBeforeSequence` sem repetir `sequence`; filtro novo zera o cursor e descarta resposta velha. Cada linha leva o selo do servidor com ícone + rótulo + `Semantics` (Íntegro / Adulterado? / Não verificável); o selo olha só para trás, então só o painel global acusa a quebra a jusante. O painel "Integridade da trilha" (`AuditChainReason` com os 8 motivos, `brokenInScope` `bool?`) só mostra "Cadeia íntegra" para `verified`, em verde; `broken` ("Falha de integridade na cadeia", `redOnSurface`, "Primeiro registro com falha: nº N", com a ressalva de que pode ser troca de chave, sem dizer "ataque"), `unavailable` ("Verificação indisponível") e `inconclusive` ("Verificação inconclusiva") trazem "isso não significa que a cadeia está íntegra"; erro de consulta não vira estado. O verde é exceção a `spec/ui_design.md` (D3, **pendente de aceite do dono**) e vem sempre com ícone + texto. A verificação roda ao abrir a tela e no botão "Verificar" (cache de 60 s no servidor). **Exportação** (seção montada só para `role == 'admin'`; o coordenador nem a vê): exige período aplicado, confirma ("será registrado na trilha…"), mostra contagem, intervalo de `sequence`, SHA-256, estado da cadeia (só `verified` diz "íntegra"; os outros dizem "Atenção: …") e entrega o CSV por **área de transferência** (`Clipboard.setData`, "Copiar CSV"; sem arquivo, sem pacote novo) com aviso de que a área de transferência é compartilhada com outros apps; o CSV vive só no estado da tela (descartado em "Fechar", ao mudar o filtro e no `dispose`, nunca desenhado ou logado) e **segue o filtro aplicado**, não o rascunho. Falhas com texto fixo do app (teto, permissão, genérica + "Tentar novamente"); `AuditExportFailedException` chega como erro genérico. O app inteiro é pt-BR por `flutter_localizations` (seletores de data/hora); o seletor de hora em modo teclado estoura com fonte ≥ 150% (defeito do Material). Abrir a Auditoria grava duas linhas (`admin_audit_logs` + `admin_audit_chain`). Menores abertos: corrida mínima em `_copiarCsv` se o filtro muda durante `Clipboard.setData`; Enter nos campos aplica filtro durante a exportação (seguro). Testes: `audit_log_screen_test.dart`, `audit_export_test.dart`, pares de contraste em `contrast_tokens_test.dart`. **E2E:** `scripts/qa/admin_auditoria_e2e.sh` (`DEVICE=`; sobe a stack de e2e com `E2E_SEED_AUDIT_TRAIL=1`, roda `integration_test/admin_auditoria_e2e.dart` no `emulator-5554`, adultera/restaura uma linha só em `sinalacs_e2e` por `e2e_stack.sh psql`, confere o banco e o CLI `audit_chain_check`); a estrutura é guardada por `bash scripts/qa/admin_auditoria_e2e_test.sh` (sem Docker nem aparelho, fora da CI como os outros `admin_*_e2e_test.sh`). O teste espera ~60 s (cache) antes de reverificar a adulteração. Evidência: `docs/superpowers/plans/2026-10-13-issue-45-e2e.log`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 99a7fdc..470c682 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -34,9 +34,9 @@ serverpod generate 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 still listens on cleartext 8080 **inside** the container — 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. +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 `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` (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). 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`. @@ -86,7 +86,7 @@ The clinical columns are encrypted at the ORM boundary only (since #75 also the ### Backend (`backend/`) — Serverpod workspace -`backend/` is a Dart workspace with `sinalacs_server` (the server) and `sinalacs_client` (the generated typed client). Serverpod was the original stack decision recorded in `spec/stack.md`/`spec/PRD_system.md`; it was not implemented at first — the server was a hand-rolled `dart:io` HttpServer — and was adopted later, replacing it. The Dockerfile (`backend/sinalacs_server/Dockerfile`) is a multi-stage build: `dart compile exe` (AOT) on `dart:3.12.2` (bumped from `3.8.0` because the `postgres` package, a transitive/direct dependency, requires `sdk: '^3.9.0'`), copied into `alpine` with a non-root user, `curl` for the healthcheck, and a `HEALTHCHECK` that does `POST /health/check`. +`backend/` is a Dart workspace with `sinalacs_server` (the server) and `sinalacs_client` (the generated typed client). Serverpod was the original stack decision recorded in `spec/stack.md`/`spec/PRD_system.md`; it was not implemented at first — the server was a hand-rolled `dart:io` HttpServer — and was adopted later, replacing it. The Dockerfile (`backend/sinalacs_server/Dockerfile`) is a multi-stage build: `dart compile exe` (AOT) on `dart:3.12.2` (bumped from `3.8.0` because the `postgres` package, a transitive/direct dependency, requires `sdk: '^3.9.0'`), copied into `alpine` with a non-root user, `curl` for the healthcheck, and a `HEALTHCHECK` that does `POST /health/check` over http (the Render path; the local compose overrides it with an https one, because there the 8080 speaks TLS). Layering under `backend/sinalacs_server/lib/src/`: - `models/` — the schema as `.spy.yaml` model files: 21 tables, 9 enums (`RiskLevel`, `AlertStatus`, `SyncStatus`, `UserRole`, `ConsentPurpose`, `ArrivalMethod`, `DataSubjectRequestType`, `DataSubjectRequestStatus`, `VisitAuthorship`), typed exceptions, and endpoint result types. `serverpod generate` turns these into Dart classes shared by server and client; `serverpod create-migration` turns them into SQL under `migrations/`. diff --git a/backend/DEPLOY.md b/backend/DEPLOY.md index 1539955..045d3ed 100644 --- a/backend/DEPLOY.md +++ b/backend/DEPLOY.md @@ -97,6 +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. +> > O certificado de desenvolvimento deste repositório > (`infra/docker/traefik/runtime/certs/` — a folha é emitida pela CA de dev > `SinalACS Dev RPC CA` e regerada a cada subida; **auto-assinada é a CA**, que é diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index 9290997..4c26c82 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 | **ausente** | A mesma chave do SQLCipher é usada por toda a vida da instalação; nenhuma rotação. | Rotação da chave do cache (mudança de dispositivo) ou decisão registrada de não rotacionar. | | 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 | **ausente** | Traefik fala com o backend em HTTP puro na 8080 (`docker-compose.yml:194`, `loadbalancer.server.port=8080`), sem `serversTransport` nem mTLS. | mTLS entre Traefik e backend, ou decisão registrada de rede interna confiável. | +| CRIPTO-TRAEFIK-BACKEND | mTLS Traefik ↔ backend | **parcial** (2026-10-16, #78) | 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-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. | A taxa > 99,5 % em campo não é medida: nenhum evento `sync_attempt`/`sync_result` é emitido (ver EVT-*). | | 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** | RPC em TLS 1.3 obrigatório (`tls_invariants.sh` OK); broker MQTT só em TLS na 8883 com mTLS (`mtls_invariants.sh` OK). | O broker aceita TLS 1.2: `openssl s_client -tls1_2 -connect localhost:8883` com certificado de cliente negociou TLSv1.2 (2026-10-15). Falta `tls_version tlsv1.3` no `mosquitto.conf` e o caso no `tls_invariants.sh`; Traefik-backend em texto claro (CRIPTO-TRAEFIK-BACKEND). | +| RNF04 | TLS 1.3 em todas as comunicações | **atendido na stack local** (2026-10-16, #78) | 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). | 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). | | 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) @@ -172,7 +172,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | M1.5 | SQLCipher local | **entregue** | SQLCipher no ACS (`apps/acs/lib/core/database/encrypted_database.dart`, `integration_test/encrypted_storage_test.dart`). O paciente usa `sqflite` sem cifra, de propósito, para lembretes e preferências de consentimento. | Lembretes de saúde do paciente em texto livre sem cifra (ver STRIDE-I). | | M2.1 | App Paciente MVP | **parcial** | Login, alerta, triagem e status no app do paciente (RF01, RF03, RF04, RF05). | SMS real (RF01). | | M2.2 | App ACS MVP | **entregue** | Login, territorialização, dashboard e registro no ACS (RF07, RF08, RF09, RF11). | | -| M2.3 | MQTT com TLS (WSS) | **parcial** | MQTT sobre TLS com mTLS na 8883 (`mtls_invariants.sh` OK). | O critério fala em WSS, e WebSockets (9001) não é publicado por decisão; o broker aceita TLS 1.2 (ver RNF04). | +| M2.3 | MQTT com TLS (WSS) | **parcial** | MQTT sobre TLS 1.3 com mTLS na 8883 (`mtls_invariants.sh` e Grupo F de `tls_invariants.sh` OK, 2026-10-16). | O critério fala em WSS, e WebSockets (9001) não é publicado por decisão. | | M2.4 | 100 registros sincronizam em < 5 s | **parcial** | 100 visitas por lote em p95 112 ms, 10/10 (meta < 5 s), `measure_latency.dart` contra a stack local (2026-10-16 UTC); `offline_burst_test.dart`. | 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. | | M2.5 | Testes de caos (Toxiproxy) | **parcial** | Simulador em processo `apps/acs/lib/core/services/network_chaos_simulator.dart` + `network_chaos_test.dart`; resiliência real do broker em `scripts/qa/mqtt_resiliencia_e2e.sh`. | Toxiproxy (0 ocorrências): latência, jitter e partição na rede de verdade entre app, Traefik e broker. | | M2.6 | Testes de usabilidade em campo | **externo** | Avaliação de acessibilidade e e2e no emulador (`spec/ux_accessibility_assessment.md`). | Beta fechado com 5 ACS e 10 pacientes em campo (dono: produto + UBS). | @@ -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 | 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 | 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. | | 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; hoje a visita em conflito fica parada na fila para sempre. | | 6 | P1 | Reserva por HTTP para o ACS receber alertas quando o MQTT cai | R2, INV-03 | código | Risco de impacto 5 em rede 3G; o ACS não tem como listar alertas pendentes por HTTP. | | 7 | P1 | Remote wipe e certificado de cliente por aparelho | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; depende de login de supervisor e de PKI de produção. | diff --git a/scripts/qa/admin_alertas_escalonar_e2e.sh b/scripts/qa/admin_alertas_escalonar_e2e.sh index c04b78a..3aa3d75 100755 --- a/scripts/qa/admin_alertas_escalonar_e2e.sh +++ b/scripts/qa/admin_alertas_escalonar_e2e.sh @@ -38,7 +38,7 @@ # backend): o app chega ao backend por UM caminho só — `SINALACS_HOST= # https://localhost:8443/` no aparelho -> `adb reverse tcp:8443 tcp:443` -> :443 # do host -> sinalacs-traefik (a única porta publicada do RPC; o 8080 do -# backend não é publicado) -> serverpod:8080 na rede do Compose. `svc wifi +# backend não é publicado) -> https://serverpod:8080 (TLS 1.3, #78) na rede do Compose. `svc wifi # disable` não derruba o canal do adb. Com o Traefik congelado a conexão TCP # ainda é aceita pelo docker-proxy, mas ninguém responde ao TLS: a chamada # fica pendurada até o prazo de 20 s do pulso, e o selo aparece. O backend não From d259bac52b413d0f49e36f44ec6f9a82d23b5cd2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 16 Oct 2026 16:54:08 -0400 Subject: [PATCH 6/6] =?UTF-8?q?fix(qa):=20e2e=5Fstack=20gera=20a=20folha?= =?UTF-8?q?=20do=20backend=20antes=20do=20--no-deps;=20docs=20n=C3=A3o=20d?= =?UTF-8?q?eclaram=20o=20RNF04=20fechado=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 2 +- backend/sinalacs_server/config/development.yaml | 6 ++++-- docs/auditoria-prd/2026-10-15-lacunas-prd.md | 2 +- scripts/qa/e2e_stack.sh | 8 ++++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index ceb5846..3e1e86c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -572,7 +572,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. - **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). +- **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). - **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/sinalacs_server/config/development.yaml b/backend/sinalacs_server/config/development.yaml index e7234ba..0ce311f 100644 --- a/backend/sinalacs_server/config/development.yaml +++ b/backend/sinalacs_server/config/development.yaml @@ -8,8 +8,10 @@ # Configuration for the main API server. apiServer: - # O processo continua escutando 8080 em texto claro DENTRO do container — - # quem termina TLS é o Traefik, que é o único que publica porta. Estes campos + # O processo escuta 8080 DENTRO do container: em TLS 1.3 na stack local desde + # a #78 (API_TLS_CERT_PATH/API_TLS_KEY_PATH, ApiTlsConfig) e em texto claro + # sem elas (Render, TLS na borda). Quem termina o TLS do cliente é o Traefik, + # que é o único que publica porta. Estes campos # apontam para a URL cifrada, que é o endereço público real do RPC, e não para # a porta interna. # diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index 4c26c82..6ec3ede 100644 --- a/docs/auditoria-prd/2026-10-15-lacunas-prd.md +++ b/docs/auditoria-prd/2026-10-15-lacunas-prd.md @@ -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. | A taxa > 99,5 % em campo não é medida: nenhum evento `sync_attempt`/`sync_result` é emitido (ver EVT-*). | | 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 | **atendido na stack local** (2026-10-16, #78) | 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). | 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** (2026-10-16, #78: os três trechos da issue fechados) | 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). | | 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) diff --git a/scripts/qa/e2e_stack.sh b/scripts/qa/e2e_stack.sh index e5f7bf0..62f374c 100755 --- a/scripts/qa/e2e_stack.sh +++ b/scripts/qa/e2e_stack.sh @@ -41,6 +41,14 @@ case "${1:-}" in for f in infra/docker/traefik/runtime/certs/ca.crt; do [[ -f "$f" ]] || falhou "falta $f: suba a stack normal uma vez (docker compose up -d) para gerar os certificados" done + # A folha TLS da 8080 interna do backend (#78) vem do traefik-init, que o + # `--no-deps` abaixo não roda: num runtime/ anterior à #78 o Docker criaria + # 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 + [[ -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 \ || falhou 'a subida do backend falhou' for _ in $(seq 1 90); do