← Back
PTEN

TAIVA Vault: Technical Whitepaper

Version: 1.10.x, 2026-05-29 (Phase 2 multi-tenant + CRM + public multi-signer + free tier + inline OPAQUE step-up re-auth) Status: active (updated with each major release)

> Newer cycles (2026-05-28/29: free tier ADR-0024, inline OPAQUE step-up re-auth, ESLint server-only guard) are described in the PT whitepaper and CHANGELOG.md; EN translation pending (see translation gap note in CHANGELOG-EN.md).

> Changes since 1.9.0 (2026-05-25 → 2026-05-27): > > - §5.4 + §7.7 NEW: Phase 2 multi-tenant Workspace (ADR-0012). Root Workspace model with single ownerId, 10 legacy models gain workspaceId String? SetNull (back-compat: Solo = NULL workspace = implicit "Personal"). Anti-IDOR T-021 via centralized requireWorkspaceOwnership helper: 404 anti-enum when ws belongs to another user (NEVER 403, does not leak existence). Audit chain canonical does NOT include workspaceId — Bitcoin OTS anchors preserved bit-for-bit. Soft-delete with 90d grace via workspace-cleanup cron. > - §7.8 NEW: Sequential multi-signer (ADR-0018) — SignatureRequest + Signer with accessToken (32 bytes hex, unique per signer). Third-party signer receives link via owner's mailto:, opens public /sign/[accessToken] without logging in. Brazilian Federal Law 14.063 Simple (Art. 4º I, checkbox+name) or Advanced (Art. 4º II, ZK CPF + inline face biometry via snarkjs PLONK + browser face-api). ZK selfie via ECIES X25519 (owner generates keypair in browser, priv encrypted with their DEK, signer derives wrapping key via ECDH+HKDF — server never sees keys). > - §7.9 NEW: Native CRM with full ZK (ADR-0022) — WorkspaceContact + WorkspaceTask. PII 100% encrypted client-side with the user's DEK (nameCipher/emailCipher/phoneCipher/titleCipher/etc). Server NEVER decrypts. mailto: reminder is client-side, preserving the owner's branding (email goes out from their own client). tasks-reminder cron at 08:00 BRT daily sends a GENERIC reminder to the owner (count + window, ZERO PII). Double idempotency: Redis SETNX 24h + WorkspaceTask.reminderSentDaysJson. > - §5.5 NEW: Tier flag SP0 (ADR-0021) — User.tier (solo|profissional) on the SAME Epsilon host. Helper lib/user-tier.ts is the SINGLE source of truth. Tier-aware HKDF domain separation (buildHkdfInfo(ctx, scope, version)). Principle 9 (centralized helper) mandatory: never if (user.tier === 'profissional') directly in a route. > - §7.10 NEW: ZK credential sharing (ADR-0019) — SharedCredential + UserShareKeyPair. ML-KEM-1024 encapsulation to the recipient. Pubkey lookup is constant-time for timing safety. Per-user opt-in via searchable. > - §7.11 NEW: Encrypted file vault (ADR-0017) — EncryptedFile, workspace-scoped. 100 MB ciphertext cap. Pluggable R2/S3 backend (falls back to local FS). The workspace-cleanup cron purges deletedAt > 30d. IIFE on module load assertR2BackendReady() fail-fast on boot if R2_ENDPOINT is set without @aws-sdk/client-s3 installed. > - §7.12 NEW: Contracting profile (Issue 1+4) — 9 new User cols (displayName/companyName/companyDocument/addressJson/phoneNumber/websiteUrl/bio/brandColor/logoBlob/logoMimeType). Appears in invitation emails, public /sign page, and signed PDF footers. Logo whitelist PNG/JPEG (SVG removed in audit — XSS via event handlers bypasses CSP nonce). > - §3.2.15 NEW: F32 LGPD soft-delete. User.deletedAt anonymizes in-place (email → random SHA-256, PII zeroed, opaqueEnvelope/wrappedDek/mlKemCt nulled) preserving AuditLog + AuditChainAnchor + Bitcoin OTS anchors. Previously, prisma.user.delete() cascaded and destroyed the proof for external auditors (ANPD / court). 25 User lookups across the codebase now filter deletedAt:null. Helper lib/user-deleted-cache.ts (Redis flag, fail-OPEN) plus a gate in sessionLoad closes the AUTO_LOCK_MS=7d race. > - §3.2.16 NEW: Real Asaas billing (ADR-0014) — /api/webhooks/asaas webhook with constant-time HMAC verify + strict Zod + DOUBLE idempotency (Redis SET NX EX 7d + AsaasWebhookEvent.eventId @unique) + composite externalReference userId:tier:plan cross-validation against tier promotion without an explicit ceremony. billing-reconcile cron every 6h + trial-expiration daily at 09:00 BRT. > - §2.3 NEW: 9 mandatory defense-in-depth principles for new code (Zod strict, server-side enforcement final, constant-time + anti-enum, crypto domain separation, idempotency, layered rate-limit, audit log + scrub, own feature flag, centralized helper). > > Changes since 1.6.3 (combines v1.7.x, v1.8.x and v1.9.0): > > - §3.2.10 NEW: automated Cosign attestation cycle. The 3 public artifacts (taiva-bundle-manifest.json, sbom.json, cbom.json) now have signed .bundle (sig + cert + Rekor proof) generated in postbuild when the local key is available, plus an hourly cron on the operator workstation detecting drift and re-signing. Verifiable by any third party via scripts/verify-public-attestations.sh. > - §3.2.11 NEW: ordered JWKS issuer-key rotation. Support for SDJWT_ISSUER_PREVIOUS_JWK_B64 (retired pubkey-only key) covering the window in which older JWS still need to validate. External verifiers resolve a JWS by kid in the header. > - §3.2.12 NEW: 5 expensive routes got consistent rate-limit (signing/verify 50/min, signing/sign 10/min, derive-export-key 20/min, lgpd/data-export 3/h, blog/subscribe 5/h + honeypot). Unified pattern via checkIpLimit (Redis INCR atomic, in-process fallback). > - §3.2.13 NEW: PII redact in Pino logs. maskEmail helper consolidated, paths *.email, *.adminEmail, *.targetEmail added to the redact list (defense in depth against log leak via Sentry hack or stolen backup). > - §3.2.14 NEW: counter race in signing/sign closed via atomic compare-and-swap inside prisma.$transaction (defense in depth over the Redis GETDEL of the challenge). > - §7.4: AEAd module already shipped in v1.8.0 (Brazilian Federal Law 14.063/2020 Art. 4º II), ECDSA P-256 client-side with a private scalar XOR-split between MPC 2-of-3 (path aead) and a wrappedShare1 encrypted by signingKek (HKDF of export_key + WebAuthn PRF). PDF stamping + QR verifier + cosign-ready bundle manifest. Now consolidated with the closed Cosign cycle in §3.2.10. > - §7.5 NEW: public /status page (60s ISR) with aggregate mesh health (MPC circuit-breaker state, OpenBao seal status, TLS cert days until expiry, Merkle audit chain plus the last 5 anonymized entries). No IPs, no internal latencies, no per-node counts. > - Proof-of-existence (v1.7.0, ML-DSA-65 + Bitcoin OTS anchor) is documented in CHANGELOG.md; an English WHITEPAPER-EN.md section will follow in a future translation pass. > - §3.2.5 updated: recovery key fix is deployed (no longer "in rollout"). Verified by grepping importers of lib/recovery-key.ts (legacy): 0 active imports; lib/recovery-key-client.ts (browser CSPRNG) used in production. > - §3.2.6 and §3.2.7 unchanged. > - §3.2.9: Pino logger redact active on 29 crypto-related paths plus 3 PII paths. > - §3.3: end-to-end trace verdict of the OPAQUE flow confirmed the server cannot decrypt user data alone, verified via code analysis with file:line references.


