Encrypted recovery file: independent security review

Review date: 2026-10-11. Reviewer: delegated implementation-independent security audit agent. Human release reviewer: ___ Date: ___ Outcome: ___. This is scoped engineering evidence, not cryptographic certification or production approval.

Status: core and browser integration source review completed; independent real-Chrome adversarial tests passed. The actual production gates in security and DEVLOG remain applicable.

Scope and threat model

Reviewed the v1 contract, packages/recovery/index.mjs, packages/recovery/normalization.mjs, scripts/recovery-kdf-worker.mjs, packages/runtime/http.mjs, and the existing identity recovery/resume transaction. Also reviewed apps/web/src/providers/recovery-file.mjs, the browser private submission vault, composition dispatch, recovery Actions, question rendering, fresh document navigation, and signup confirmation.

The feature protects an independently random recovery credential with a single key derived from all selected answers. An attacker with the envelope has an unlimited offline guessing oracle through its GCM tag. Birthdays, IDs, phone numbers, short PINs, and correlated biographical facts may be known. Argon2 increases guessing cost; it cannot make these facts secret. One question is permitted, so low-entropy configurations are a deliberate product tradeoff. The recommendation for an additional private passphrase and a readable-metadata warning must remain visible in setup and restore.

Prioritized findings

No unresolved critical/high-severity implementation defect was demonstrated in the reviewed core and browser integration. This is limited evidence about the exercised code, not a guarantee against undiscovered defects. The remaining release requirements and trust boundaries below remain open.

Release evidence still required: supported low-memory/mobile browser performance, supported Unicode repertoire, human comprehension of offline guessing and public hints, and independent specialist cryptographic review remain unverified. These are documented release requirements, not findings that the current mock/unit evidence can close.

Portability limitation: v1 intentionally uses native JavaScript normalization and lowercasing. Newly assigned Unicode characters may behave differently across engine Unicode data versions. Existing v1 files must retain their named algorithm; silently changing it would lock users out. The spec accurately distinguishes lowercasing from full case folding and does not claim universal cross-version equivalence.

Metadata/phishing boundary: prompts and hints are readable and unauthenticated until successful decryption. GCM authentication prevents successful recovery after unauthorized metadata changes; it cannot authenticate the text before a user types answers. Uploaded text must remain inert, and the app must never navigate or send credentials to file-selected issuers. The independent browser test verifies inert HTML/link-shaped metadata and issuer rejection before any recovery API request.

Document isolation boundary: fresh first-party recovery documents with restrictive CSP can avoid loading Turnstile beside the answer fields. They do not sandbox an already malicious script previously granted the same origin's authority: retained same-origin windows, pending setup authority, and origin storage remain relevant. Describe this as a recovery document that does not load third-party scripts, not protection against prior origin compromise.

Reproducible evidence

Run:

node --test tests/recovery-audit.test.mjs tests/identity.test.mjs

Observed result: 25 passed, 0 failed. Four audit tests use the installed real hash-wasm Argon2id, not a substitute derivation function. The independent AES check decrypts the implementation's deterministic envelope through Node's createDecipheriv('aes-256-gcm', ...) with an explicitly constructed expected answer tuple and AAD. Its fixed salt/IV are test fixtures only.

The implementation owner's final core suite and audit suite were also run together with node --test tests/recovery-file.test.mjs tests/recovery-audit.test.mjs: 14 passed, 0 failed. After review of the setup hint guard, the owner suite additionally checks exact/type-normalized public hint equality rejection before derivation (without changing restore compatibility), the archived real Argon2id key/ciphertext vector in packages/recovery/vectors.mjs, Gregorian/Unicode normalization, binary-buffer cleanup, and remaining envelope tampering combinations. The canonical password encoding has a separate 32 KiB cap before worker invocation to bound JSON escaping overhead.

Core review also confirms exact-schema bounded payload validation after GCM authentication, generic authentication failure for validly formatted wrong answers/tampering, private nonextractable imported AES keys, best-effort wiping of byte buffers, and no alternate weaker key slots or per-answer correctness hashes. Invalid answer syntax can fail type validation before derivation; this does not expose a stored per-answer verifier. Garbage-collected strings cannot be reliably wiped.

The coordinator reports a separate real-Chrome test at tests/e2e/recovery-crypto.test.mjs: Node/browser interoperability and tampered-hint failure with the actual dedicated worker under the actual isolated-route CSP. That test intentionally bypasses the portal UI and does not establish complete signup/recovery browser behavior. Its result must remain distinct from full-flow evidence.

Browser integration evidence

With the assembled assets and local runtime listening at http://localhost:8787, run:

node --test tests/e2e/recovery-security.test.mjs

Observed final result: 2 passed, 0 failed, using actual Chrome, the current UI, real Argon2id worker, and non-live encrypted fixtures. The first sandbox launch failed with Chrome SIGABRT; the authorized run outside that sandbox completed. No production deployment, real account takeover, or live CAPTCHA verification was performed.

The first independent test imports a valid encrypted envelope whose public prompt resembles an HTML image with an event handler and whose hint resembles an external URL. Both remain text: no injected element, navigation, or related network request occurs. An incorrect answer produces the generic failure and clears the answer field. A correct answer decrypts a file for a different issuer, then fails the provider check before any /api/auth/recover request. The fixture answer and token appear in neither observed network requests, browser storage, nor serialized native intent details. The local recovery document contains no Turnstile script. Production route exclusion is additionally supported by source review; these local tests do not verify real CAPTCHA issuance or expiry.

The second test submits twice and navigates at the actual worker creation boundary. Exactly one native worker is created and one explicit termination is observed, with no recovery API call. Test instrumentation wraps the native Worker; it does not replace its cryptography. A test-only numeric sessionStorage counter observes termination across document unload, because DevTools can discard unload console events. That counter contains no application data. The initial console-only check was inconclusive and is not treated as proof of a surviving browser worker.

During review the UI owner hardened pagehide by calling the captured recovery cancel function synchronously before the engine's asynchronous provider teardown. Other reconciliations include restore normalization guidance and wording that describes testing the encrypted file/answers rather than claiming a downloaded file was reread.

Source review confirms:

The UI owner's successful end-to-end flow evidence is separate from this independent adversarial test. Full low-memory/mobile testing, live CAPTCHA handoff/expiry behavior, and human usability/security sign-off remain outstanding.