TAIVA Vault: Whitepaper Técnico
Versão: 1.10.x, 2026-05-29 (Fase 2 multi-tenant + CRM + multi-signer público + tier free + step-up OPAQUE inline) Status: ativo (atualizado a cada release maior)
> Mudanças desde 2026-05-27 (2026-05-28 → 2026-05-29): > > - §5.6 NOVO: Tier free (ADR-0024) vira default de cadastro. 3 tiers free | solo | profissional. Free = 15 credenciais / 5 notas / 3 TOTP, 8 features Solo bloqueadas, cripto idêntica a Solo (mesmo domínio HKDF, zero downgrade). Promoção free→solo = flip de tier sem ceremony cripto. Gating server-side via requireSoloFeature + UI via canAccessSoloFeature (allowlist). Kill switch FREE_GATING_ENABLED=0. > - §3.2.17 NOVO: Re-autenticação step-up inline. Operações sensíveis (cadastrar passkey, criar identidade de assinatura) exigem auth fresca (requireFreshAuth, janela 5min). Em vez de forçar logout/login, um modal re-executa a cerimônia OPAQUE-3DH com a senha mestra sem emitir OTP nem cookie novo (/api/auth/opaque/step-up/{init,finalize}), apenas renovando freshAuthAt na sessão atual. Defesa T-038 (brute-force da senha mestra via sessão roubada): lockout escalável + rate-limit per-userId + path fixo em primary. O servidor nunca recebe a senha; só o resultado do PAKE. > > Mudanças desde 1.9.0 (2026-05-25 → 2026-05-27): > > - §5.4 + §7.7 NOVO: Fase 2 multi-tenant Workspace (ADR-0012). Modelo raiz Workspace ownerId-único + 10 modelos legacy ganham workspaceId String? SetNull (back-compat Solo = NULL workspace "Pessoal"). Anti-IDOR T-021 via requireWorkspaceOwnership helper centralizado: 404 anti-enum quando ws pertence a outro user (NUNCA 403, não vaza existência). Audit chain canonical NÃO inclui workspaceId — Bitcoin OTS anchors preservados bit-a-bit. Soft-delete + papelaria 90d via cron workspace-cleanup. > - §7.8 NOVO: Multi-signer sequencial (ADR-0018) — SignatureRequest + Signer com accessToken hex 32B único per signer. Cliente terceiro recebe link via mailto: do owner, acessa página pública /sign/[accessToken] sem login. Lei 14.063 Simples (Art. 4º I, checkbox+nome) ou Avançada (Art. 4º II, CPF ZK + biometria face inline via snarkjs PLONK + face-api browser). ZK selfie via ECIES X25519 (owner gera keypair browser, priv cifrada com DEK, signer deriva wrapping key via ECDH+HKDF — server nunca vê chaves). > - §7.9 NOVO: CRM nativo ZK total (ADR-0022) — WorkspaceContact + WorkspaceTask. PII 100% cifrada client-side com DEK (nameCipher/emailCipher/phoneCipher/titleCipher/etc). Server NUNCA decifra. Mailto reminder client-side preserva branding do owner (email sai do client email dele). Cron tasks-reminder daily 08:00 BRT envia email GENÉRICO pro owner (count + window, ZERO PII). Idempotency dupla: Redis SETNX 24h + WorkspaceTask.reminderSentDaysJson. > - §5.5 NOVO: Tier flag SP0 (ADR-0021) — User.tier (solo|profissional) no MESMO host Epsilon. Helper lib/user-tier.ts ÚNICO ponto de verdade. HKDF domain separation tier-aware (buildHkdfInfo(ctx, scope, version)). Princípio 9 (helper centralizado) obrigatório: nunca if (user.tier === 'profissional') direto em rota. > - §7.10 NOVO: Compartilhamento ZK de credenciais (ADR-0019) — SharedCredential + UserShareKeyPair. ML-KEM-1024 encapsulation para recipient. Pubkey lookup constant-time para timing safety. Opt-in searchable per-user. > - §7.11 NOVO: Cofre de arquivos cifrados (ADR-0017) — EncryptedFile workspace-scoped. Cap 100MB ciphertext. R2/S3 backend pluggable (fallback local FS). Cron workspace-cleanup purga deletedAt > 30d. IIFE no module-load assertR2BackendReady() fail-fast no boot se R2_ENDPOINT setado sem @aws-sdk/client-s3 instalado. > - §7.12 NOVO: Perfil contratante (Issue 1+4) — 9 cols novas em User (displayName/companyName/companyDocument/addressJson/phoneNumber/websiteUrl/bio/brandColor/logoBlob/logoMimeType). Aparece em emails de invitation, página pública /sign, footer PDFs assinados. Logo whitelist PNG/JPEG (SVG removido em audit — XSS via event handlers bypassa CSP nonce). > - §3.2.15 NOVO: F32 soft-delete LGPD. User.deletedAt anonymize in-place (email → SHA-256 random, PII zerada, opaqueEnvelope/wrappedDek/mlKemCt nulled) preservando AuditLog + AuditChainAnchor + Bitcoin OTS anchors. Antes prisma.user.delete() cascade destruía a prova pro auditor externo (ANPD/justiça). 25 lookups de User pelo codebase filtrados deletedAt:null. Helper lib/user-deleted-cache.ts (Redis flag fail-OPEN) + gate em sessionLoad fecha race AUTO_LOCK_MS=7d. > - §3.2.16 NOVO: Asaas billing real (ADR-0014) — webhook /api/webhooks/asaas HMAC verify constant-time + Zod strict + idempotência DUPLA (Redis SET NX EX 7d + AsaasWebhookEvent.eventId @unique) + externalReference composite userId:tier:plan cross-validation anti-promoção sem ceremony. Cron billing-reconcile 6h + trial-expiration daily 09:00 BRT. > - §2.3 NOVO: 9 princípios defense-in-depth obrigatórios em código novo (Zod strict, server-side enforcement final, constant-time + anti-enum, domain separation cripto, idempotency, rate-limit em camadas, audit log + scrub, feature flag own, helper centralizado). > > Mudanças desde 1.8.0 (cycle anterior): > > - §3.2.10 NOVO: Cosign attestation cycle automatizado. Os 3 artefatos públicos (taiva-bundle-manifest.json, sbom.json, cbom.json) agora têm .bundle assinado (sig + cert + Rekor proof) gerado no postbuild quando a chave local está disponível, com cron horário em rogerio-pc detectando drift e re-assinando. Verificável por qualquer terceiro via scripts/verify-public-attestations.sh. > - §3.2.11 NOVO: rotação ordenada da chave JWKS do issuer. Suporte a SDJWT_ISSUER_PREVIOUS_JWK_B64 (chave aposentada pubkey-only) cobrindo a janela em que JWS antigos ainda precisam validar. Verificadores externos resolvem JWS pelo kid no header. > - §3.2.12 NOVO: 5 rotas caras ganharam rate-limit consistente (signing/verify 50/min, signing/sign 10/min, derive-export-key 20/min, lgpd/data-export 3/h, blog/subscribe 5/h + honeypot). Pattern unificado via checkIpLimit (Redis INCR atomic, fallback in-process). > - §3.2.13 NOVO: PII redact em logs Pino. Helper maskEmail consolidado, paths *.email, *.adminEmail, *.targetEmail adicionados ao redact (defense in depth contra log leak via Sentry hack ou backup roubado). > - §3.2.14 NOVO: counter race em signing/sign fechado via compare-and-swap atômico dentro do prisma.$transaction (defesa em profundidade sobre o GETDEL Redis do challenge). > - §7.5: módulo AEAd (Assinatura Eletrônica Avançada, Lei 14.063/2020 Art. 4º II), ECDSA P-256 client-side com escalar privado XOR-particionado entre MPC 2-de-3 (path aead) e wrappedShare1 cifrado por signingKek (HKDF de export_key + WebAuthn PRF). Carimbo PDF + QR verifier + bundle manifest cosign-ready. > - §7.6 NOVO: página pública /status (ISR 60s) com saúde agregada do mesh (MPC circuit-breaker state, OpenBao seal status, TLS cert dias até expirar, audit chain Merkle + 5 últimas entradas anonimizadas). Zero IPs, zero latências internas, zero contagem por nó. > - §7.4: provas de existência (proof-of-existence) v1.7.0 com ML-DSA-65 + Bitcoin OTS anchor. > - §3.2.5: recovery key gerada no browser via lib/recovery-key-client.ts (CSPRNG do navegador). Servidor nunca toca o plaintext. > - §3.2.9: Pino logger redact ativo em 29 paths cripto-sensíveis e 3 paths de PII. > - Veredicto §3.3: trace end-to-end do flow OPAQUE-3DH confirmou que servidor não decifra dados de usuário sozinho, verificado via análise de código com referências file:line.
1. Sumário executivo
TAIVA Vault é um gerenciador de senhas zero-knowledge no modelo web vault, com autenticação PAKE de verdade (OPAQUE-3DH, RFC 9497) e wrap híbrido pós-quântico (ML-KEM-1024, NIST FIPS 203 categoria 5). O servidor jamais aprende a senha mestre nem o export_key que abre cada envelope. Os payloads sensíveis do cofre (senhas, conteúdo de notas, segredos TOTP) trafegam e ficam em repouso apenas como ciphertext AES-256-GCM cuja chave (DEK) é reconstruída em memória do navegador a cada login.
Posicionamento honesto:
A TAIVA opera em dois modos de cliente com garantias de zero-knowledge distintas. Documentar a distinção é parte da honestidade técnica:
1. Extensão de navegador (Chrome / Firefox), recomendada para uso diário: caminho zero-knowledge real. A extensão é instalada uma única vez pela loja oficial (Chrome Web Store / Mozilla Add-ons) e não depende do servidor TAIVA para entregar código em cada uso. Mesmo se o servidor TAIVA for comprometido ou intimado, a extensão continua executando código auditável e assinado. Aqui o adversário "backend ativamente malicioso entregando JS" é neutralizado pelo modelo de distribuição.
2. Web vault em `vault.taiva.com.br`, recomendado apenas para onboarding, emergência ou verificação manual: zero-knowledge das senhas / conteúdo / segredos TOTP / notas é preservado contra todos os adversários estáticos (banco de dados vazado, operador curioso, intimação após o fato, hoster malicioso lendo disco at-rest). Porém: o servidor entrega o JavaScript que faz a criptografia local; um servidor ativamente comprometido em tempo real poderia entregar JS malicioso para capturar a senha mestre durante o login. Mitigações: CSP nonce + strict-dynamic + bundle manifest com SHA-256 dos chunks + assinatura de releases (Cosign + Rekor + Bitcoin OTS anchor). Mitigações elevam o custo do ataque para "coordenar comprometimento simultâneo de pipeline de build + signing keys + transparency log", não eliminam o risco. Para zero-knowledge contra adversário ativo em tempo real, use a extensão.
Demais trade-offs operacionais:
- Metadados do cofre (URL/site, username, issuer TOTP, category, favorite, travel-safe) ficam em plaintext no servidor para permitir busca, ordenação e travel-mode UI. Isto é um trade-off explícito entre privacidade total e usabilidade, documentado em §3.2 e na Política de Privacidade. Roadmap: cipher-first metadata em sprint dedicada (audit 2026-05-21 finding #3, condicional a posicionamento).
- A recovery key (TVAU-…) é gerada exclusivamente no navegador via CSPRNG (
lib/recovery-key-client.ts, Sub-stage 2H). O servidor nunca toca o plaintext do recovery key. - Janela OTP: o servidor armazena em Redis (TTL 5 min) apenas
sessionKey(saída do PAKE) + metadados do desafio (userId,email,path). OsessionKeysozinho não decifra a DEK — ele prova posse da senha, mas a chave que abre o envelope (export_key) só existe no navegador. Material cripto-sensível (wrappedDek,share2,pqSharedSecret) não trafega por Redis: é derivado em memória do processo apenas em/login/verify-otp, após o OTP correto, e devolvido ao cliente na mesma resposta. Fix aplicado em 2026-05-21 (audit caveat 3).
A chave DEK é dividida em dois fragmentos: share1, embrulhado em uma hybridKEK formada pelo export_key do OPAQUE misturado ao segredo compartilhado do ML-KEM-1024 via HKDF; e share2, fragmentado por Shamir Secret Sharing entre três nós MPC distribuídos em pelo menos duas jurisdições independentes (Brasil e Europa), com threshold 2-de-3. Tolera falha de 1 nó. Compromisso simultâneo de 2 nós não basta para abrir o cofre: o atacante ainda precisaria do export_key (que só nasce a partir da senha + envelope OPAQUE do usuário, no navegador).
A camada de rede usa TLS 1.3 com troca de chaves pós-quântica híbrida X25519MLKEM768 no edge (versões recentes de Caddy / Traefik com Go 1.24+). A trilha de auditoria é uma cadeia Merkle SHA-256 ancorada diariamente no blockchain do Bitcoin via OpenTimestamps. As releases são assinadas com Cosign e registradas no log público de transparência Sigstore Rekor.
Master secrets operacionais residem em OpenBao (HSM-light) com unseal Shamir, tokens AppRole de 24 h e rotação a cada 6 h. Backups do DB rodam diariamente: VACUUM INTO produz um snapshot, é re-cifrado com AES-256-CBC (PBKDF2 600k iters, chave derivada de SQLITE_ENCRYPTION_KEY) e replicado via rsync SSH cross-host pela mesh Tailscale privada (host offsite Sigma).
Este documento descreve o threat model, as primitivas criptográficas, a hierarquia de chaves, os fluxos críticos, os trade-offs reconhecidos e como auditar o sistema de forma independente.
2. Threat model
2.1. Adversários considerados
| Adversário | Capacidade | Defesa principal |
|---|---|---|
| Atacante externo | DDoS, scanning, brute force online | CrowdSec L7 + fail2ban + rate limit Redis Lua EVAL + lockout per-user |
| Insider operador | Acesso total a 1 host de aplicação | OPAQUE (servidor nunca aprende senha); master secrets em OpenBao em host segregado |
| Servidor comprometido durante login | MITM + leitura de memória do processo | OPAQUE-3DH PAKE: mensagens são OPRF-blinded; export_key jamais sai do navegador |
| DB snapshot vazado | Posse dos envelopes OPAQUE | OPRF binding à chave do servidor torna ataque de dicionário offline ineficiente |
| Subpoena / coerção legal | Acesso forense a 1 servidor | ZK strict + MPC threshold 2-de-3 |
| Supply-chain compromise | npm malicioso, CDN comprometido | Bundles cripto locais (public/crypto-vendor/), npm audit gate no build, Cosign + Rekor |
| Computador quântico (hipotético) | Quebra DH, RSA, ECC | ML-KEM-1024 no wrap (cat-5 NIST FIPS 203) + X25519MLKEM768 no TLS |
| Compromise lateral entre nós | Pivot via rede interna | ACL de mesh restritiva + mTLS inter-host + chaves de cifra distintas por nó |
2.2. Adversários fora do escopo
- Compromisso simultâneo de 2 dos 3 mpc-nodes mais posse do
export_key(que exige senha do usuário). Defesa: senha forte + OPAQUE OPRF binding. - Compromisso do dispositivo cliente (extensão / navegador). Defesa: DEK importada como
CryptoKeynon-extractable + CSP rigorosa + auto-lock configurável. - Ataques contra hardware (cold boot, Rowhammer). Fora de escopo.
3. Primitivas criptográficas
| Primitiva | Uso | Lib |
|---|---|---|
| OPAQUE-3DH (suite OPAQUE_P256) | PAKE de autenticação; gera export_key no cliente | @cloudflare/opaque-ts |
| ML-KEM-1024 (NIST FIPS 203 cat 5) | KEM pós-quântico; produz ss para misturar no wrap | @noble/post-quantum |
| HKDF-SHA-256 | Deriva hybridKEK = HKDF(export_key ‖ ss, info) e demais sub-chaves per-purpose | Node crypto |
| AES-256-GCM | Wrap de share1, cifra de DEK em sessão e cifra de itens do cofre | Web Crypto API + Node crypto |
| Shamir Secret Sharing GF(2^8) | Fan-out de share2 entre 3 mpc-nodes; threshold 2-de-3 | shamirs-secret-sharing v2.0.1 |
| TLS 1.3 + X25519MLKEM768 | Camada de rede com troca de chaves híbrida pós-quântica | Go std lib (Caddy / Traefik) |
| Ed25519 / ECDSA P-256 | mTLS inter-host + WebAuthn passkeys | Node TLS + @simplewebauthn/server |
| SHA-256 Merkle chain | Audit log tamper-evident per-usuário | lib/audit-chain.ts |
| OpenTimestamps + Bitcoin | Anchor diário do Merkle root no blockchain | lib/audit-chain-anchor.ts |
| CSPRNG sem viés | Geração de senhas, tokens, nonces, DEK, share1 | crypto.getRandomValues + rejection sampling |
Parâmetros operacionais:
- ML-KEM-1024: ciphertext 1568 bytes, shared secret 32 bytes.
- AES-256-GCM: IV 12 bytes random + auth tag 16 bytes + ciphertext.
- OPAQUE OPRF: seed estática 32 bytes (OpenBao path
opaque/oprf-seed). - OPAQUE AKE: P-256 keypair (em transição para OpenBao, hoje em
process.env; ver §3.2).
> Argon2id não está mais no caminho cripto da v1.6. Foi removido na Sub-stage 2C (2026-05-19) junto com o caminho de auth legado.
3.2 Honestidade operacional: trade-offs e limitações conhecidas
Esta seção lista trade-offs e limitações reais do design atual. Documenta o que não é zero-knowledge no sentido estrito, para evitar leitura otimista do produto.
3.2.1 Metadados do cofre em plaintext. As tabelas Credential e TotpAccount armazenam alguns campos sem cifragem:
Credential.site(URL do serviço),Credential.username,Credential.category,Credential.favorite,Credential.travelSafeTotpAccount.issuer,TotpAccount.account,TotpAccount.digits,TotpAccount.period,TotpAccount.travelSafe
O payload sensível continua cifrado (passwordCipher, secretCipher, titleCipher, contentCipher são AES-256-GCM blobs). Mas o servidor conhece: quais serviços (URLs) o usuário usa, qual identificador em cada um, qual issuer TOTP (ex: GitHub, Google, AWS). É um trade-off para permitir busca, filtragem, travel-mode UI e ordenação no cliente sem decapsulação prévia. Roadmap futuro pode oferecer modo "metadata-encrypted opt-in" com HMAC determinístico para search index.
3.2.2 OPAQUE secrets em `process.env` (não OpenBao na implementação atual). A OPAQUE_OPRF_SEED e o keypair AKE (OPAQUE_AKE_PUBLIC/OPAQUE_AKE_PRIVATE) ainda são carregados de variáveis de ambiente. Quando o bug de runtime read do OpenBao for resolvido, esses três passam para OpenBao com paths idênticos a sqlite-encryption-key. Risco atual: comprometimento do filesystem do host de aplicação → exfiltração de seeds OPAQUE → ataque de dicionário contra envelopes (proteção restante: OPRF binding ainda é resistente a brute force online via rate-limit + lockout per-user).
3.2.3 Janela OTP fechada (v1.8.1, 2026-05-21). O fluxo de login OPAQUE acontece em duas requisições separadas pelo OTP por email. Em versões anteriores a 1.8.1, entre a primeira (/login/finalize) e a segunda (/login/verify-otp), o servidor armazenava em Redis com TTL 5 min o wrappedDek cifrado, o share2 reconstruído via MPC, o sessionKey e o pqSharedSecret (decapsulação ML-KEM). Sem o export_key do cliente esse material não permitia reconstruir o DEK, mas o servidor tocava material auxiliar durante a janela.
A partir de v1.8.1, /login/finalize armazena em Redis apenas {userId, email, path, sessionKey}. O sessionKey é a saída pública do PAKE e, sozinho, não decifra envelope algum: o que abre o wrappedDek é o export_key, que existe exclusivamente no navegador. As operações cripto-sensíveis (mpcRetrieveShareV3Opaque + decapsulação ML-KEM) foram movidas para /login/verify-otp, executadas em memória do processo apenas após validação do OTP, e devolvidas ao cliente na mesma resposta. Resultado: material decifrável nunca permanece em Redis. Cenário "DB + Redis leak simultâneo durante a janela OTP" deixa de habilitar brute-force eficiente da senha mestre.
3.2.4 Modelo web vault: servidor entrega o JS. Toda a cripto client-side roda em JavaScript baixado do servidor. Um servidor comprometido poderia entregar JS modificado para vazar a senha mestre no PAKE ou o DEK pós-unlock. Mitigações: CSP nonce + strict-dynamic + Cosign-signed releases verificáveis (commit SHA do tarball assinado vs runtime expectativa). Não há mitigação completa em web vault: clientes nativos (extensão Chrome/Firefox, desktop, mobile) reduzem essa janela porque o código é instalado uma vez e atualizado via store com signing chain externa. Atualmente a extensão (taiva-vault-extension/) é o caminho mais resistente; ela está em migração para OPAQUE (ainda usa stack legado v1.3.8).
3.2.5 Recovery key (TVAU-...) gerada no navegador (deployed). Antes da v1.6.x, a recovery key era gerada com crypto.randomBytes em Node (server-side), tocando o material em memória do servidor durante emissão. Esta janela foi fechada: lib/recovery-key-client.ts:70 usa crypto.getRandomValues (Web Crypto API) no navegador. O servidor recebe apenas o envelope OPAQUE derivado, nunca a recovery key em claro. Verificação: 0 imports de lib/recovery-key.ts (versão legacy server-side) em código de produção — função sobrevive como dead code candidato a remoção.
3.2.6 mTLS inter-node — fail-closed em produção (v1.6.1). O cliente MPC (lib/mpc-client.ts) sempre usou mTLS opt-OUT por default. Em v1.6.1 acrescentamos uma proteção adicional: em NODE_ENV=production, se MPC_USE_MTLS=false for explícito e ALPHA_MPC_URL for HTTP plain, o módulo lança erro fatal no boot impedindo regressão silenciosa (share2 trafegando em plaintext).
3.2.7 `Cache-Control: no-store` em todas as respostas vault (v1.6.1). O helper vaultJson() agora seta Cache-Control: no-store, no-cache, must-revalidate, private + Pragma: no-cache + Expires: 0 em toda resposta de API. Defesa em camadas contra cache acidental (CDN, proxy, browser) de material sensível (share2, sessionKey, ciphertext do cofre).
3.2.9 Logger redact expandido para 26 paths cripto (v1.6.3). lib/logger.ts foi atualizado para censurar automaticamente qualquer campo de objeto logado pelo Pino que tenha nome em uma allowlist expandida: share1, share2, wrappedDek, opaqueWrappedDek, sessionKey, exportKey, hybridKEK, pqSharedSecret, mlKemCt, opaqueEnvelope, ke1, ke2, ke3, recoveryKey, além dos genéricos dek, token, otp, secret, password, encryptionKey. Verificação prévia confirmou que nenhuma chamada sessionLog.X({material}) existe hoje em código (verificado via grep das rotas OPAQUE). A expansão é defesa em profundidade contra leak acidental futuro.
3.2.10 Cosign attestation cycle automatizado (v1.9.0). Os três artefatos públicos de attestation que o whitepaper promete (/.well-known/taiva-bundle-manifest.json, sbom.json, cbom.json) agora são assinados de forma ininterrupta. A invariância "private key NEVER on Epsilon" é preservada: o postbuild step scripts/sign-public-attestations.mjs apenas tenta assinar se a chave ~/.cosign-taiva-vault/cosign.key estiver disponível, caso contrário sai com exit 0 e imprime instrução. O ciclo é fechado por um cron horário em rogerio-pc (scripts/cron-sign-attestations.sh, entrada 7 * * * *) que baixa os JSON publicados, detecta drift via cosign verify-blob, re-assina local e devolve o .bundle a Epsilon via scp + restart do serviço. Qualquer terceiro com cosign + curl pode reproduzir a validação rodando ./scripts/verify-public-attestations.sh https://vault.taiva.com.br. Formato cosign v3 (.bundle único contém signature + cert + Rekor proof inline). Antes deste ciclo, os JSON eram gerados sem .sig e o verifier público retornava "não assinado", o que reduzia a promessa a uma intenção. Agora a promessa é executável.
3.2.11 Rotação ordenada da chave do issuer JWKS (v1.9.0). A chave ECDSA P-256 que assina SD-JWT VC, recibos LGPD e bundles ganhou suporte a rotação. lib/issuer-pubkey.ts mantém um cache {current, previous, byKid: Map<string, CachedIssuer>}. A variável de ambiente nova SDJWT_ISSUER_PREVIOUS_JWK_B64 aceita a chave aposentada (apenas a parte pública, sem d) para o período em que JWS antigos ainda precisam validar. JWS novos são emitidos com kid no header (RFC 7517) extraído via getCurrentIssuerKid(). O verificador lib/lgpd-receipt-verify.ts resolve a chave por getIssuerByKid(header.kid) com fallback para a current quando o JWS é legado e não traz kid. Sem kid, todo JWS existente continua válido; com kid, terceiros que cacheiam o JWKS conseguem continuar verificando mesmo após uma rotação ordenada do operador. O endpoint /.well-known/jwks.json publica current e previous quando ambas estão configuradas (kid distinto por chave, garantindo lookup determinístico).
3.2.12 Rate-limit consistente em rotas caras (v1.9.0). Cinco rotas que envolvem operação criptográfica pesada ou amplificação externa ganharam freio uniforme via helper checkIpLimit (Redis INCR atomic + fallback in-process). POST /api/signing/verify 50 por minuto por IP (público, ECDSA + Buffer 10MB). POST /api/signing/sign 10 por minuto por userId (ECDSA + DB transaction + SD-JWT + PDF stamping). POST /api/auth/opaque/derive-export-key 20 por minuto por userId (PAKE init 3DH + ML-KEM hybrid). GET /api/lgpd/data-export 3 por hora por userId (vault inteiro + audit chain). POST /api/blog/subscribe 5 por hora por IP, mais honeypot field website invisível na UI e validação zod (era ponto desprotegido amplificando envios Resend). Todas retornam 429 com header Retry-After. Pattern consistente facilita audit futuro.
3.2.13 PII redact em logs (v1.9.0). O Pino redact que cobre material criptográfico foi estendido com paths de PII: *.email, *.adminEmail, *.targetEmail. O helper novo maskEmail(email) em lib/email-utils.ts retorna yod***@*** (preserva 3 chars do local-part para debug humano sem expor o endereço completo). Doze call-sites de logs em rotas auth/signup, auth/signup/verify, auth/signup/abandon, admin/beta-invites, admin/beta-invites/[id] foram migrados para a convenção maskedEmail: maskEmail(email). Padrão ad-hoc que existia em vault/account e lib/lgpd-cleanup foi consolidado para usar o helper. Defesa em profundidade contra cenário de log leak (Sentry hack, backup roubado, syslog mal configurado).
3.2.14 Compare-and-swap atômico no counter WebAuthn (v1.9.0). O counter monotônico de assinaturas WebAuthn em signing/sign ganhou defesa em profundidade. Antes, o counter era lido fora da prisma.$transaction e atualizado dentro, abrindo janela teórica em que duas tx concorrentes (com challenges Redis distintos válidos) podiam ambas ler o mesmo valor stale. Agora a atualização usa tx.signingIdentity.updateMany({where: {id, webauthnSignCounter: oldValue}, ...}): se duas tx tentam avançar simultaneamente, a segunda detecta count !== 1 e dispara throw new Error('counter_race') que aborta tudo (audit log + signature event rollback). Cliente recebe 409 Conflict e refaz o fluxo com /sign/init + assertion fresca. Camada primária (GETDEL atômico do challenge no Redis) preservada.
3.2.8 Vulnerabilidades operacionais sob remediação ativa. Itens de postura operacional (não de arquitetura cripto) identificados em revisão interna contínua, com correção planejada. Listamos por transparência:
- Cron secret administrativo em plaintext no crontab de um host de aplicação. Acesso local privilegiado permitiria disparar endpoints de cleanup/notificação fora da janela esperada. Não vaza material do vault (chaves OPAQUE/MPC permanecem isoladas). Fix em rollout: wrapper
cron-call.shlendo secret via stdin (mesmo padrão já usado em outro host). - CI runner com privilégios excessivos: o runner do Git self-hosted roda como root com acesso ao Docker socket. Risco: workflow malicioso pode escapar do container e ler arquivos do host onde o runner reside (que NÃO contém master secrets do vault, mas contém credenciais de observabilidade e chave de backup auxiliar). Fix planejado: isolar runner em VM dedicada.
- Backup auxiliar com chave co-localizada: a chave de cifragem dos snapshots do DB do vault está no mesmo host de origem dos snapshots cifrados (replicação rsync SSH offsite). Comprometimento desse host permite decifrar histórico de backups (envelopes OPAQUE, wrappedDeks cifrados; note que para abrir, atacante ainda precisaria do
export_keyderivado da senha do usuário). Fix planejado: migrar custódia da chave para OpenBao remoto, mantendo a replicação rsync mas com a chave fora do host de origem. - Postgres mTLS com server cert padrão de distribuição (não da CA TAIVA):
pg_hba.confexigecert clientcert=verify-fulle clientes devem pinar a CA TAIVA, mas o server apresenta cert padrão Debian. Em conexões comsslmode=require(semverify-ca), o efeito é TLS-com-criptografia mas sem autenticação forte do server. Fix planejado: emitir cert do server com TAIVA CA e atualizar clientes parasslmode=verify-full. - Bypass de SSO via secrets inline: o portal SSO de observabilidade (Authelia, escopo interno apenas, não toca vault) carrega secrets HMAC/encryption/JWT inline no arquivo de config. Risco restrito a usuários do SSO interno. Fix planejado: mover para variáveis de ambiente segregadas.
- Hardening systemd ausente em alguns serviços: o serviço web do vault e auth-gateway não têm
NoNewPrivileges/ProtectSystemdeclarados; um dos MPC nodes tem regressão deNoNewPrivileges=no. Fix planejado: drop-in unificado de hardening em todos os serviços TAIVA.
Nenhum desses itens permite leitura de dados de usuário sem possuir adicionalmente a senha mestre do usuário. São aumentos de superfície/blast-radius em cenários de compromisso parcial, não quebras do modelo zero-knowledge. Detalhes completos (CVSS, file:line, fix steps, regression tests) ficam internos para reduzir janela de exploração entre disclosure e fix.
Status atual: 3 dos 4 itens CRÍTICOS acima foram fechados. Cron secret rotacionado e movido para arquivo 400. SSO interno migrado para variáveis de ambiente segregadas. Postgres mTLS confirmado servindo cert TAIVA CA (a config inline em postgresql.conf foi corrigida; um drop-in conf.d/01-taiva-hardening.conf já fazia o override correto antes, então o impacto real era menor que classificado inicialmente). Apenas o isolamento do CI runner (Forgejo) permanece pendente, em planejamento para janela calma.
3.3 Veredicto de auditoria OPAQUE end-to-end (2026-05-19)
Trace completo do fluxo OPAQUE foi reverificado com prova literal file:line em cada hop browser↔servidor:
| Garantia | Verificação |
|---|---|
| Servidor nunca recebe a senha mestre | Schemas Zod das rotas /api/auth/opaque/login/{init,finalize} e /api/auth/opaque/register/{init,finalize} não aceitam campo password. lib/opaque-server.ts:54-80 envolve apenas OpaqueServer.authInit(ke1, record, credentialId) e authFinish(ke3, expected) — ambos recebem mensagens OPAQUE blinded, nunca a senha. |
Servidor nunca deriva o export_key | @cloudflare/opaque-ts v0.7.5 retorna apenas {sessionKey} no authFinish server-side. O export_key é derivado exclusivamente no navegador a partir de (senha, record, server_seed). |
Servidor não consegue decifrar o wrappedDek sozinho | Unwrap exige hybridKEK = HKDF(export_key ‖ ss). O servidor possui ss (decapsulado do mlKemCt) mas não export_key. |
| Vazamento do banco (cold extract) não abre o vault | Envelopes são OPAQUE OPRF-bound a uma seed do servidor; wrappedDek exige export_key; mlKemCt exige servidor sk + export_key. |
| Vazamento DB + Redis simultâneo (mesmo durante OTP window) não abre | Pós v1.8.1 (§3.2.3), Redis durante OTP contém apenas sessionKey + metadados. share2 e pqSharedSecret derivados apenas em /verify-otp com OTP correto. wrappedDek continua no DB mas precisa de export_key (cliente). |
| Shamir 2-de-3 é estrito | lib/mpc-client.ts:418 define splitSecretShamir(share2, {shares:3, threshold:2}) na escrita; reconstrução exige ≥ 2 nós OK. |
| OTP atomic, single-use, anti-replay | lib/opaque-otp-challenge.ts:93 usa script Lua atômico (verifyOtpAtomic) com GETDEL, timingSafeEqual, maxAttempts=5, TTL 5 min. |
| Cookie de sessão emitido apenas após PAKE OK + OTP OK | app/api/auth/opaque/login/verify-otp/route.ts:118 é o único ponto que emite __Host-taiva-vault-token. |
| Sentry scrub cobre todos os campos sensíveis | sentry-scrub.ts:31 SENSITIVE_KEYS regex cobre share, wrappedDek, dek, seed, nonce, opaque_envelope, entre outros; SENSITIVE_PATHS zera body em /api/auth/*. |
| Logger redact cobre material OPAQUE (v1.6.3) | lib/logger.ts:14 — 26 paths incluindo share2, wrappedDek, sessionKey, exportKey, pqSharedSecret, hybridKEK, ke1-3. |
| Logs não vazam material em rotas OPAQUE | Verificado por grep: nenhum sessionLog.X({material}) em qualquer rota /app/api/auth/opaque/*. |
| Recovery key gerada no navegador (deployed) | Confirmado via grep: 0 imports ativos de lib/recovery-key.ts legacy; consumidores reais usam lib/recovery-key-client.ts. |
Posicionamento recomendado para comunicação pública: TAIVA Vault implementa zero-knowledge em runtime: o servidor não consegue descriptografar o cofre do usuário sozinho mesmo em cenário de vazamento total do banco de dados. A senha mestre nunca toca os servidores TAIVA. A extensão de navegador (caminho recomendado para uso diário) preserva ZK contra todos os adversários considerados, inclusive servidor TAIVA ativamente malicioso. O web vault em vault.taiva.com.br (caminho para onboarding e emergência) preserva ZK contra adversários estáticos; contra adversário ativo em tempo real, mitigações (CSP nonce, bundle manifest assinado por Cosign + Rekor, BTC OTS anchor) elevam o custo do ataque sem eliminá-lo no modelo web. Limitações remanescentes (metadata de navegação em plaintext para usabilidade) estão documentadas honestamente em §3.2.1-3.2.9 acima.
4. Arquitetura de chaves
4.1. Hierarquia
`` Senha mestre (somente no dispositivo) │ │ OPAQUE-3DH (RFC 9497) — cliente envia mensagens OPRF-blinded │ server: server.authInit(ke1, record_armazenado, credentialId) │ client: {ke3, session_key, export_key} = authFinish(ke2) ▼ export_key (32 bytes, nasce e morre no navegador) │ │ HKDF-SHA-256 misturado com ML-KEM shared secret (ver 4.2) ▼ hybridKEK (32 bytes) │ │ AES-256-GCM unwrap(opaqueWrappedDek) ▼ share1 ────┐ │ XOR share2 ────┘ (reconstruído por Shamir 2-de-3 entre 3 nós MPC independentes) │ ▼ DEK (32 bytes) │ │ AES-256-GCM ▼ Itens do cofre (credenciais, notas, TOTP) ``
4.2. Wrap híbrido pós-quântico (ML-KEM-1024)
O servidor mantém uma keypair ML-KEM-1024 de longa duração; o seed (64 bytes) vive em armazenamento de segredos isolado (OpenBao HSM-light, ver §8.4). A pubkey (1568 bytes) é exposta em GET /api/auth/opaque/pq-pubkey.
Register: `` client: (mlKemCt, ss) = ML_KEM_1024.encapsulate(server_pk) client: hybridKEK = HKDF-SHA256(export_key || ss, info="taiva-opaque-pq-v1") client: opaqueWrappedDek = AES-256-GCM(hybridKEK, share1) client → server: persiste {opaqueWrappedDek, mlKemCt, opaqueShare2} ``
Login: `` server: ss = ML_KEM_1024.decapsulate(mlKemCt, server_sk) server → client: envia ss na resposta do /opaque/login/finalize (sobre TLS PQ-hybrid) client: hybridKEK = HKDF(export_key ‖ ss, ...) client: share1 = AES-GCM-unwrap(opaqueWrappedDek, hybridKEK) ``
pqServerKeyVersion é gravado por envelope, permitindo rotação da keypair sem invalidar registros antigos (o cliente continua decapsulando contra o server_sk da versão correspondente).
4.3. Storage layout
| Onde | O quê | Cifrado com |
|---|---|---|
| Browser (memória) | DEK como CryptoKey non-extractable | – (em memória C++ do navegador) |
| Browser (extensão) | DEK persistida em storage.session (hex extractable, in-memory only) | – |
| Server SQLite (host de aplicação) | opaqueEnvelope{Primary,Backup,Recovery}, opaqueWrappedDek*, mlKemCt*, pqServerKeyVersion | libsql AES-256-GCM (SQLITE_ENCRYPTION_KEY) |
| MPC node 1 | Sub-share Shamir 1 do share2 | chave de cifra dedicada por nó |
| MPC node 2 | Sub-share Shamir 2 do share2 | chave de cifra dedicada por nó |
| MPC node 3 | Sub-share Shamir 3 do share2 | chave de cifra dedicada por nó |
| OpenBao (host segregado) | Master secrets operacionais (seeds OPAQUE/ML-KEM, SQLITE_ENCRYPTION_KEY, secrets de bridge…) | Shamir unseal + AppRole 24h |
Princípio: nenhuma camada sozinha contém material suficiente para reconstruir o DEK.
5. Fluxos críticos
5.1. Register (signup)
1. Browser: usuário digita senha mestre. 2. Browser: client.registerInit(senha) → RegistrationRequest (OPRF-blinded). 3. POST /api/auth/opaque/register/init com {credentialId: "<userId>:primary", request}. 4. Server: aplica OPRF com seed estática + AKE keypair, retorna RegistrationResponse. 5. Browser: client.registerFinish(response) → {record, export_key}. export_key é determinístico per (senha, credentialId, server_seed) e jamais sai do navegador. 6. Browser: gera DEK 32 bytes random + share1 = random_bytes(32). Computa share2 = DEK XOR share1. 7. Browser: (mlKemCt, ss) = ML_KEM_1024.encapsulate(server_pk). hybridKEK = HKDF(export_key ‖ ss, "taiva-opaque-pq-v1"). opaqueWrappedDek = AES-GCM(hybridKEK, share1). 8. POST /api/auth/opaque/register/finalize com {credentialId, record, opaqueWrappedDek, opaqueShare2, mlKemCt}. 9. Server (atômico): chama mpcStoreShareV3Opaque(userId, path, share2) que executa Shamir 2-de-3 e fan-out 1 sub-share por nó (3 nós MPC independentes) sob namespace opq-<userId>-<path>, mTLS + HMAC; rejeita se < 2 nós respondem OK. Em sucesso, persiste opaqueEnvelope*, opaqueWrappedDek*, mlKemCt* em SQLite, marca opaqueMigratedAt. 10. Server: em caso de falha do DB pós-MPC, dispara cleanup best-effort mpcWipeShareV3Opaque.
Os três envelopes (primary, backup, recovery) são enrolados de forma independente. credentialId = "<userId>:<path>" garante domain separation: tentar unwrap cruzado falha por OperationError na Web Crypto.
5.2. Login
1. Browser: usuário digita senha mestre. 2. Browser: client.authInit(senha) → KE1. 3. POST /api/auth/opaque/login/init com {credentialId, ke1}. 4. Server: server.authInit(ke1, storedRecord, credentialId) → {KE2, ExpectedAuthResult}. Faz stash de expected em Redis com TTL 60 s, retorna {ke2, expectedId}. 5. Browser: client.authFinish(ke2) → {KE3, session_key, export_key}. Falha aqui significa senha errada (MAC mismatch — server jamais soube). 6. POST /api/auth/opaque/login/finalize com {expectedId, ke3}. 7. Server: consumeExpected(expectedId) (atomic GETDEL anti-replay) → server.authFinish(ke3, expected) → session_key. Decapsula mlKemCt com server_sk → ss. Dispara OTP por e-mail. Retorna {challengeId, mlKemSS: ss}. 8. Browser: hybridKEK = HKDF(export_key ‖ ss). share1 = AES-GCM-unwrap(opaqueWrappedDek, hybridKEK). 9. POST /api/auth/opaque/login/verify-otp com {challengeId, otp}. Server verifica OTP (≤ 5 tentativas), em paralelo emite GET aos 3 mpc-nodes no namespace opq-<userId>-<path>. Aceita os 2+ primeiros sub-shares OK, Shamir.combine → share2. Retorna {sessionToken, share2} + cookie __Host-taiva-vault-token. 10. Browser: DEK = share1 XOR share2. Importa como CryptoKey non-extractable.
O servidor jamais teve em mãos: senha, export_key, share1 em claro, ou DEK reconstruído.
5.3. Recovery (3 paths independentes)
Cada path tem material de wrap independente:
- Primary (
opaqueEnvelopePrimary+ senha mestre) - Backup (
opaqueEnvelopeBackup+ token de backup) - Recovery (
opaqueEnvelopeRecovery+ recovery key formatoTVAU-XXXX-XXXX-...-XXXX, 13 grupos × 4 chars Crockford, ~78 bits de entropia)
Perder o dispositivo + a senha mestre não significa perda total: a recovery key (impressa offline pelo usuário no signup) reconstrói o DEK. Logins via backup ou recovery abrem sessão com flag mustResetTokens que bloqueia acesso ao cofre até POST /api/auth/reset-tokens ser chamado.
6. Anti-enumeração de usuários
O protocolo OPAQUE é, por construção, resistente a enumeração: server.authInit retorna um KE2 válido mesmo para credentialId inexistente (OPRF blinding produz output indistinguível). Para reforçar a defesa contra timing:
1. Material dummy (envelope + AKE keypair fictícios) é cacheado após primeira chamada, garantindo que o cold-start não vaze. 2. Um pad de 120 ms (lib/pad-response.ts) é aplicado a /opaque/login/init, /opaque/login/finalize e /opaque/login/verify-otp. 3. Suite vitest tests/timing/ valida com Welch's t-test em CI. Bug CRIT/HIGH se Δ médio > 5 ms ou p-value < 0.05.
7. Audit chain tamper-evident
7.1. Merkle chain SHA-256
Cada operação do usuário (CREATE/UPDATE/DELETE de credencial/nota/TOTP) gera uma entrada:
`` entryHash = SHA-256(prevHash || userId || action || detail || timestamp) ``
prevHash = entryHash da entrada anterior do mesmo usuário. O cliente reproduz o hashing via Web Crypto e detecta MODIFY/DELETE/INSERT/REORDER em verificação O(1) por delta.
Genesis (primeira entrada): prevHash = SHA-256("taiva-vault-audit-genesis-v1" || userId).
7.2. Bitcoin OpenTimestamps anchor
Cron diário (03:30 UTC): 1. Computa o Merkle root da chain de cada usuário ativo. 2. Submete a 3 calendars públicos (alice, bob, finney). 3. Salva o .ots proof por calendar como JSON em AuditChainAnchor.proofs. 4. Cron de retry polleia ~24-48 h depois para registrar bitcoinBlock.
Verificação independente: GET /api/vault/audit/proof?anchorId=X retorna o blob .ots, e ots verify <proof.ots> (CLI offline da opentimestamps-client) confirma contra a Bitcoin pública. O servidor não pode "voltar no tempo" sem contradizer o blockchain.
7.3. Append-only audit no OpenBao
Cross-link OpenBao ↔ vault chain: um trigger envia hash de janelas do log do OpenBao para ExternalAuditAnchor, e o HEAD da chain do vault é replicado no KV do OpenBao. Discrepância entre os dois lados denuncia tampering.
7.4. Provas de existência (Proof-of-Existence)
Feature PRO-only adicionada em 2026-05-19. Permite ao usuário registrar um evento datado (autoria de IP pré-publicação, ata de reunião informal, decisão pessoal, registro de acesso a informação confidencial, denúncia de whistleblower, cumprimento de obrigação, notificação extrajudicial, bem herdado sem registro formal) com prova criptográfica de existência verificável publicamente.
Pipeline (referência `lib/services/proofs-service.ts`):
1. Cliente cifra { title, description } com AES-256-GCM (nova DEK envelopada pela master CryptoKey, idêntico ao fluxo de notas zero-knowledge). 2. Cliente cifra anexo opcional (PDF, PNG, JPEG, WebP, HEIC, até 10 MB) com nova DEK independente. 3. Cliente calcula contentHash = SHA-256(JSON.stringify({ title, description, attachmentHash })). Este hash é o que entra na cadeia de auditoria e é ancorado em Bitcoin. 4. Cliente assina mensagem canônica taiva:proof:v1 | subtype | contentHash | eventDate ISO | attachmentHash com ML-DSA-65 (NIST FIPS 204, chave do dispositivo, persistida envelope-encriptada em IndexedDB). 5. Servidor valida a assinatura ML-DSA-65 server-side antes de gravar (defense-in-depth contra cliente comprometido). 6. Servidor grava ProofRecord no SQLite cifrado e blob do anexo em disco cifrado (/var/lib/taiva-vault/proof-attachments/<userId>/<id>.enc, modo 0600). 7. Servidor emite auditService.logAction(userId, 'create_proof', proof:<subtype>:<contentHash>, ip) no Merkle chain. O entryHash desse log é a folha que entra na próxima âncora diária Bitcoin via OpenTimestamps (§7.2).
Verificação por terceiro (kit de prova): GET /api/vault/proofs/:id/bundle devolve JSON com ProofRecord + AuditLog entry (id, prevHash, entryHash, createdAt) + AuditChainAnchor confirmado (rootHash, OTS receipts, bloco Bitcoin). Receptor (juiz, advogado, parte adversa) executa:
1. Recompõe canonical message e verifica assinatura ML-DSA-65 com signerPublicKey. 2. Verifica contentHash re-hashando o clear-text que o usuário revelou. 3. Recomputa entryHash do audit log entry: SHA-256(canonical(entry) || prevHash). 4. Recomputa a chain Merkle do usuário até a entry e bate rootHash com o anchor. 5. Verifica o OTS receipt offline contra a Bitcoin pública (ots verify <proof.ots>), confirmando que o hash existia antes do bloco confirmado.
Valor probatório: prova técnica de existência em data X + autoria criptográfica do dispositivo. Não substitui ICP-Brasil, cartório de Títulos e Documentos, nem tabelião. Constitui prova documental atípica admissível em juízo (CPC art. 369), cuja força probatória será apreciada pelo julgador conforme o caso concreto.
Limitações reconhecidas:
- A janela entre criação e confirmação Bitcoin (~24-48h) permite, em teoria, reescrita servidor-side durante esse intervalo. A âncora confirmada estabelece um upper-bound: "este hash existia antes do bloco N".
- A
eventDatereivindicada pelo usuário é metadado não-criptográfico (claim self-reported). O que a blockchain prova é ocreatedAtdo servidor, não a data alegada do evento. - O sub-tipo curado e disclaimer in-app excluem categorias problemáticas no contexto brasileiro: documentação médica paralela ao prontuário CFM e testamentos. Recibos verbais e transcrições de mensagens são suportados mas comunicados como prova fraca (usuário é quem escreve, logo prova "eu sabia", não "o outro prometeu").
- LGPD: o usuário é controlador de dados de terceiros eventualmente inclusos (atas, e-mails, etc.) e responde pela base legal de armazenamento (Art. 7º VI, exercício regular de direito).
7.5. Assinatura Eletrônica Avançada (AEAd)
Adicionada em 2026-05-20 como módulo opt-in (SIGNING_ENABLED). Permite assinar documentos PDF com validade legal Lei 14.063/2020 Art. 4º II (Assinatura Eletrônica Avançada) — cobre prontuário eletrônico, atestado, laudo e receita não-controlada por força da Resolução CFM 2299/2021. NÃO cobre receita controlada (RDC 471/2021 ANVISA), atos notariais ou comunicação PJe federal que exija ICP-Brasil qualificada — esses casos seguem fora do escopo do TAIVA Vault.
Custódia da chave de assinatura (zero-knowledge end-to-end):
1. Keypair ECDSA P-256 gerado e exportado dentro do browser via WebCrypto. 2. O escalar privado d (32 B) é XOR-particionado em share1 + share2. share2 (32 B) é distribuído nos nós Alpha+Beta+Gama via Shamir 2-de-3 sob o namespace OPAQUE aead. share1 é cifrado com signing_kek antes de ir ao servidor. 3. signing_kek = HKDF-SHA256(ikm = export_key‖prf_output, salt = "TAIVA-AEAd-signing-v1", info = identityId, L = 32), onde export_key é derivado on-demand via OPAQUE PAKE (não cacheado) e prf_output é o resultado da extensão WebAuthn PRF avaliada contra um prfSalt aleatório por-identidade, persistido no servidor. 4. Cifragem wrapped_share1 = AES-256-GCM(share1, signing_kek), layout iv(12)‖tag(16)‖ct(32) = 60 B totais. 5. Public key (JWK) é armazenada em claro no SigningIdentity.pubkeyJwk — é informação pública.
Implicação: comprometer só o DB do TAIVA não decifra a chave (precisa de share2 do MPC). Comprometer 1 nó MPC adicional ainda não basta (Shamir threshold 2-de-3). Mesmo o cenário extremo de 2 MPC + DB + senha do usuário phishada falha sem o prf_output, que requer presença física do autenticador (TouchID/FaceID/YubiKey 5.4+).
Cerimônia de assinatura (resumo):
1. Cliente POST /api/signing/sign/init informa identityId + documentId. Servidor verifica posse, recupera share2 do MPC, gera challenge WebAuthn ligado criptograficamente a (userId, identityId, docHash, nonce16), retorna o pacote de material + o hash canônico SHA-256 do documento (docHashHex). 2. Cliente solicita ao usuário a senha do vault, deriva export_key via OPAQUE de uma só ida ao servidor (endpoint /api/auth/opaque/derive-export-key), executa a cerimônia WebAuthn .get() com prf.eval(prfSalt) e o challenge do passo 1 — autenticador retorna prf_output + assertion assinada. 3. Cliente reconstrói signing_kek, desfaz wrap de share1, calcula d = share1 XOR share2, importa como CryptoKey extractable:false com key_ops:['sign'] e gera a assinatura ECDSA-SHA256 (64 B raw r‖s). Todas as variáveis intermediárias são zeradas. 4. Cliente POST /api/signing/sign com signatureValue + webauthnAssertion. Servidor (a) consome o challenge atomicamente via Redis GETDEL, (b) verifica que assertion.clientDataJSON.challenge bate, (c) verifica ECDSA contra a pubkeyJwk armazenada, (d) insere em transação atômica AuditLog{action:'signing.signature_issued', detail:JSON estruturado} + SignatureEvent com FK 1:1, (e) devolve envelope TAIVA-AEAd-v1. 5. Próximo ciclo de âncora Bitcoin (cron diário 03:07 UTC) carimba a entry da chain, populando SignatureEvent.btcOtsProofId. A partir desse ponto a assinatura tem prova temporal pública verificável offline via ots verify.
Defesa anti-replay:
- Challenge único, com TTL 5 min, consumido por GETDEL atômico.
- Mesma assinatura submetida duas vezes falha em (a) challenge já consumido + (b) doc-hash binding.
SignatureEvent.signatureHashindexado: detecta tentativa de re-submeter byte-for-byte.
Verificação independente via POST /api/signing/verify (público, sem auth) retorna 5 checks: 1. doc_hash_match: SHA-256 recomputado bate com envelope.doc.hash_sha256. 2. signature_valid: ECDSA P-256 verify contra envelope.signer.pubkey_jwk. 3. audit_chain_present: existe AuditLog com id = envelope.evidence.audit_entry_id e action = 'signing.signature_issued'. 4. audit_chain_hash_match: AuditLog.entryHash armazenado bate com envelope.evidence.audit_chain_entry_hash. 5. btc_anchored: AuditChainAnchor.status = 'confirmed' para envelope.evidence.btc_ots_proof_id.
valid = doc_hash_match && signature_valid (mínimo criptográfico); os outros 3 checks são prova auxiliar de cadeia de auditoria + ancoragem temporal.
Limitações reconhecidas:
- Receita controlada (psicotrópicos, opioides RDC 471 ANVISA): AEAd não substitui ICP-Brasil qualificada. Médico precisa de cert A1 ICP para esses casos. Path co-existe (futuro) sem impactar AEAd.
- Atos notariais (escrituras, registros imobiliários): exigem ICP-Brasil qualificada por força da MP 2.200-2/2001 §1º. Fora do escopo do TAIVA.
- Cadeia de evidência LGPD-mortal: pedido LGPD Art. 18 VI (eliminação) cascateia para
SignatureEvent+AuditLogdo usuário. A assinatura permanece criptograficamente verificável via envelope JSON exportado ANTES da deleção, masaudit_chain_presentretornafalseapós wipe. Comprovante PDF (download no momento da assinatura) é o artefato durável para terceiros. - Bundle attestation requer extensão: a CSP estrita aplicada em produção (
/assinar/*) cobre eval/wasm-eval, mas o ataque de supply-chain via dep maliciosa do bundle Next.js só é detectado client-side via verificação cosign do/.well-known/taiva-bundle-manifest.json, implementada via extensão Chrome do TAIVA (mesma extensão que valida o vault). Usuários sem a extensão recebem CSP-only protection, suficiente contra XSS mas não contra dep maliciosa que passe pelo build.
7.6. Página pública /status (v1.9.0)
A página https://vault.taiva.com.br/status é um snapshot operacional público, atualizado a cada 60 segundos via ISR, sem autenticação. Permite que terceiros (advogados, auditores LGPD, pesquisadores, jornalistas) confirmem a saúde dos componentes críticos do TAIVA sem precisar de credencial nem acesso interno.
Sinais expostos:
- Versão do app em produção (extraída do
package.jsonno build, bate commetadata.component.versiondo SBOM publicado). - Estado agregado do mesh MPC (label "operacional", "recuperando", "degradado" ou "não configurado" via
getMpcCircuitState()). - Status seal do OpenBao (apenas booleano "destravado"/"selado", via fetch interno em
127.0.0.1:8200/v1/sys/healthcom timeout 1.5s e fail-gracioso). - TLS cert dias até expirar (handshake direto via
tls.connectcom servername correto, lêvalid_todo peer certificate, calcula dias). - Audit chain Merkle: total de eventos + relativo da última entrada.
- Bitcoin OTS: total de âncoras, confirmadas, pendentes, e a última âncora confirmada com link clicável para
mempool.space/block/<N>. - Últimas 5 entradas da audit chain global anonimizadas: apenas
action(rótulo humano viadescribeAction()) + tempo relativo. Sem userId, sem detalhes, sem IP. - 4 atestações públicas linkadas:
/.well-known/jwks.json,taiva-bundle-manifest.json,sbom.json,cbom.json.
Defesa anti-OSINT explícita: o footer da página declara o que NÃO é exposto: IPs ou hostnames das VPSs, latências internas, contagem de users por nó, detalhes do estado seal além do booleano. O ISR de 60 segundos limita a granularidade temporal observável; o fetch ao OpenBao é loopback (não cross-host) reduzindo superfície de side-channel; o histórico de audit é anônimo por construção (sem mapeamento reversível).
A página /status materializa o posicionamento "trustless auditável" do projeto: qualquer pessoa, sem login, sem fazer requisição privilegiada, sem confiar na palavra do TAIVA, pode verificar que os componentes críticos estão no estado declarado.
8. Infraestrutura
8.1. Topologia
| Função | Papel | Stack genérico |
|---|---|---|
| Host de auth + API + MPC node A | auth-gateway, api-core, primeiro nó MPC, OpenBao warm-standby | Node LTS, reverse proxy moderno, Postgres, Redis |
| Host MPC node B | nó MPC adicional em jurisdição independente | Node LTS, reverse proxy moderno, Postgres |
| Host segregado de secrets | OpenBao master (HSM-light) + nó MPC adicional + Postgres audit_db (mTLS) | Node LTS, Postgres recente, OpenBao recente |
| Host de observabilidade | SIEM, auditd central, SSO de escopo interno | SIEM/IDS open-source padrão de mercado |
| Host de aplicação (PWA) | vault Next.js, DMS worker, OpenBao warm-standby, agentes auxiliares | Node LTS, Next.js recente, reverse proxy moderno, Redis, SQLite encrypted |
| Host de CI + observabilidade | Forgejo self-hosted, observabilidade (Grafana/Loki/Prometheus), backup hub | (Docker) |
Hosts em três VPS providers distintos em Brasil + Europa. Provedores específicos, endereços de rede internos, codenames internos e portas de serviço não são publicados. O OpenBao em host segregado é a fonte canônica dos master secrets desde a Phase 3 (2026-05-17); demais instâncias são warm-standby para emergência.
8.2. Inter-host
- Mesh privada com ACL restritiva (rede overlay self-hosted).
- mTLS vault ↔ OpenBao e vault ↔ mpc-nodes. CA própria com validade plurianual.
- Default mTLS é opt-OUT: qualquer valor de
MPC_USE_MTLSque não seja"false"habilita; os nós rejeitam HTTP plain.
8.3. Edge TLS pós-quântico
| Edge | Software | Curva PQ-hybrid |
|---|---|---|
vault.taiva.com.br | Caddy recente | X25519MLKEM768 (explícito) |
taiva.com.br + www | Traefik recente (Go 1.24+) | X25519MLKEM768 (default do Go) |
| Endpoints públicos de auth/API | Caddy recente | X25519MLKEM768 (default do Go) |
Clientes que não suportam a curva híbrida caem em X25519 clássico (backward compatible). Versões exatas de edge não são publicadas para reduzir superfície de fingerprinting.
8.4. Master secrets (OpenBao)
- OpenBao em host segregado de aplicações, storage Raft em disco, mTLS obrigatório.
- Unseal Shamir 3-de-5 (shards distribuídos em locations físicas independentes).
- Tokens AppRole com TTL de 24 h e rotação a cada 6 h via systemd timer.
- Armazena: seeds OPAQUE OPRF/AKE, seed ML-KEM-1024, chave de cifra do SQLite, segredos HMAC de bridge, entre outros segredos operacionais.
- Políticas ACL restringem leitura por serviço (princípio do menor privilégio); cada serviço usa AppRole separado com path scope restrito.
- Audit device em arquivo (mode 700, fora do alcance dos services de aplicação).
- Fallback
.envpermitido em runtime apenas para o subset não-cripto (config strings) quandoOPENBAO_FALLBACK_OK=1. Material cripto crítico (seeds OPAQUE, ML-KEM) NÃO tem fallback e o serviço recusa boot se ausente. - Instâncias secundárias funcionam como warm-standby (sealed por default; usadas apenas em DR).
8.5. Backup
- Snapshot DB cifrado (
VACUUM INTO→ AES-256-CBC PBKDF2 600k iters → rsync SSH) replicado cross-host pela mesh Tailscale privada, daily, para o host offsite Sigma. - DR restore validado (md5 INTEGRITY OK).
- Off-site adicional B2 Backblaze pendente (roadmap Q3 2026).
9. Decisões arquiteturais documentadas (ADRs)
| ADR | Tópico | Status |
|---|---|---|
| 0001 | Zero-knowledge strict | Accepted |
| 0002 | OPAQUE para autenticação (vs SRP) | SUPERSEDED por 0008 (era roadmap fictício) |
| 0003 | MPC Shamir 2-of-2 (desenho histórico) | SUPERSEDED por 0007 |
| 0004 | Hybrid KEK (classical + PQ) | Accepted (forma final em 0008 + 2H) |
| 0005 | OpenBao HSM-light em host segregado | Accepted |
| 0006 | God-objects split plan | Partial done |
| 0007 | MPC 2-of-3 + mTLS inter-node (Sprint E) | Accepted |
| 0008 | OPAQUE-3DH real via @cloudflare/opaque-ts | Accepted |
ADRs versionados em docs/adr/.
10. Trade-offs reconhecidos
10.1. Em favor de UX vs. estrita segurança
- Auto-lock de 7 dias: o valor em produção é
AUTO_LOCK_MS=604800000(7 dias) na sessão do servidor. Trade-off forte e consciente: o produto roda como segundo fator de muitos usuários e relogar diariamente foi avaliado como custo de usabilidade alto demais. Significa que, em dispositivo confiável, o DEK pode permanecer carregado em memória do navegador por até 7 dias após o último unlock. Mitigações em camadas:CryptoKeynon-extractable (impede exfiltração via JS comum), wipe em evento de lock, panic wipe via UI, eCache-Control: no-storeem todas as respostas (§3.2.7). Usuários em ambiente compartilhado devem reduzir esse valor ou usar panic wipe ao encerrar sessão. - `'unsafe-eval'` e `'wasm-unsafe-eval'` em CSP:
unsafe-evalé exigido pelo Prisma client (usanew Function()internamente);wasm-unsafe-evalé exigido pelos módulos WASM. Refactor para eliminarunsafe-evalestá em deferred. - `'unsafe-inline'` em `style-src`: extensão popup usa
el.style.display='none'dinâmico. Refactor de inline-styles para CSS classes está como tech debt. - Metadata em plaintext (§3.2.1): URLs, usernames e issuer TOTP ficam legíveis pelo servidor para permitir busca e travel-mode. Trade-off explícito para usabilidade.
10.2. Ainda em melhoria
- Off-site backup B2 (atual: replicação cross-host mas ainda dentro do mesmo conjunto de provedores).
- HSM real (atual: OpenBao Shamir é HSM-light). Plan Q4 2026.
- Phases 2-5 god-objects split (ADR-0006).
- Sub-stages remanescentes do rollout OPAQUE: 2E (whitepaper estendido com ataques formais), 2F (suite e2e OPAQUE completa), 2G (merge de
feature/opaque-pakeparamaster).
11. Como auditar você mesmo
1. Releases assinadas: cada release publicada é assinada com Cosign + Rekor transparency log. cosign verify --key cosign.pub confirma que o binário corresponde à release publicada; a chave pública (cosign.pub, SHA-256 cb2b3bdac4c48106b200d728ffc8b22401a292ba1d16b0073f77f0d1331d2bc9) é distribuída no próprio repositório. 2. Audit chain Bitcoin: baixe o .ots proof via GET /api/vault/audit/proof?anchorId=X e execute ots verify localmente. Comprova que o servidor não reescreveu seu histórico. 3. TLS pós-quântico: openssl s_client -connect vault.taiva.com.br:443 -groups X25519MLKEM768 (ou Go 1.24+ PQ-only probe) confirma a curva híbrida negociada. 4. CSP em produção: curl -I https://vault.taiva.com.br/login mostra a Content-Security-Policy ativa. 5. Reportar vulnerabilidades: contato@taiva.com.br com prefixo [SECURITY]. Safe harbor para pesquisa em boa fé, embargo até fix em produção, naming no changelog se desejado. Política em /disclosure.
12. Mudanças vs. whitepaper v1.5 (2026-05-18)
- ✅ OPAQUE-3DH virou o único caminho de auth: Sub-stages 2C (libs legacy removidas), 2D (13 colunas legacy droppadas do
User) finalizaram o cutover. Não há mais "ZK-named legacy". - ✅ ML-KEM-768 → ML-KEM-1024: Sub-stage 2H (deploy 2026-05-19) trocou o KEM pós-quântico para FIPS 203 categoria 5 com keypair de longa duração do servidor em OpenBao.
- ✅ TLS hybrid PQ X25519MLKEM768 em produção em todos os edges. Sub-stage 2I.
- ✅ Argon2id removido do caminho cripto ativo (era usado no wrap legacy; o KDF do
export_keyagora é interno ao OPAQUE). - ✅ MPC 2-de-3 ativo (
CURRENT_KEY_SHARE_VERSION=3), namespaceopq-<userId>-<path>separado do legacy. - ✅ mTLS inter-node default opt-OUT (HTTP plain rejeitado).
- ✅ OpenBao como HSM-light com unseal Shamir.
Última atualização: 2026-05-29. v1.10.x deployed: Fase 2 multi-tenant + CRM Onda 1 + Sprint D+E /sign público + perfil contratante + F32 soft-delete + 2 rounds audit hardening + cron workspace-cleanup + ADR-0024 tier free + step-up OPAQUE inline (re-auth por senha mestra sem logout).
Próxima revisão programada: imediata se houver mudança em primitiva crítica; senão a cada release maior.