1. Executive Summary

TAIVA Vault is a zero-knowledge password manager in the web vault model, with real PAKE authentication (OPAQUE-3DH, RFC 9497) and post-quantum hybrid wrapping (ML-KEM-1024, NIST FIPS 203 category 5). The server never learns the master password, nor the export_key that opens each envelope. Sensitive vault payloads (passwords, note content, TOTP secrets) transit and are stored exclusively as AES-256-GCM ciphertext, whose key (DEK) is rebuilt in the browser's memory at each login.

Operational honesty:

  • Vault metadata (URL/site, username, TOTP issuer, category, favorite, travel-safe) sits in plaintext on the server to enable search, sorting and travel-mode UI. This is an explicit trade-off between full privacy and usability, documented in §3.2 and the Privacy Policy.
  • Inherent web-model limitation: the server delivers the JavaScript that performs all client-side crypto. A compromised server could deliver malicious JS. Mitigations: CSP nonce + strict-dynamic + signed releases (Cosign + Rekor). We do not reach "zero-knowledge against an entirely malicious server delivering JS" — that requires native clients (extension/desktop/mobile).
  • Momentarily during OTP, the server stores in Redis (TTL 5 min) auxiliary material post-PAKE: encrypted wrappedDek, share2, sessionKey (PAKE output) and pqSharedSecret (ML-KEM decapsulation). Without the client's export_key this material cannot reconstruct the DEK, but the server touches this material in memory during the OTP window.
  • The recovery key (TVAU-…) is, at issuance (signup) and rotation (reset), generated and momentarily touched in server memory before being returned to the client. Fix in rollout (v1.6.1) migrates generation to the browser CSPRNG, eliminating that window.

The DEK is split into two fragments: share1, wrapped under a hybridKEK formed by mixing the OPAQUE export_key with the ML-KEM-1024 shared secret via HKDF; and share2, distributed via Shamir Secret Sharing across three MPC nodes distributed across at least two independent jurisdictions (Brazil and Europe), with a 2-of-3 threshold. The system tolerates the failure of 1 node. A simultaneous compromise of 2 nodes is not enough to open the vault: the attacker would still need the export_key (which only exists from the user's password + their OPAQUE envelope, inside the browser).

The network layer uses TLS 1.3 with hybrid post-quantum key exchange X25519MLKEM768 at the edge (recent Caddy / Traefik versions with Go 1.24+). The audit trail is a SHA-256 Merkle chain anchored daily to the Bitcoin blockchain via OpenTimestamps. Releases are signed with Cosign and recorded in Sigstore Rekor's public transparency log.

Operational master secrets reside in OpenBao (HSM-light) with Shamir unseal, 24h-TTL AppRole tokens and rotation every 6 hours. Daily encrypted DB backups run cross-host: VACUUM INTO produces a snapshot, it is re-encrypted with AES-256-CBC (PBKDF2 600k iters, key derived from SQLITE_ENCRYPTION_KEY) and shipped via rsync SSH over the private Tailscale mesh to an offsite host (Sigma).

This document describes the threat model, cryptographic primitives, key hierarchy, critical flows, acknowledged trade-offs and how to audit the system independently.


2. Threat Model

2.1. Considered Adversaries

AdversaryCapabilityPrimary Defense
External attackerDDoS, scanning, online brute forceCrowdSec L7 + fail2ban + Redis Lua-EVAL rate limit + per-user lockout
Insider operatorFull access to 1 application hostOPAQUE (server never learns password); master secrets in OpenBao on a segregated host
Server compromised during loginMITM + process memory readsOPAQUE-3DH PAKE: messages are OPRF-blinded; export_key never leaves the browser
Leaked DB snapshotPossession of OPAQUE envelopesOPRF binding to the server's key makes offline dictionary attacks ineffective
Subpoena / legal coercionForensic access to 1 serverZK strict + MPC 2-of-3 threshold
Supply-chain compromiseMalicious npm, compromised CDNLocal crypto bundles (public/crypto-vendor/), npm audit build gate, Cosign + Rekor
Quantum computer (hypothetical)Breaks DH, RSA, ECCML-KEM-1024 in the wrap (NIST FIPS 203 cat 5) + X25519MLKEM768 in TLS
Lateral compromise between nodesPivot via internal networkRestrictive mesh ACL + inter-host mTLS + per-node encryption keys

2.2. Adversaries Out of Scope

  • Simultaneous compromise of 2 of the 3 mpc-nodes plus possession of the export_key (which requires the user's password). Defense: strong password + OPAQUE OPRF binding.
  • Client device compromise (extension / browser). Defense: DEK imported as a non-extractable CryptoKey + strict CSP + configurable auto-lock.
  • Hardware attacks (cold boot, Rowhammer). Out of scope.

3. Cryptographic Primitives

PrimitiveUseLibrary
OPAQUE-3DH (suite OPAQUE_P256)Authentication PAKE; produces export_key client-side@cloudflare/opaque-ts
ML-KEM-1024 (NIST FIPS 203 cat 5)Post-quantum KEM; produces ss mixed into the wrap@noble/post-quantum
HKDF-SHA-256Derives hybridKEK = HKDF(export_key ‖ ss, info) and per-purpose sub-keysNode crypto
AES-256-GCMWraps share1, encrypts session DEK and vault itemsWeb Crypto API + Node crypto
Shamir Secret Sharing GF(2^8)Fan-out of share2 across the 3 mpc-nodes; threshold 2-of-3shamirs-secret-sharing v2.0.1
TLS 1.3 + X25519MLKEM768Network layer with hybrid post-quantum key exchangeGo stdlib (Caddy / Traefik)
Ed25519 / ECDSA P-256Inter-host mTLS + WebAuthn passkeysNode TLS + @simplewebauthn/server
SHA-256 Merkle chainTamper-evident per-user audit loglib/audit-chain.ts
OpenTimestamps + BitcoinDaily anchor of the Merkle root in the blockchainlib/audit-chain-anchor.ts
Bias-free CSPRNGGeneration of passwords, tokens, nonces, DEK, share1crypto.getRandomValues + rejection sampling

Operational parameters:

  • ML-KEM-1024: ciphertext 1568 bytes, shared secret 32 bytes.
  • AES-256-GCM: random 12-byte IV + 16-byte auth tag + ciphertext.
  • OPAQUE OPRF: 32-byte static seed (currently in process.env, in transition to OpenBao — see §3.2.2).
  • OPAQUE AKE: P-256 keypair (currently in process.env, in transition to OpenBao — see §3.2.2).

> Argon2id is no longer part of the active cryptographic path in v1.6. It was removed in Sub-stage 2C (2026-05-19) together with the legacy auth path.

3.2 Operational honesty: known trade-offs and limitations

This section lists real trade-offs and limitations of the current design. It documents what is not zero-knowledge in the strict sense, to avoid an overly optimistic reading of the product.

3.2.1 Vault metadata in plaintext. The Credential and TotpAccount tables store some fields without encryption:

  • Credential.site (service URL), Credential.username, Credential.category, Credential.favorite, Credential.travelSafe
  • TotpAccount.issuer, TotpAccount.account, TotpAccount.digits, TotpAccount.period, TotpAccount.travelSafe

The sensitive payload remains encrypted (passwordCipher, secretCipher, titleCipher, contentCipher are AES-256-GCM blobs). But the server knows: which services (URLs) the user has, which identifier in each one, which TOTP issuer (e.g. GitHub, Google, AWS). This is a trade-off to allow search, filtering, travel-mode UI and sorting on the client without prior unwrapping. A future roadmap may offer an opt-in "metadata-encrypted" mode with deterministic HMAC for the search index.

3.2.2 OPAQUE secrets in `process.env` (not OpenBao in current implementation). The OPAQUE_OPRF_SEED and the AKE keypair (OPAQUE_AKE_PUBLIC/OPAQUE_AKE_PRIVATE) are still loaded from environment variables. Once the OpenBao runtime-read bug is resolved, these three move to OpenBao with paths similar to sqlite-encryption-key. Current risk: filesystem compromise of the application host → OPAQUE seed exfiltration → dictionary attack against envelopes (remaining protection: OPRF binding still resists online brute force via rate-limit + per-user lockout).

3.2.3 OTP window closed (v1.8.1, 2026-05-21). The OPAQUE login flow happens across two requests separated by an OTP sent by email. In versions prior to 1.8.1, between the first request (/login/finalize) and the second (/login/verify-otp), the server stored in Redis with a 5 min TTL the encrypted wrappedDek, the share2 reconstructed via MPC, the sessionKey and the pqSharedSecret (ML-KEM decapsulation). Without the client's export_key this material could not reconstruct the DEK, but the server touched auxiliary material during the window.

As of v1.8.1, /login/finalize stores in Redis only {userId, email, path, sessionKey}. The sessionKey is the public output of the PAKE and, on its own, decrypts no envelope: what opens wrappedDek is the export_key, which exists exclusively in the browser. The cryptographically sensitive operations (mpcRetrieveShareV3Opaque + ML-KEM decapsulation) were moved to /login/verify-otp, executed in process memory only after OTP validation, and returned to the client in the same response. Result: decryptable material never dwells in Redis. The "simultaneous DB + Redis leak during the OTP window" scenario no longer enables efficient brute-force of the master password.

3.2.4 Web vault model: the server delivers the JS. All client-side crypto runs in JavaScript downloaded from the server. A compromised server could deliver modified JS to leak the master password during PAKE or the DEK after unlock. Mitigations: CSP nonce + strict-dynamic + Cosign-signed releases verifiable (signed tarball commit SHA vs runtime expectation). There is no complete mitigation in a web vault: native clients (Chrome/Firefox extension, desktop, mobile) reduce this window because code is installed once and updated via store with external signing chain. Currently the extension (taiva-vault-extension/) is the most resistant path; it is being migrated to OPAQUE (still uses legacy stack v1.3.8).

3.2.5 Recovery key (TVAU-...) generated in the browser (deployed). Before v1.6.x, the recovery key was generated with crypto.randomBytes in Node (server-side), touching the material in server memory during issuance. This window is now closed: lib/recovery-key-client.ts:70 uses crypto.getRandomValues (Web Crypto API) in the browser. The server only receives the derived OPAQUE envelope, never the recovery key in clear. Verification: 0 imports of lib/recovery-key.ts (legacy server-side version) in production code — the function survives as dead code, candidate for removal.

3.2.6 Inter-node mTLS — fail-closed in production (v1.6.1). The MPC client (lib/mpc-client.ts) has always used mTLS opt-OUT by default. In v1.6.1 we added an additional protection: in NODE_ENV=production, if MPC_USE_MTLS=false is explicitly set and ALPHA_MPC_URL is plain HTTP, the module raises a fatal error at boot, preventing silent regression (share2 traveling in plaintext).

3.2.7 `Cache-Control: no-store` on every vault response (v1.6.1). The vaultJson() helper now sets Cache-Control: no-store, no-cache, must-revalidate, private + Pragma: no-cache + Expires: 0 on every API response. Defense-in-depth against accidental caching (CDN, proxy, browser) of sensitive material (share2, sessionKey, vault ciphertext).

3.2.9 Logger redact expanded to 26 crypto paths (v1.6.3). lib/logger.ts was updated to automatically censor any object field logged by Pino whose name is in an expanded allowlist: share1, share2, wrappedDek, opaqueWrappedDek, sessionKey, exportKey, hybridKEK, pqSharedSecret, mlKemCt, opaqueEnvelope, ke1, ke2, ke3, recoveryKey, plus the generic dek, token, otp, secret, password, encryptionKey. Prior verification confirmed no sessionLog.X({material}) call exists today in code (checked via grep on all OPAQUE routes). The expansion is defense-in-depth against accidental future leakage.

3.2.10 Automated Cosign attestation cycle (v1.9.0). The three public attestation artifacts this whitepaper promises (/.well-known/taiva-bundle-manifest.json, sbom.json, cbom.json) are now signed end-to-end. The invariant "private key NEVER on Epsilon" is preserved: the postbuild step scripts/sign-public-attestations.mjs only tries to sign when the key ~/.cosign-taiva-vault/cosign.key is available, otherwise exits with 0 and prints instructions. The cycle is closed by an hourly cron on the operator workstation (scripts/cron-sign-attestations.sh, crontab entry 7 * * * *) which downloads the published JSON files, detects drift via cosign verify-blob, re-signs locally and ships the .bundle back to Epsilon via scp + service restart. Any third party with cosign + curl can reproduce the validation by running ./scripts/verify-public-attestations.sh https://vault.taiva.com.br. Cosign v3 format (.bundle consolidated, contains signature + cert + Rekor proof inline). Before this cycle the JSON were generated without .sig and the public verifier returned "not signed", reducing the promise to a mere intention. Now the promise is executable.

3.2.11 Ordered JWKS issuer-key rotation (v1.9.0). The ECDSA P-256 key that signs SD-JWT VC, LGPD receipts and bundles gained rotation support. lib/issuer-pubkey.ts keeps a {current, previous, byKid: Map<string, CachedIssuer>} cache. The new environment variable SDJWT_ISSUER_PREVIOUS_JWK_B64 accepts the retired key (public part only, no d) for the window in which older JWS still need to validate. New JWS are emitted with kid in the header (RFC 7517) extracted via getCurrentIssuerKid(). The verifier lib/lgpd-receipt-verify.ts resolves the key via getIssuerByKid(header.kid) with fallback to current when the JWS is legacy and does not carry kid. Without kid, every existing JWS remains valid; with kid, third parties that cache the JWKS can keep verifying even after an orderly operator rotation. The /.well-known/jwks.json endpoint publishes current and previous when both are configured (distinct kid per key, guaranteeing deterministic lookup).

3.2.12 Consistent rate-limit on expensive routes (v1.9.0). Five routes that involve heavy cryptography or external amplification got uniform throttling via the checkIpLimit helper (Redis INCR atomic + in-process fallback). POST /api/signing/verify 50 per minute per IP (public, ECDSA + 10MB Buffer). POST /api/signing/sign 10 per minute per userId (ECDSA + DB transaction + SD-JWT + PDF stamping). POST /api/auth/opaque/derive-export-key 20 per minute per userId (PAKE init 3DH + ML-KEM hybrid). GET /api/lgpd/data-export 3 per hour per userId (whole vault + audit chain). POST /api/blog/subscribe 5 per hour per IP plus invisible UI honeypot field website and zod validation (was an unprotected point amplifying Resend sends). All return 429 with Retry-After header. Consistent pattern facilitates future audit.

3.2.13 PII redact in logs (v1.9.0). The Pino redact that covers cryptographic material was extended with PII paths: *.email, *.adminEmail, *.targetEmail. The new helper maskEmail(email) in lib/email-utils.ts returns yod***@*** (preserves the first 3 chars of the local-part for human debugging without exposing the full address). Twelve call sites of logs in routes auth/signup, auth/signup/verify, auth/signup/abandon, admin/beta-invites, admin/beta-invites/[id] were migrated to the convention maskedEmail: maskEmail(email). Ad-hoc pattern that existed in vault/account and lib/lgpd-cleanup was consolidated to use the helper. Defense in depth against the log-leak scenario (Sentry hack, stolen backup, misconfigured syslog).

3.2.14 Atomic compare-and-swap on the WebAuthn counter (v1.9.0). The monotonic counter of WebAuthn signatures in signing/sign gained defense in depth. Previously the counter was read outside the prisma.$transaction and updated inside, opening a theoretical window in which two concurrent transactions (with distinct valid Redis challenges) could both read the same stale value. Now the update uses tx.signingIdentity.updateMany({where: {id, webauthnSignCounter: oldValue}, ...}): if two transactions try to advance simultaneously, the second detects count !== 1 and triggers throw new Error('counter_race') which aborts everything (audit log + signature event rollback). The client receives 409 Conflict and replays the flow with /sign/init + a fresh assertion. The primary layer (atomic GETDEL of the challenge in Redis) is preserved.

3.2.8 Operational vulnerabilities under active remediation. Items of operational posture (not cryptographic architecture) identified during ongoing internal review, with planned fixes. Listed here for transparency:

  • Plaintext admin cron secret in the crontab of one application host. Privileged local access would allow triggering cleanup/notification endpoints outside the expected window. This does not leak vault material (OPAQUE/MPC keys remain isolated). Rollout fix: cron-call.sh wrapper reading the secret from stdin (same pattern already in use on another host).
  • CI runner with excessive privileges: the self-hosted Git runner runs as root with Docker socket access. Risk: a malicious workflow could escape the container and read host files (the host does NOT carry vault master secrets, but does carry observability credentials and an auxiliary backup key). Planned fix: isolate the runner to a dedicated VM.
  • Auxiliary backup with co-located key: the encryption private key for the hourly DB snapshots of the vault sits on the same host as the encrypted snapshots. Compromising that host would allow decrypting backup history (OPAQUE envelopes, wrappedDeks ciphertext; note that to actually open the vault, an attacker would still need the user's export_key derived from the master password). Planned fix: migrate key custody to remote OpenBao, keeping the rsync replication but with the key off the origin host.
  • Postgres mTLS with distribution-default server cert (not the TAIVA CA): pg_hba.conf requires cert clientcert=verify-full and clients must pin the TAIVA CA, but the server presents the Debian default cert. Under client connections using sslmode=require (without verify-ca), the effect is TLS-with-encryption but without strong server authentication. Planned fix: issue server cert from TAIVA CA and update clients to sslmode=verify-full.
  • SSO bypass via inline secrets: the internal observability SSO portal (Authelia, internal scope only, does not touch vault) loads HMAC/encryption/JWT secrets inline in the config file. Risk constrained to internal SSO users. Planned fix: move to segregated environment variables.
  • Missing systemd hardening on some services: the vault web service and auth-gateway lack NoNewPrivileges/ProtectSystem declarations; one of the MPC nodes has a NoNewPrivileges=no regression. Planned fix: unified hardening drop-in applied to every TAIVA service.

None of these items allow reading user data without also possessing the user's master password. They are surface/blast-radius increases under partial-compromise scenarios, not breaks of the zero-knowledge model. Full details (CVSS, file:line, fix steps, regression tests) are kept internal to reduce the disclosure-to-fix window.

Current status: 3 of the 4 CRITICAL items above were closed. Cron secret was rotated and moved to a 400-mode file. Internal SSO was migrated to segregated environment variables. Postgres mTLS was confirmed to serve the TAIVA CA cert (the inline config in postgresql.conf was fixed; a drop-in conf.d/01-taiva-hardening.conf already overrode it correctly before, so the real impact was lower than initially classified). Only CI runner isolation (Forgejo) remains pending, planned for a calm maintenance window.

3.3 OPAQUE end-to-end audit verdict (2026-05-19)

Full trace of the OPAQUE flow was re-verified with literal file:line proof at every browser↔server hop:

GuaranteeVerification
Server never receives the master passwordZod schemas on routes /api/auth/opaque/login/{init,finalize} and /api/auth/opaque/register/{init,finalize} do not accept a password field. lib/opaque-server.ts:54-80 only wraps OpaqueServer.authInit(ke1, record, credentialId) and authFinish(ke3, expected) — both receive blinded OPAQUE messages, never the password.
Server never derives the export_key@cloudflare/opaque-ts v0.7.5 returns only {sessionKey} on server-side authFinish. The export_key is derived exclusively in the browser from (password, record, server_seed).
Server cannot decrypt the wrappedDek aloneUnwrap requires hybridKEK = HKDF(export_key ‖ ss). The server holds ss (decapsulated from mlKemCt) but not export_key.
Database leak (cold extract) does not open the vaultEnvelopes are OPAQUE OPRF-bound to a server seed; wrappedDek requires export_key; mlKemCt requires server sk + export_key.
Simultaneous DB + Redis leak (even during OTP window) does not openPost v1.8.1 (§3.2.3), Redis during OTP contains only sessionKey + metadata. share2 and pqSharedSecret are derived only in /verify-otp upon correct OTP. wrappedDek remains in DB but requires export_key (client-side).
Shamir 2-of-3 is strictlib/mpc-client.ts:418 defines splitSecretShamir(share2, {shares:3, threshold:2}) on write; reconstruction requires ≥ 2 OK nodes.
OTP atomic, single-use, anti-replaylib/opaque-otp-challenge.ts:93 uses an atomic Lua script (verifyOtpAtomic) with GETDEL, timingSafeEqual, maxAttempts=5, TTL 5 min.
Session cookie issued only after PAKE OK + OTP OKapp/api/auth/opaque/login/verify-otp/route.ts:118 is the only point that issues __Host-taiva-vault-token.
Sentry scrub covers all sensitive fieldssentry-scrub.ts:31 SENSITIVE_KEYS regex covers share, wrappedDek, dek, seed, nonce, opaque_envelope, among others; SENSITIVE_PATHS zeroes the body on /api/auth/*.
Logger redact covers OPAQUE material (v1.6.3)lib/logger.ts:14 — 26 paths including share2, wrappedDek, sessionKey, exportKey, pqSharedSecret, hybridKEK, ke1-3.
Logs do not leak material in OPAQUE routesVerified by grep: no sessionLog.X({material}) in any /app/api/auth/opaque/* route.
Recovery key generated in the browser (deployed)Confirmed by grep: 0 active imports of legacy lib/recovery-key.ts; real consumers use lib/recovery-key-client.ts.

Recommended public positioning: TAIVA Vault implements runtime zero-knowledge, meaning the server cannot decrypt the user's vault alone, not even under a full database leak. The master password never reaches TAIVA servers. The inherent limitations of the web-vault model (server ships the JavaScript, 5-min window of auxiliary material in Redis during OTP, plaintext navigation metadata for usability) are honestly documented in §3.2.1-3.2.9 above.


4. Key Architecture

4.1. Hierarchy

`` Master password (device-only) │ │ OPAQUE-3DH (RFC 9497) — client sends OPRF-blinded messages │ server: server.authInit(ke1, stored_record, credentialId) │ client: {ke3, session_key, export_key} = authFinish(ke2) ▼ export_key (32 bytes, born and dies in the browser) │ │ HKDF-SHA-256 mixed with ML-KEM shared secret (see 4.2) ▼ hybridKEK (32 bytes) │ │ AES-256-GCM unwrap(opaqueWrappedDek) ▼ share1 ────┐ │ XOR share2 ────┘ (reconstructed via Shamir 2-of-3 across 3 independent MPC nodes) │ ▼ DEK (32 bytes) │ │ AES-256-GCM ▼ Vault items (credentials, notes, TOTP) ``

4.2. Post-quantum hybrid wrap (ML-KEM-1024)

The server holds a long-term ML-KEM-1024 keypair; its 64-byte seed lives in a segregated secrets store (OpenBao HSM-light, see §8.4). The 1568-byte public key is exposed via 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: persists {opaqueWrappedDek, mlKemCt, opaqueShare2} ``

Login: `` server: ss = ML_KEM_1024.decapsulate(mlKemCt, server_sk) server → client: returns ss in the /opaque/login/finalize response (over PQ-hybrid TLS) client: hybridKEK = HKDF(export_key ‖ ss, ...) client: share1 = AES-GCM-unwrap(opaqueWrappedDek, hybridKEK) ``

pqServerKeyVersion is recorded per envelope, enabling keypair rotation without invalidating existing enrolls (the client keeps decapsulating against the matching server_sk).

4.3. Storage Layout

WhereWhatEncrypted with
Browser (memory)DEK as non-extractable CryptoKey– (in browser C++ memory)
Browser (extension)DEK persisted in storage.session (hex extractable, in-memory only)
Server SQLite (application host)opaqueEnvelope{Primary,Backup,Recovery}, opaqueWrappedDek*, mlKemCt*, pqServerKeyVersionlibsql AES-256-GCM (SQLITE_ENCRYPTION_KEY)
MPC node 1Shamir sub-share 1 of share2dedicated per-node encryption key
MPC node 2Shamir sub-share 2 of share2dedicated per-node encryption key
MPC node 3Shamir sub-share 3 of share2dedicated per-node encryption key
OpenBao (segregated host)Operational master secrets (OPAQUE/ML-KEM seeds, SQLITE_ENCRYPTION_KEY, bridge secrets…)Shamir unseal + AppRole 24h

Principle: no single layer alone contains sufficient material to reconstruct the DEK.


5. Critical Flows

5.1. Register (signup)

1. Browser: user types master password. 2. Browser: client.registerInit(password)RegistrationRequest (OPRF-blinded). 3. POST /api/auth/opaque/register/init with {credentialId: "<userId>:primary", request}. 4. Server: applies OPRF with the static seed + AKE keypair, returns RegistrationResponse. 5. Browser: client.registerFinish(response){record, export_key}. export_key is deterministic per (password, credentialId, server_seed) and never leaves the browser. 6. Browser: generates random 32-byte DEK + share1 = random_bytes(32). Computes 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 with {credentialId, record, opaqueWrappedDek, opaqueShare2, mlKemCt}. 9. Server (atomic): calls mpcStoreShareV3Opaque(userId, path, share2) which runs Shamir 2-of-3 and fans out 1 sub-share per node (3 independent MPC nodes) under namespace opq-<userId>-<path>, mTLS + HMAC; rejects if fewer than 2 nodes respond OK. On success, persists opaqueEnvelope*, opaqueWrappedDek*, mlKemCt* in SQLite, marks opaqueMigratedAt. 10. Server: on DB failure after MPC, triggers best-effort mpcWipeShareV3Opaque cleanup.

The three envelopes (primary, backup, recovery) are enrolled independently. credentialId = "<userId>:<path>" ensures domain separation: any cross-path unwrap attempt fails with OperationError from Web Crypto.

5.2. Login

1. Browser: user types master password. 2. Browser: client.authInit(password)KE1. 3. POST /api/auth/opaque/login/init with {credentialId, ke1}. 4. Server: server.authInit(ke1, storedRecord, credentialId){KE2, ExpectedAuthResult}. Stashes expected in Redis with a 60s TTL, returns {ke2, expectedId}. 5. Browser: client.authFinish(ke2){KE3, session_key, export_key}. A failure here means wrong password (MAC mismatch — server never knew). 6. POST /api/auth/opaque/login/finalize with {expectedId, ke3}. 7. Server: consumeExpected(expectedId) (atomic GETDEL anti-replay) → server.authFinish(ke3, expected)session_key. Decapsulates mlKemCt with server_skss. Sends OTP via email. Returns {challengeId, mlKemSS: ss}. 8. Browser: hybridKEK = HKDF(export_key ‖ ss). share1 = AES-GCM-unwrap(opaqueWrappedDek, hybridKEK). 9. POST /api/auth/opaque/login/verify-otp with {challengeId, otp}. Server verifies OTP (≤ 5 attempts), in parallel issues GET to the 3 mpc-nodes under namespace opq-<userId>-<path>. Accepts the first 2+ OK sub-shares, Shamir.combineshare2. Returns {sessionToken, share2} + cookie __Host-taiva-vault-token. 10. Browser: DEK = share1 XOR share2. Imports as a non-extractable CryptoKey.

The server has never held: the password, the export_key, share1 in the clear, or the reconstructed DEK.

5.3. Recovery (3 independent paths)

Each path has its own independent wrap material:

  • Primary (opaqueEnvelopePrimary + master password)
  • Backup (opaqueEnvelopeBackup + backup token)
  • Recovery (opaqueEnvelopeRecovery + recovery key in format TVAU-XXXX-XXXX-...-XXXX, 13 groups × 4 Crockford chars, ~78 bits of entropy)

Losing the device plus the master password does not mean total loss: the recovery key (printed offline by the user at signup) reconstructs the DEK. Logins via backup or recovery open a session with a mustResetTokens flag that blocks vault access until POST /api/auth/reset-tokens is called.


6. User Enumeration Defense

OPAQUE is, by construction, enumeration-resistant: server.authInit returns a valid-looking KE2 even for a non-existent credentialId (OPRF blinding produces indistinguishable output). To reinforce the defense against timing channels:

1. Dummy material (fictitious envelope + AKE keypair) is cached after the first call, ensuring cold-start does not leak. 2. A 120ms pad (lib/pad-response.ts) is applied to /opaque/login/init, /opaque/login/finalize and /opaque/login/verify-otp. 3. The vitest suite tests/timing/ validates via Welch's t-test in CI. A CRIT/HIGH bug is raised if mean Δ > 5ms or p-value < 0.05.


7. Tamper-Evident Audit Chain

7.1. SHA-256 Merkle Chain

Each user operation (CREATE/UPDATE/DELETE of a credential/note/TOTP) generates an entry:

`` entryHash = SHA-256(prevHash || userId || action || detail || timestamp) ``

prevHash = entryHash of the previous entry for the same user. The client reproduces the hashing via Web Crypto and detects MODIFY/DELETE/INSERT/REORDER in O(1) verification per delta.

Genesis (first entry): prevHash = SHA-256("taiva-vault-audit-genesis-v1" || userId).

7.2. Bitcoin OpenTimestamps Anchor

Daily cron (03:30 UTC): 1. Computes the Merkle root of each active user's chain. 2. Submits to 3 public calendars (alice, bob, finney). 3. Stores .ots proofs per calendar as JSON in AuditChainAnchor.proofs. 4. A retry cron polls ~24-48h later to record bitcoinBlock.

Independent verification: GET /api/vault/audit/proof?anchorId=X returns the .ots blob, and ots verify <proof.ots> (offline CLI from opentimestamps-client) confirms it against the public Bitcoin chain. The server cannot "rewind" without contradicting the blockchain.

7.3. Append-only audit in OpenBao

Cross-link OpenBao ↔ vault chain: a trigger pushes window hashes of the OpenBao log into ExternalAuditAnchor, and the vault chain HEAD is mirrored into the OpenBao KV. Any discrepancy between sides exposes tampering.

7.4. Advanced Electronic Signature (AEAd)

Added 2026-05-21 as an opt-in module (SIGNING_ENABLED). Allows users to sign PDF documents with legal validity under Brazilian Federal Law 14.063/2020 Art. 4º II (Advanced Electronic Signature) — covers electronic medical records, attestations, reports and non-controlled prescriptions per CFM Resolution 2299/2021. Does NOT cover controlled prescriptions (ANVISA RDC 471/2021), notarial acts or federal court PJe systems that require ICP-Brasil qualified certificates — those cases remain outside TAIVA Vault's scope.

Signing key custody (end-to-end zero-knowledge):

1. ECDSA P-256 keypair is generated and exported inside the browser via WebCrypto. 2. The 32-byte private scalar d is XOR-split into share1 + share2. share2 (32 B) is distributed across Alpha+Beta+Gama via Shamir 2-of-3 under the OPAQUE namespace aead. share1 is encrypted with signing_kek before being uploaded. 3. signing_kek = HKDF-SHA256(ikm = export_key‖prf_output, salt = "TAIVA-AEAd-signing-v1", info = identityId, L = 32), where export_key is re-derived on demand via OPAQUE PAKE (not cached) and prf_output is the WebAuthn PRF extension evaluated against a random per-identity prfSalt persisted server-side. 4. Encryption: wrapped_share1 = AES-256-GCM(share1, signing_kek) with layout iv(12)‖tag(16)‖ct(32) = 60 B total. 5. The public key (JWK) is stored in clear in SigningIdentity.pubkeyJwk — public information by definition.

Implication: compromising the TAIVA database alone does NOT decrypt the signing key (requires share2 from the MPC). Compromising 1 additional MPC node is still insufficient (Shamir 2-of-3 threshold). Even the extreme scenario of 2 MPC nodes + database + the user's phished password fails without the prf_output, which requires physical presence of the authenticator (TouchID/FaceID/YubiKey firmware 5.4+).

Sign ceremony (summary):

1. The client POSTs to /api/signing/sign/init with identityId + documentId. The server validates ownership, retrieves share2 from the MPC, generates a WebAuthn challenge cryptographically bound to (userId, identityId, docHash, nonce16), and returns the bundle of material plus the canonical SHA-256 hash of the document (docHashHex). 2. The client prompts the user for their vault password, derives export_key via OPAQUE in a single round-trip to the server (the lightweight /api/auth/opaque/derive-export-key endpoint), and runs the WebAuthn .get() ceremony with prf.eval(prfSalt) and the challenge from step 1 — the authenticator returns prf_output and a signed assertion. 3. The client reconstructs signing_kek, unwraps share1, computes d = share1 XOR share2, imports the result as a CryptoKey with extractable:false and key_ops:['sign'], and produces the ECDSA-SHA256 signature (64 B raw r‖s). All intermediate variables are zeroed. 4. The client POSTs to /api/signing/sign with signatureValue + webauthnAssertion. The server (a) consumes the challenge atomically via Redis GETDEL, (b) cryptographically verifies the WebAuthn assertion via @simplewebauthn/server (including challenge match, origin, RP-ID and monotonic counter), (c) verifies the ECDSA signature against the stored pubkeyJwk, (d) inserts AuditLog{action:'signing.signature_issued', detail: structured JSON} + SignatureEvent with a 1:1 FK in a single atomic transaction, and (e) returns a TAIVA-AEAd-v1 envelope. 5. The next Bitcoin OTS anchor cycle (daily cron, 03:07 UTC) stamps the chain entry, populating SignatureEvent.btcOtsProofId. From that moment on the signature carries a publicly verifiable temporal proof that can be checked offline via ots verify.

Anti-replay defenses:

  • Single-use challenge with 5-minute TTL, consumed by atomic GETDEL.
  • Re-submitting the same assertion fails on both (a) the consumed challenge and (b) the WebAuthn counter monotonicity check enforced by the server.
  • SignatureEvent.signatureHash is indexed: any attempt to re-submit a byte-identical signature is detectable.

Independent verification through POST /api/signing/verify (publicly accessible, no auth required) returns 5 individual checks: 1. doc_hash_match: the re-computed SHA-256 matches envelope.doc.hash_sha256. 2. signature_valid: ECDSA P-256 verify passes against envelope.signer.pubkey_jwk. 3. audit_chain_present: an AuditLog exists with id = envelope.evidence.audit_entry_id, action = 'signing.signature_issued', and userId matches the envelope signer (added in Sprint 6c after security review). 4. audit_chain_hash_match: the stored AuditLog.entryHash matches envelope.evidence.audit_chain_entry_hash. 5. btc_anchored: AuditChainAnchor.status = 'confirmed' for envelope.evidence.btc_ots_proof_id.

valid = doc_hash_match && signature_valid (the cryptographic minimum); the other 3 checks are auxiliary evidence of the audit-chain and temporal anchoring.

Acknowledged limitations:

  • Controlled prescriptions (psychoactive drugs, opioids under ANVISA RDC 471/2021): AEAd does NOT replace qualified ICP-Brasil signing. A physician needs an A1 ICP-Brasil certificate for those cases. A future co-existing path can be added without affecting AEAd.
  • Notarial acts (real-estate deeds, formal registrations): require ICP-Brasil qualified signatures by force of MP 2.200-2/2001 §1º. Outside TAIVA Vault's scope.
  • LGPD-mortal evidence chain: an Art. 18 VI deletion request cascades to the user's SignatureEvent + AuditLog rows. The signature remains cryptographically verifiable through the previously exported envelope JSON, but audit_chain_present will return false post-deletion. The downloadable comprovante PDF (issued at signing time) is the durable artifact for third parties.
  • Bundle attestation requires the extension: the hardened CSP applied in production at /assinar/* covers eval/wasm-eval, but a supply-chain attack via a malicious Next.js build dependency is only detectable client-side via cosign verification of /.well-known/taiva-bundle-manifest.json, implemented in the TAIVA Chrome extension (the same extension that already validates the vault). Users without the extension still get CSP-only protection, which is sufficient against XSS but not against a malicious dependency that survives the build pipeline.

7.5. Public /status page (v1.9.0)

The page https://vault.taiva.com.br/status is a public operational snapshot, refreshed every 60 seconds via ISR, with no authentication required. It lets third parties (lawyers, LGPD auditors, researchers, journalists) confirm the health of TAIVA's critical components without needing credentials or internal access.

Exposed signals:

  • Production app version (extracted from package.json at build time, matches metadata.component.version of the published SBOM).
  • Aggregate MPC mesh state (labels "operational", "recovering", "degraded" or "not configured" via getMpcCircuitState()).
  • OpenBao seal status (boolean "unsealed"/"sealed" only, via an internal fetch to 127.0.0.1:8200/v1/sys/health with 1.5s timeout and graceful failure).
  • TLS cert days until expiry (direct handshake via tls.connect with proper servername, reads valid_to from the peer certificate, computes the days remaining).
  • Audit chain Merkle: total events + relative time of the last entry.
  • Bitcoin OTS: total anchors, confirmed, pending, and the last confirmed anchor with a clickable link to mempool.space/block/<N>.
  • Last 5 entries of the global audit chain, anonymized: only action (human label via describeAction()) plus relative time. No userId, no details, no IP.
  • 4 public attestations linked: /.well-known/jwks.json, taiva-bundle-manifest.json, sbom.json, cbom.json.

Explicit anti-OSINT defense: the page footer declares what is NOT exposed: VPS IPs or hostnames, internal latencies, per-node user counts, seal details beyond the boolean. The 60-second ISR limits the observable temporal granularity; the OpenBao fetch is loopback (not cross-host), shrinking side-channel surface; the audit history is anonymous by construction (no reversible mapping).

The /status page materializes the project's "trustless auditable" positioning: anyone, with no login, no privileged request, no need to trust TAIVA's word, can verify that the critical components are in the declared state.


8. Infrastructure

8.1. Topology

FunctionRoleGeneric stack
Auth + API + MPC node A hostauth-gateway, api-core, first MPC node, OpenBao warm-standbyNode LTS, modern reverse proxy, Postgres, Redis
MPC node B hostadditional MPC node in an independent jurisdictionNode LTS, modern reverse proxy, Postgres
Segregated secrets hostOpenBao master (HSM-light) + additional MPC node + Postgres audit_db (mTLS)Node LTS, recent Postgres, recent OpenBao
Observability hostSIEM, central auditd, internal-scope SSOopen-source SIEM/IDS standard
PWA application hostvault Next.js, DMS worker, OpenBao warm-standby, auxiliary agentsNode LTS, recent Next.js, modern reverse proxy, Redis, encrypted SQLite
CI + observability hostSelf-hosted Forgejo, observability (Grafana/Loki/Prometheus), backup hub(Docker)

Hosts run on three distinct VPS providers across Brazil + Europe. Specific providers, internal network addresses, internal codenames and service ports are not published. The OpenBao on the segregated host is the canonical source of master secrets since Phase 3 (2026-05-17); other instances are warm-standby for emergency.

8.2. Inter-host

  • Self-hosted private mesh with restrictive ACL.
  • mTLS vault ↔ OpenBao and vault ↔ mpc-nodes. Internal CA with multi-year validity.
  • Default mTLS is opt-OUT: any MPC_USE_MTLS value other than "false" enables it; nodes reject plain HTTP.

8.3. Post-quantum edge TLS

EdgeSoftwarePQ-hybrid curve
vault.taiva.com.brrecent CaddyX25519MLKEM768 (explicit)
taiva.com.br + wwwrecent Traefik (Go 1.24+)X25519MLKEM768 (Go default)
Public auth/API endpointsrecent CaddyX25519MLKEM768 (Go default)

Clients that do not support the hybrid curve fall back to classical X25519 (backward compatible). Exact edge versions are not published, to reduce fingerprinting surface.

8.4. Master secrets (OpenBao)

  • OpenBao on a host segregated from application hosts, disk-backed Raft storage, mandatory mTLS.
  • Shamir 3-of-5 unseal (shards distributed across independent physical locations).
  • AppRole tokens with 24h TTL and 6h rotation via systemd timer.
  • Stores: OPAQUE OPRF/AKE seeds, ML-KEM-1024 seed, SQLite encryption key, bridge HMAC secrets, and other operational secrets.
  • ACL policies restrict reads per service (least-privilege principle); each service uses a separate AppRole with restricted path scope.
  • File audit device (mode 700, isolated from application service accounts).
  • .env runtime fallback permitted only for the non-crypto subset (config strings) when OPENBAO_FALLBACK_OK=1. Critical crypto material (OPAQUE/ML-KEM seeds) has NO fallback and the service refuses to boot if absent.
  • Secondary instances act as warm-standby (sealed by default; used only in DR).

8.5. Backup

  • Encrypted DB snapshot (VACUUM INTO → AES-256-CBC PBKDF2 600k iters → rsync SSH) replicated cross-host over the private Tailscale mesh, daily, to the offsite Sigma host.
  • DR restore validated (md5 INTEGRITY OK).
  • Additional off-site B2 Backblaze pending (roadmap Q3 2026).

9. Documented Architectural Decisions (ADRs)

ADRTopicStatus
0001Zero-knowledge strictAccepted
0002OPAQUE for authentication (vs SRP)SUPERSEDED by 0008 (was a fictitious roadmap)
0003MPC Shamir 2-of-2 (historical design)SUPERSEDED by 0007
0004Hybrid KEK (classical + PQ)Accepted (final form in 0008 + 2H)
0005OpenBao HSM-light on a segregated hostAccepted
0006God-objects split planPartial done
0007MPC 2-of-3 + inter-node mTLS (Sprint E)Accepted
0008Real OPAQUE-3DH via @cloudflare/opaque-tsAccepted

ADRs versioned in docs/adr/.


10. Acknowledged Trade-offs

10.1. In Favor of UX vs. Strict Security

  • 7-day auto-lock: the production value is AUTO_LOCK_MS=604800000 (7 days) on the server session. Strong, conscious trade-off: the product is used as a second factor by many users and a daily re-login was deemed too costly for usability. This means that, on a trusted device, the DEK may remain loaded in browser memory for up to 7 days after the last unlock. Mitigations in layers: non-extractable CryptoKey (prevents exfil via common JS), wipe on lock event, panic wipe via UI, and Cache-Control: no-store on every response (§3.2.7). Users in shared environments should reduce that value or use panic wipe when ending a session.
  • `'unsafe-eval'` and `'wasm-unsafe-eval'` in CSP: unsafe-eval is required by the Prisma client (uses new Function() internally); wasm-unsafe-eval is required by WASM modules. Refactoring to remove unsafe-eval is tracked as deferred.
  • `'unsafe-inline'` in `style-src`: the extension popup uses el.style.display='none' dynamically. Refactoring inline styles to CSS classes is tracked as tech debt.
  • Plaintext metadata (§3.2.1): URLs, usernames and TOTP issuers remain readable by the server to allow search and travel-mode. Explicit usability trade-off.

10.2. Still Being Improved

  • Off-site backup B2 (current: cross-host replication but still inside the same set of providers).
  • Real HSM (current: OpenBao Shamir is HSM-light). Plan Q4 2026.
  • Formal external audit (Trail of Bits / Cure53 / similar). Deferred until revenue allows — no promised timeline.
  • Phases 2-5 god-objects split (ADR-0006).
  • Remaining OPAQUE rollout sub-stages: 2E (extended whitepaper with formal attack models), 2F (full OPAQUE e2e suite), 2G (merge of feature/opaque-pake into master).

11. How to Audit Yourself

1. Signed releases: each published release is signed with Cosign + Rekor transparency log. cosign verify --key cosign.pub proves the binary matches the published release; the public key (cosign.pub, SHA-256 cb2b3bdac4c48106b200d728ffc8b22401a292ba1d16b0073f77f0d1331d2bc9) is distributed in the repository itself. 2. Bitcoin audit chain: download the .ots proof via GET /api/vault/audit/proof?anchorId=X and run ots verify locally. This proves the server has not rewritten your history. 3. Post-quantum TLS: openssl s_client -connect vault.taiva.com.br:443 -groups X25519MLKEM768 (or a Go 1.24+ PQ-only probe) confirms the negotiated hybrid curve. 4. CSP in production: curl -I https://vault.taiva.com.br/login shows the active Content-Security-Policy. 5. Report vulnerabilities: contato@taiva.com.br with the prefix [SECURITY]. Safe harbor for good-faith research, embargo until fix in production, naming in the changelog if desired. Policy at /disclosure.


12. Changes vs. Whitepaper v1.5 (2026-05-18)

  • OPAQUE-3DH became the only auth path: Sub-stages 2C (legacy libs removed) and 2D (13 legacy columns dropped from User) finalized the cutover. No more "ZK-named legacy".
  • ML-KEM-768 → ML-KEM-1024: Sub-stage 2H (deploy 2026-05-19) switched the post-quantum KEM to FIPS 203 category 5, with a long-term server keypair in OpenBao.
  • Hybrid PQ TLS X25519MLKEM768 in production across all edges. Sub-stage 2I.
  • Argon2id removed from the active crypto path (was used in the legacy wrap; the export_key KDF is now internal to OPAQUE).
  • MPC 2-of-3 active (CURRENT_KEY_SHARE_VERSION=3), opq-<userId>-<path> namespace separate from any legacy MPC entries.
  • Default opt-OUT inter-node mTLS (plain HTTP rejected).
  • OpenBao as HSM-light with Shamir unseal.

Last updated: 2026-05-29. v1.10.x deployed: Phase 2 multi-tenant + CRM Onda 1 + Sprint D+E public /sign + contracting profile + F32 soft-delete + 2 audit hardening rounds + workspace-cleanup cron + free tier (ADR-0024) + inline OPAQUE step-up re-auth.

Next scheduled review: immediate if any critical primitive changes; otherwise with each major release.