From 906d7b27b135745f73cb8766ca89813cb7ca88ef Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 4 Aug 2026 00:47:30 +0530 Subject: [PATCH] release: consolidate VeyraKey v0.10 --- PROJECT_CONTEXT.md | 8 +- README.md | 879 ++++-------------- .../extension/entrypoints/autofill.content.ts | 33 +- apps/extension/entrypoints/background.ts | 99 ++ apps/extension/src/autofill.test.ts | 61 ++ apps/extension/src/autofill.ts | 89 ++ apps/extension/src/googleDrive.test.ts | 13 + apps/extension/src/googleDrive.ts | 73 +- apps/extension/src/manifest.test.ts | 4 + apps/extension/src/manifest.ts | 9 +- apps/extension/src/privateEmail.test.ts | 99 ++ apps/extension/src/privateEmail.ts | 269 ++++++ apps/extension/src/session.ts | 1 + apps/web/src/App.test.tsx | 4 +- docs/17-constraints-and-non-goals.md | 6 +- docs/20-progress.md | 21 + docs/21-architecture-decisions.md | 24 + docs/22-research-sources.md | 11 + docs/26-security-invariants.md | 45 +- docs/36-enterprise-password-manager-status.md | 10 +- docs/39-private-email-and-passkey-boundary.md | 100 ++ .../src/archive.integration.test.ts | 53 ++ .../ui/src/VaultScreen.lifecycle.test.tsx | 4 +- packages/ui/src/VaultScreen.task3.test.tsx | 21 +- packages/ui/src/VaultScreen.test.tsx | 161 +++- packages/ui/src/VaultScreen.tsx | 864 ++++++++++++++--- packages/ui/src/styles.css | 130 +++ packages/vault/src/items.test.ts | 52 ++ packages/vault/src/items.ts | 103 +- packages/vault/src/service.ts | 83 ++ packages/vault/src/types.ts | 4 + release/release-manifest.json | 32 +- tooling/verify-artifacts.mjs | 9 +- 33 files changed, 2462 insertions(+), 912 deletions(-) create mode 100644 apps/extension/src/privateEmail.test.ts create mode 100644 apps/extension/src/privateEmail.ts create mode 100644 docs/39-private-email-and-passkey-boundary.md diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 90939f0..0a48c07 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -12,7 +12,7 @@ ## Mission -Build an industry-grade portfolio v1: a browser-first, zero-knowledge password manager with encrypted offline storage, user-controlled recovery, deterministic Google Drive synchronization, safe browser autofill, TOTP, encrypted backup/restore, and local password-health analysis. The application server must never possess keys that decrypt vault content. +Build an industry-grade portfolio v1: a browser-first, zero-knowledge password manager with encrypted offline storage, user-controlled recovery, deterministic Google Drive synchronization, safe browser autofill, TOTP, private-email aliases, encrypted backup/restore, and local password-health analysis. The application server must never possess keys that decrypt vault content. ## Non-negotiable invariants @@ -46,11 +46,13 @@ Build an industry-grade portfolio v1: a browser-first, zero-knowledge password m - Generic CSV and one Bitwarden-compatible importer. - Provider-independent encrypted archive backup and restore. - Local weak/reused/old password analysis and HIBP k-anonymous compromise checks. +- Signup-only private-email generation through plus addressing or a user-configured SimpleLogin/Addy.io account. Provider secrets remain encrypted vault content. +- Public references to passkeys held by platform authenticators, security keys, or external providers. TOTP seeds remain encrypted login data; passkey private keys are never imported into the WebExtension. - Whole-system hardening, accessibility, reproducible deployment, and a polished portfolio demonstration. ## Explicit non-goals for v1 -- WebDAV, multiple production providers, cards, identities, addresses, attachments, payment autofill, software passkeys, SSH keys, Secure Send, document intelligence, digital credentials, DigiLocker, iCloud, Safari, native desktop/mobile apps, native SSH agent, browser SSH terminal, persistent live shared vaults, emergency access, continuous paid breach monitoring, email aliases, arbitrary-PDF selective disclosure, and paid eSign generation. +- WebDAV, multiple production providers, attachments, software passkey private-key custody/signing, SSH keys, Secure Send, document intelligence, digital credentials, DigiLocker, iCloud, Safari, native desktop/mobile apps, native SSH agent, browser SSH terminal, persistent live shared vaults, emergency access, continuous paid breach monitoring, a project-operated email relay/domain, arbitrary-PDF selective disclosure, and paid eSign generation. ## Security model summary @@ -58,7 +60,7 @@ Task 3 persists independent random root, document, and credential keys, each wra ## Deferred expansion -The former document-wallet, digital-credential, DigiLocker, Secure Send, SSH, software-passkey, WebDAV, and broader item plans are preserved as future work, not v1 commitments. See [`docs/32-future-work.md`](docs/32-future-work.md). +The former document-wallet, digital-credential, DigiLocker, Secure Send, SSH, native passkey-provider, WebDAV, and broader item plans are preserved as future work, not v1 commitments. See [`docs/32-future-work.md`](docs/32-future-work.md). The browser alias and passkey-reference boundary is specified in [`docs/39-private-email-and-passkey-boundary.md`](docs/39-private-email-and-passkey-boundary.md). ## Working rules for future agents diff --git a/README.md b/README.md index 9de2086..0087d95 100644 --- a/README.md +++ b/README.md @@ -1,771 +1,256 @@ -# VeyraKey — Zero-Knowledge Password Manager - -> **End-to-end encrypted, browser-first password manager** — vault keys, master passwords, Recovery Kits, and OAuth tokens never leave your device. Google Drive and OneDrive store only authenticated ciphertext they cannot decrypt. Built with TypeScript, React, Argon2id, XChaCha20-Poly1305, WebAuthn PRF biometric unlock, and a Manifest V3 browser extension for Chrome and Firefox. - -⭐ **If this project is useful or impressive, please [star it](https://github.com/Iamsujithd/VeyraKey) — it helps others discover it!** +# VeyraKey
+ VeyraKey — a zero-knowledge password manager -VeyraKey – Zero-Knowledge Password Manager built with TypeScript, React, Argon2id, and WebAuthn - -

+

Your passwords. Your cloud. Your keys.

-[![Version](https://img.shields.io/badge/version-0.10.0-0ea5e9?style=for-the-badge)](CHANGELOG.md) -[![TypeScript](https://img.shields.io/badge/TypeScript-7.0-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Node](https://img.shields.io/badge/Node-24.11.0-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://nodejs.org/) -[![pnpm](https://img.shields.io/badge/pnpm-11.10.0-F69220?style=for-the-badge&logo=pnpm&logoColor=white)](https://pnpm.io/) -[![Tests](https://img.shields.io/badge/tests-146%20passing-22c55e?style=for-the-badge)](vitest.config.ts) -[![License](https://img.shields.io/badge/license-MIT-8b5cf6?style=for-the-badge)](LICENSE) -[![Manifest V3](https://img.shields.io/badge/Manifest-V3-ea580c?style=for-the-badge&logo=googlechrome&logoColor=white)](apps/extension) +

+ A browser-first, zero-knowledge password manager that encrypts vault data locally and can sync + authenticated ciphertext through the user's own Google Drive. +

-[🚀 Quick Start](#-quick-start) · [🏗 Architecture](#-system-architecture) · [🔐 Security Model](#-security-model) · [📦 Packages](#-monorepo-packages) · [🛠 Development](#-development) · [📚 Docs](#-documentation) + [![Release candidate](https://img.shields.io/badge/release%20candidate-v0.10.0-0A84FF?style=flat-square)](release/release-manifest.json) + [![TypeScript](https://img.shields.io/badge/TypeScript-7.0-3178C6?style=flat-square&logo=typescript&logoColor=white)](package.json) + [![Tests](https://img.shields.io/badge/tests-259%20passing-22C55E?style=flat-square)](#verification) + [![Chrome MV3](https://img.shields.io/badge/Chrome-MV3-4285F4?style=flat-square&logo=googlechrome&logoColor=white)](apps/extension) + [![Firefox MV3](https://img.shields.io/badge/Firefox-MV3-FF7139?style=flat-square&logo=firefoxbrowser&logoColor=white)](apps/extension) + [Quick start](#quick-start) · [Capabilities](#capabilities) · [Architecture](#architecture) · [Security](#security-model) · [Documentation](docs/README.md)
---- - -## ✨ Features at a Glance +> [!WARNING] +> VeyraKey is a portfolio-grade release candidate, not an independently audited password manager. +> Do not use pre-release builds for irreplaceable secrets. Public release still requires the external +> evidence listed in [`release/external-gates.json`](release/external-gates.json). -
-VeyraKey Feature Grid -
+## Why VeyraKey -
+Most password-manager demos stop at encrypted CRUD. VeyraKey treats the browser, cloud provider, +autofill surface, device unlock flow, revision history, and release pipeline as separate trust +boundaries. -| Category | Capability | +| Principle | What it means in practice | |---|---| -| 🔐 **Vault** | Logins · Secure Notes · Identity Profiles · Payment Cards | -| 🔑 **Unlock** | Master Password · Recovery Kit · WebAuthn PRF (Touch ID / Face ID) | -| ☁️ **Sync** | Google Drive `appDataFolder` · Microsoft OneDrive · Offline-first IndexedDB cache | -| 🌐 **Autofill** | Exact-origin matching · MV3 content script · Save/update prompts · TOTP fill | -| 🔒 **Crypto** | Argon2id · XChaCha20-Poly1305-IETF · HKDF-SHA-256 · Bech32m Recovery Kit | -| 📊 **Security** | Weak/reused/old password analysis · k-anonymous HIBP breach checks | -| 📥 **Import** | Generic CSV · Bitwarden-compatible · Dry-run preview · Atomic rollback | -| 💾 **Backup** | Encrypted portable archive with full revision history · Clean-profile restore | -| 🏷️ **Organize** | Tags · Favorites · Folders · Encrypted local full-text search | -| 🧪 **Quality** | 146 tests across 29 test files · Property-based · Integration · Chaos corpus | - ---- +| **Zero knowledge** | Master passwords, Recovery Kits, plaintext records, vault keys, and device PRF outputs stay in the client. | +| **Bring your own storage** | Google Drive stores encrypted vault state in its private app-data area; it cannot decrypt the payload. | +| **Local-first** | A vault can be used without a cloud account. Cloud connection is optional and reversible. | +| **Origin-bound autofill** | Credentials are released only to an exact saved origin after the required local authorization. | +| **Recoverable by design** | Encrypted backups and Recovery Kits exist; a server-side reset or escrow key does not. | -## 🏗 System Architecture +## Product preview
-System Architecture + VeyraKey capability overview
-
- -VeyraKey is a **pnpm monorepo** with strict package-boundary enforcement. Three application targets share a common set of cryptographic and vault packages: - -``` -User - ├─▶ apps/web React + Vite web application (full vault UI) - └─▶ apps/extension WXT Manifest V3 extension (Chromium + Firefox) - │ - ├─▶ packages/crypto Argon2id · XChaCha20 · WebAuthn PRF - ├─▶ packages/vault Key hierarchy · Item CRUD · Service layer - ├─▶ packages/sync HLC · Revision DAG · Deterministic merge - ├─▶ packages/security Autofill decisions · HIBP · TOTP · Generation - ├─▶ packages/persistence IndexedDB repositories (encrypted) - ├─▶ packages/ui Shared React vault screen and components - ├─▶ packages/import-export CSV + Bitwarden import/export - └─▶ packages/provider-drive Google Drive + OneDrive adapters - - ──▶ apps/api Hono on Cloudflare Workers (health endpoint only) -``` - -### Component Responsibilities - -| Component | Role | -|---|---| -| **Web App** | Full vault UI, OAuth BYOS setup, Google/OneDrive sync management | -| **Browser Extension** | Secure unlock sessions, autofill, save/update prompts, TOTP fill | -| **`@zk-wallet/crypto`** | All cryptographic primitives behind a project-owned interface | -| **`@zk-wallet/vault`** | Key slots, wrapped-key sets, item revisions, vault service | -| **`@zk-wallet/sync`** | Immutable revision DAG, HLC clocks, provider adapters, conflict resolution | -| **`@zk-wallet/security`** | Origin-safe autofill, credential capture, HIBP checks, TOTP, password generation | -| **`@zk-wallet/persistence`** | `IndexedDbVaultHeaderRepository`, `IndexedDbItemRevisionRepository` | -| **`@zk-wallet/ui`** | `VaultScreen.tsx` (133 KB) – all vault UI states in a single accessible component | -| **API Worker** | Minimal Hono Cloudflare Worker; exposes `/v1/health` only; no vault secrets | +## Capabilities ---- +### Passwords and autofill -## 📁 Monorepo Structure +- Encrypted logins, secure notes, identity profiles, and payment cards. +- Exact-origin username/password suggestions with guarded fill and submit behavior. +- Automatic save and update prompts that suppress unchanged duplicate credentials. +- Readable strong-password generation on recognized registration and password-change forms only. +- Identity and address autofill; payment-card support intentionally excludes stored CVV and purchase submission. +- Local TOTP generation and QR import for authenticator codes. -
-Monorepo Package Structure -
+### Private email -
+- Automatic signup-only email aliases on recognized HTTPS registration forms. +- Provider-free plus addressing, generated per site with a random suffix. +- Optional SimpleLogin or Addy.io integration using the user's own encrypted provider token. +- Aliases are persisted only when the matching credential save is accepted, then follow normal encrypted sync. -``` -veyrakey/ -├── apps/ -│ ├── api/ Hono Cloudflare Worker (src/index.ts, 39 lines) -│ │ └── src/index.ts Security headers · /v1/health · 404 handler -│ ├── extension/ WXT MV3 browser extension -│ │ ├── entrypoints/ -│ │ │ ├── background.ts Service worker (747 lines) – vault, session, autofill -│ │ │ ├── autofill.content.ts Content script (31 KB) – form detection + fill -│ │ │ └── popup/ Extension popup UI -│ │ └── src/ Extension-specific auth, session, PRF, autofill index -│ └── web/ -│ └── src/ React app shell, Google Drive, OneDrive OAuth wiring -├── packages/ -│ ├── contracts/ Shared health constants (HEALTH_PATH, HEALTH_RESPONSE) -│ ├── crypto/ Crypto primitives (577 lines) -│ │ └── src/index.ts CryptoProvider · DevicePrfProvider · WebAuthn PRF -│ ├── import-export/ CSV + Bitwarden importers (8 KB) -│ ├── persistence/ IndexedDB repositories (22 KB) -│ ├── provider-drive/ Google Drive + OneDrive sync adapters (16 KB) -│ ├── provider-onedrive/ OneDrive-specific PKCE OAuth adapter -│ ├── security/ Autofill engine + secrets (20 KB total) -│ │ ├── src/index.ts decideAutofill · findCredentialFields · fillCredentialFields -│ │ ├── src/secrets.ts generatePassword · generatePassphrase · generateTotp -│ │ └── src/password-health.ts analyzePasswordHealth · checkPwnedPassword -│ ├── sync/ Sync engine (13 KB) -│ │ └── src/index.ts SyncEngine · HybridLogicalClock · RevisionDAG -│ ├── ui/ Shared React UI (133 KB VaultScreen) -│ └── vault/ Core vault logic (16 files, ~180 KB) -│ ├── src/types.ts VaultClient · VaultError · all public types -│ ├── src/items.ts LoginItem · SecureNote · IdentityProfile · PaymentCard -│ ├── src/service.ts createVaultService (2523 lines) – the vault brain -│ ├── src/header.ts VaultHeaderV1/V2 parsing and validation -│ ├── src/archive.ts Encrypted archive create/restore -│ ├── src/recovery.ts Bech32m Recovery Kit encode/decode -│ └── src/search.ts Encrypted local search index -├── tooling/ Artifact verification, release manifest, smoke tests -├── docs/ 40+ architecture, security, and progress documents -├── biome.json Biome linter + formatter config -├── vitest.config.ts Test runner config with jsdom + fake-indexeddb -└── pnpm-workspace.yaml Monorepo catalog, allowBuilds, overrides -``` +### Storage and recovery ---- +- Device-only local encrypted vault with no account requirement. +- Google-connected encrypted vault using Drive's private `appDataFolder` scope. +- Restore on another device with the same Google account and the existing vault master password. +- Account switching, cloud disconnect, encrypted backup, import preview, and atomic restore boundaries. +- Immutable item revisions, history inspection, and restore-as-new-revision semantics. -## 🔐 Security Model +### Security tools -
-V2 Cryptographic Key Hierarchy -
+- Weak, reused, and aged-password analysis performed locally. +- Optional Pwned Passwords range checks that send a five-character SHA-1 prefix, never the password. +- WebAuthn PRF device unlock where the browser/authenticator combination supports it. +- Per-device enrollment status and revocation. +- Encrypted single-item sharing with a separate secret and bounded expiry. -### Zero-Knowledge Guarantees +### Passkey boundary -VeyraKey's server **never possesses** keys that decrypt vault content. The following secrets never leave the client: +VeyraKey does **not** claim to replace the browser or operating system as a native passkey provider. +WebAuthn ceremonies and passkey private keys remain with the platform authenticator. The vault can +store bounded public passkey references and related account metadata, while TOTP secrets are +encrypted and generated locally. See +[`docs/39-private-email-and-passkey-boundary.md`](docs/39-private-email-and-passkey-boundary.md). -- Master passwords and KDF outputs -- Recovery Kit secrets (32 random bytes, Bech32m encoded) -- WebAuthn PRF results (biometric unlock material) -- Root, document, and credential keys -- Plaintext vault records and item contents -- Google/Microsoft OAuth access tokens +## Choose how to use it -### V2 Key Hierarchy - -``` -Master Password ──▶ Argon2id (≥19,456 KiB · t=2 · p=1) ──▶ Master KEK -Recovery Kit ──▶ HKDF-SHA-256 ──▶ Recovery KEK -WebAuthn PRF ──▶ HKDF-SHA-256 ──▶ Device KEK - │ - ┌────────────────────────────────────┤ - ▼ ▼ ▼ - Root Key (32B) Document Key (32B) Credential Key (32B) - (random, never (compartment, (compartment, - derived from step-up required) step-up required) - password) - │ - ┌─────────┴──────────┐ - ▼ ▼ - Vault Header Auth Payload Encryption - (HKDF + seal tag) (XChaCha20-Poly1305) -``` - -Every key wrapper uses **XChaCha20-Poly1305-IETF** with a 24-byte random nonce. Authenticated Additional Data (AAD) binds: algorithm, vault ID, slot ID, envelope version, schema version, and purpose label. - -### Cryptographic Primitives - -| Primitive | Library / Standard | Use | +| | Local vault | Google-connected vault | |---|---|---| -| **Password KDF** | `libsodium-wrappers-sumo@0.8.4` Argon2id 1.3 | Master password hardening | -| **AEAD** | libsodium XChaCha20-Poly1305-IETF | All envelope encryption | -| **Key Derivation** | WebCrypto HKDF-SHA-256 | Domain separation, slot KEKs | -| **Recovery Encoding** | `@scure/base@2.2.0` Bech32m (`zkwr` prefix) | Recovery Kit encoding | -| **Randomness** | `crypto.getRandomValues` (platform only) | Nonces, salts, IDs, challenges | -| **Biometric Unlock** | WebAuthn Level 3 `prf` extension | Touch ID / Face ID key wrap | - -### Security Tag (V2 Header Authentication) - -Every V2 vault header includes a `securityTag`: a 40-byte base64url value derived by sealing empty plaintext with XChaCha20-Poly1305 using the canonical JSON of all mutable header fields as AAD, authenticated against a root-derived HKDF key. This binds device slots, key wrappers, payload ciphertext, and revision as one root-authenticated unit — **without rewriting payload ciphertext during password rotation**. - -### Trust Boundaries - -| Boundary | Guarantee | -|---|---| -| Application server | Cannot decrypt vault content (no keys ever sent) | -| Cloud provider (Drive/OneDrive) | Sees only authenticated ciphertext in app-private folders | -| Content scripts | Receive only minimum fields for active origin + operation | -| BYOS provider | Cannot derive keys even with full ciphertext access | - -### Non-Goals (Intentional Limits) - -- ❌ Server-side password reset / key escrow -- ❌ Emergency access backdoor -- ❌ Cloud AI receiving vault data -- ❌ JavaScript memory zeroization guarantees (best-effort only) -- ❌ Freshness proof against a provider presenting old self-consistent history - ---- - -## 🔄 Sync Protocol (BYOS) - -VeyraKey uses a **Bring Your Own Storage** model. Your cloud provider stores immutable encrypted objects only. - +| Account required | No | Google authorization | +| Source of truth | This browser profile | Encrypted Drive app data + local cache | +| Cross-device restore | Encrypted backup + Recovery Kit | Same Google account + vault unlock material | +| Cloud plaintext access | Not applicable | None by design | +| Can disconnect later | Not applicable | Yes; the local encrypted vault remains | + +## Architecture + +```mermaid +flowchart LR + Page["Untrusted web page"] -->|schema-validated request| Content["Isolated content script"] + Content -->|exact origin + field descriptors| Worker["MV3 service worker"] + Popup["React vault UI"] --> Worker + Worker --> Session["Short-lived unlock session"] + Worker --> Vault["Vault service"] + Vault --> Crypto["Argon2id · HKDF · XChaCha20-Poly1305"] + Vault --> IDB["Encrypted IndexedDB cache"] + Vault --> Sync["Immutable revision sync"] + Sync --> Drive["Google Drive appDataFolder"] + Drive -. "authenticated ciphertext only" .-> Sync ``` -Client Write Path: - 1. Validate domain model - 2. Advance Hybrid Logical Clock (HLC) - 3. Reference current parent revision heads - 4. Encrypt/authenticate new immutable revision (XChaCha20-Poly1305) - 5. Commit to IndexedDB (encrypted) + queue upload - 6. Upload idempotently to BYOS provider - 7. Publish encrypted snapshot candidate - -Pull + Merge Path: - 1. Read provider change feed from last safe cursor - 2. Download unseen opaque revisions - 3. Authenticate envelope + validate schema - 4. Build revision DAG, detect conflicts - 5. Apply deterministic merge rules - 6. Create conflict copies for competing secret edits - 7. Update local snapshot after durable commit -``` - -**Merge Policy:** -- Identical revisions are idempotent -- Causally-later revisions replace ancestors (DAG ordering) -- Independent metadata edits merge deterministically -- Competing secret values / delete+edit races create visible conflict copies -- Clients always converge from the same valid revision set regardless of delivery order ---- +
+ VeyraKey architecture and trust boundaries +
-## 🌐 Browser Extension Architecture +### Monorepo map + +```text +apps/ + extension/ WXT Manifest V3 extension for Chromium and Firefox + web/ React/Vite development and recovery surface + api/ Minimal Hono health worker; no vault keys or records +packages/ + crypto/ Project-owned cryptographic interface + vault/ Key hierarchy, records, revisions, archives, sharing + security/ Autofill policy, generation, TOTP, password health + persistence/ IndexedDB repositories and compare-and-replace writes + sync/ Revision graph, clocks, merge, provider orchestration + provider-drive/ Google Drive provider adapter + provider-onedrive/ Experimental OneDrive adapter (not a v0.10 release path) + import-export/ CSV and Bitwarden-compatible import pipeline + ui/ Shared accessible React application shell +tooling/ Packaging, permission, CSP, secret, SBOM, and size gates +release/ Checksummed manifest, SBOM, and external release gates +``` -The MV3 extension follows a strict layered model to prevent credential leakage: +## Security model -``` -┌─────────────────────────────────────────────────────┐ -│ Hostile Page (MAIN world) │ -│ │ minimal WebAuthn bridge only │ -├────────▼─────────────────────────────────────────── │ -│ Isolated Content Script │ -│ • Form detection (findCredentialFields) │ -│ • Origin-safe fill (fillCredentialFields) │ -│ • Schema-validated messages only │ -│ │ chrome.runtime.sendMessage │ -├────────▼─────────────────────────────────────────── │ -│ MV3 Service Worker (background.ts) │ -│ • VaultService + ExtensionSessionCoordinator │ -│ • Autofill decisions (decideAutofill) │ -│ • Credential capture (decideCredentialCapture) │ -│ • HIBP checks · TOTP generation │ -│ │ browser.storage.session │ -├────────▼─────────────────────────────────────────── │ -│ Extension Popup / Side Panel │ -│ • VaultScreen (search, add, autofill choices) │ -│ • Biometric step-up UI │ -└─────────────────────────────────────────────────────┘ -``` +
+ VeyraKey key hierarchy +
-**Autofill Security Rules:** -- Only fires on `https:` or localhost (never `http:` on live sites) -- Blocks cross-origin iframe fills unconditionally -- Requires explicit user action before releasing any credential -- Exact origin match by default; related-domain requires explicit saved policy -- Save/update prompts show destination origin before writing anything +VeyraKey generates random root and compartment keys, then wraps them for each authorized unlock +method. Password rotation rewraps keys instead of decrypting and rewriting every record. ---- +| Layer | Construction | Purpose | +|---|---|---| +| Master-password slot | Argon2id | Memory-hard derivation of a key-encryption key | +| Payload encryption | XChaCha20-Poly1305 | Confidentiality and authentication for records and archives | +| Domain separation | HKDF-SHA-256 | Separate keys by purpose and slot | +| Recovery Kit | 32 random bytes, Bech32m encoded | Offline recovery without server escrow | +| Device unlock | WebAuthn PRF when available | Locally authorized key unwrap | -## 🚀 Quick Start +Critical invariants are executable where possible and documented in +[`docs/26-security-invariants.md`](docs/26-security-invariants.md). The threat model and explicit +non-goals are in [`docs/03-trust-and-threat-model.md`](docs/03-trust-and-threat-model.md) and +[`docs/17-constraints-and-non-goals.md`](docs/17-constraints-and-non-goals.md). -### Prerequisites +## Quick start -| Tool | Version | -|---|---| -| Node.js | `24.11.0` (exact) | -| pnpm | `11.10.0` (exact) | +### Requirements -### Run the Web App +- Node.js `24.11.0` +- pnpm `11.10.0` -```sh -# Install all dependencies (frozen lockfile — reproducible) +```bash CI=true pnpm install --frozen-lockfile - -# Start the web app dev server pnpm dev:web ``` -Open [`http://127.0.0.1:5173`](http://127.0.0.1:5173) in Chrome or Firefox. - -> ⚠️ The app requires WebCrypto API and IndexedDB. Embedded iframes/previews (Codex, Figma embeds, etc.) cannot operate the vault. - -### First Use - -1. **Create a vault** — choose Personal Cloud (recommended) or Device-Only storage. Set a strong master password. -2. **Save your Recovery Kit** — re-enter it immediately to verify the drill. Losing all unlock methods is **permanently unrecoverable** — no server reset exists. -3. **Add items** — Logins, Secure Notes, Identity Profiles, Payment Cards. -4. **Download an encrypted backup** before clearing browser data. +The development UI opens at `http://127.0.0.1:5173`. -### Restore on a New Device +### Build the extension -1. Open the app → **Restore** -2. Connect the same cloud account -3. Enter your Recovery Kit -4. Set a new local master password - ---- - -## 📦 Monorepo Packages - -### `@zk-wallet/crypto` -> **577 lines** · `packages/crypto/src/index.ts` - -The cryptographic foundation. All primitives are isolated behind this interface — application code never calls libsodium or WebCrypto directly. - -| Export | Description | -|---|---| -| `createCryptoProvider()` | Returns `CryptoProvider` with `deriveArgon2id`, `hkdfSha256`, `sealXChaCha20Poly1305`, `openXChaCha20Poly1305`, `randomBytes` | -| `createWebAuthnPrfProvider()` | WebAuthn PRF enrollment and evaluation for biometric unlock | -| `encodeEnvelopeAad()` | Length-prefixed AAD serialization (prevents field reordering attacks) | -| `assertProductionKdfParameters()` | Enforces Argon2id ≥19,456 KiB/t=2/p=1 floor | -| `zeroBytes()` | Best-effort memory zeroization | -| Encoding helpers | `bytesToBase64Url` · `base64UrlToBytes` · `bytesToHex` · `hexToBytes` · `utf8ToBytes` | - -**Key constants:** -```typescript -ARGON2ID_PRODUCTION_FLOOR = { memoryKiB: 19_456, operations: 2, outputLength: 32, parallelism: 1 } -XCHACHA20_POLY1305_KEY_BYTES = 32 -XCHACHA20_POLY1305_NONCE_BYTES = 24 -XCHACHA20_POLY1305_TAG_BYTES = 16 +```bash +CI=true pnpm --dir apps/extension build ``` ---- - -### `@zk-wallet/vault` -> **16 source files** · `packages/vault/src/` +Load `apps/extension/.output/chrome-mv3` from `chrome://extensions` with Developer mode enabled. +After rebuilding or reloading the extension, refresh existing test pages so they receive the new +content script. -The core vault domain. Manages key slots, item revisions, and the complete vault lifecycle. +### Google Drive development setup -#### Key Types (`types.ts` — 323 lines) - -| Type | Description | -|---|---| -| `VaultHeaderV2` | Current header format with master slot, recovery slot, device slots, security tag, revision | -| `MasterPasswordSlotV2` | Argon2id KDF params + three-envelope wrapped key set | -| `RecoveryKitSlotV1` | Bech32m Recovery Kit with three-envelope wrapped key set | -| `ActiveDeviceSlotV2` | WebAuthn PRF credential + scope + three-envelope wrapped key set | -| `WrappedKeySetV1` | Root + document + credential key envelopes | -| `VaultClient` | Full vault interface (~30 methods) | -| `VaultError` | Typed error codes (`VAULT_LOCKED`, `VAULT_WRITE_CONFLICT`, etc.) | - -#### Item Types (`items.ts` — 753 lines) - -```typescript -interface LoginItemInput { title, username, password, uris[], notes, totpUri?, breachCheck?, tags, folder, favorite } -interface SecureNoteItemInput { title, note, tags, folder, favorite } -interface IdentityProfileItemInput { firstName, lastName, email, phone, address fields... } -interface PaymentCardItemInput { cardholderName, cardNumber, expiryMonth, expiryYear, securityCode, billingAddress } -``` +The application owner must register the extension OAuth client and enable the Drive API. End users +then connect with a normal Google consent flow; they never enter a client ID. -All fields have enforced byte limits (e.g., `MAX_PASSWORD_BYTES = 8192`, `MAX_NOTE_BYTES = 1_048_576`). +1. Create a Google Cloud OAuth client for the extension. +2. Register the built extension ID and required redirect URI. +3. Configure `VITE_GOOGLE_CLIENT_ID` for the build. +4. Grant only the Drive app-data scope used by the application. -#### Vault Service (`service.ts` — 2,523 lines) +OAuth consent, real-account restore, and account-switch tests remain external release gates because +they require provider configuration and user interaction. -The central vault brain. Key behaviors: +## Development -| Method | What it does | +| Command | Purpose | |---|---| -| `createVault(masterPassword)` | Generates Argon2id salt, derives KEK, creates random root/doc/cred keys, writes V2 header with security tag | -| `unlock(masterPassword)` | KDF-derives KEK, unwraps root key, verifies security tag, loads items | -| `unlockWithDevice(slotId)` | WebAuthn PRF ceremony → unwrap keys → verify tag | -| `unlockWithRecoveryKit(kit)` | Decode Bech32m → unwrap keys → verify tag | -| `changeMasterPassword(req)` | Rewraps same random root/doc/cred keys without touching payload ciphertext | -| `enrollDevice(masterPassword)` | PRF enrollment ceremony → add active slot → increment revision → refresh tag | -| `revokeDevice(slotId)` | Replace with tombstone → increment revision → refresh tag | -| `createLogin(input)` | Validates → encrypts revision → persists → queues sync upload | -| `searchItems(query)` | Queries encrypted local search index → decrypts matching items | -| `exportEncryptedArchive()` | Full history with wrapped keys for cross-device/provider restore | - ---- - -### `@zk-wallet/security` -> `packages/security/src/` · 3 modules - -#### Autofill Engine (`index.ts` — 300 lines) - -```typescript -// Origin-safe autofill decision -decideAutofill({ credentials, frameUrl, topUrl, userInitiated }): AutofillDecision -// → allowed: true (exact origin match) | false (with reason: INSECURE_SCHEME, CROSS_ORIGIN_FRAME, etc.) - -// Form field detection -findCredentialFields(root: ParentNode): CredentialFormFields | null -// → { password: HTMLInputElement, username: HTMLInputElement | null } - -// Credential fill (dispatches React-compatible synthetic events) -fillCredentialFields(fields, { username, password }): void - -// Save/update decision -decideCredentialCapture({ captured, credentials, frameUrl, topUrl }): CredentialCaptureDecision -// → { action: 'save' | 'update' | 'none', ... } -``` - -#### Secrets (`secrets.ts`) - -```typescript -generatePassword(options) // CSPRNG · configurable length/charset/exclusions/min-counts -generatePassphrase(options) // Wordlist-based · entropy-aware · no modulo bias -generateTotp(config) // RFC 6238 TOTP · HMAC-SHA1/256/512 · countdown -parseOtpAuthUri(uri) // otpauth:// URI parser -parseOtpAuthQr(image) // QR code → OTP config (optional QrCodeDetector) -copyWithBestEffortClear() // Clipboard copy with timeout clearing -``` - -#### Password Health (`password-health.ts`) - -```typescript -analyzePasswordHealth(logins[]): PasswordHealthFinding[] -// → weak (zxcvbn-style score) · reused · old · unsecured-http-origin - -checkPwnedPassword(password, options): Promise -// → k-anonymous HIBP range query (sends only first 5 SHA-1 hex chars) -``` - ---- - -### `@zk-wallet/sync` -> `packages/sync/src/index.ts` — 12 KB - -```typescript -// Hybrid Logical Clock -class HybridLogicalClock { advance(wallMs): HLCTimestamp } - -// Revision DAG -class RevisionDAG { - insert(revision): void - heads(): RevisionId[] - ancestors(id): RevisionId[] - lca(a, b): RevisionId | null -} - -// Deterministic merge -mergeRevisions(local[], remote[]): MergeResult -// → { merged: RevisionId[], conflicts: ConflictSet } -``` - ---- - -### `@zk-wallet/persistence` -> `packages/persistence/src/index.ts` — 22 KB - -```typescript -class IndexedDbVaultHeaderRepository // create() · read() · replace(condition, header) -class IndexedDbItemRevisionRepository // put() · list() · listSince(cursor) -``` - -All writes use compare-and-replace conditioned on `{ vaultId, version, revision }`. A loser locks and reloads. - ---- - -### `@zk-wallet/import-export` - -```typescript -parseGenericCsv(input): ImportPreview // Generic CSV with dry-run counts -parseBitwardenExport(input): ImportPreview // Bitwarden JSON export format -``` +| `pnpm lint` | Biome formatting and lint rules | +| `pnpm typecheck` | Strict TypeScript across the workspace | +| `pnpm test` | Unit, property, integration, lifecycle, and compatibility tests | +| `pnpm build` | Production builds for packages and applications | +| `pnpm check` | Full automated release gate, including packaged extension verification | +| `pnpm release:verify` | Checks, checksummed manifest, and release-status evaluation | -Provides dry-run preview, field-loss warnings, formula/control-character-safe display, duplicate detection, and all-or-nothing rollback boundaries. +## Verification ---- - -### `apps/api` — Hono Cloudflare Worker +The current release candidate passes: -```typescript -// apps/api/src/index.ts (39 lines) -const app = new Hono() +- **259 tests across 38 test files**. +- Strict TypeScript checks across **13 workspace packages**. +- Web, Chrome MV3, and Firefox MV3 production builds. +- Extension packaging for Chrome, Firefox, and reviewable sources. +- Permission, CSP, source-map, embedded-secret, chunk-size, version-alignment, and SBOM checks. -// Security headers on every response -app.use("*", addSecurityHeaders) -// → Cache-Control: no-store -// → Content-Security-Policy: default-src 'none'; ... -// → X-Frame-Options: DENY -// → X-Content-Type-Options: nosniff +The reproducible command is: -// Only one route: health check -app.get("/v1/health", () => json(HEALTH_RESPONSE)) +```bash +CI=true pnpm check ``` -The Worker has **no access to vault keys, user secrets, or vault content**. It exists solely for infrastructure health checks and future capability signaling. - ---- - -## 🌩 Cloud Sync Setup - -### Google Drive (Recommended) +Automated success is not the same as public-release approval. Physical biometric devices, live +Google OAuth, browser-store signing, and independent security/accessibility review are tracked in +[`docs/40-external-release-evidence.md`](docs/40-external-release-evidence.md). -1. Create a Google Cloud project → enable **Google Drive API** -2. Configure OAuth consent screen → add your account as a test user -3. Create **Web application** OAuth client -4. Add `http://127.0.0.1:5173` as authorized JavaScript origin -5. Add `http://127.0.0.1:5173/oauth/google/callback` as redirect URI -6. Set env var: - ```sh - VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com - ``` -7. In the app: **Settings → Connect Google Drive** +## Documentation -The app requests only `drive.appdata` scope. Access tokens live **in memory only** and are discarded on disconnect/reload. +Start with [`docs/README.md`](docs/README.md). Useful entry points: -### Microsoft OneDrive +- [Product requirements](docs/01-product-requirements.md) +- [System architecture](docs/02-system-architecture.md) +- [Cryptography and key management](docs/04-cryptography-and-key-management.md) +- [BYOS sync protocol](docs/06-byos-sync-protocol.md) +- [Browser extension architecture](docs/08-browser-extension-architecture.md) +- [Testing and quality strategy](docs/14-testing-and-quality-strategy.md) +- [Security invariants](docs/26-security-invariants.md) +- [UX and accessibility](docs/28-ux-and-accessibility.md) +- [Release runbook](docs/33-v1-release-runbook.md) +- [Implementation status](docs/36-enterprise-password-manager-status.md) -1. Register a **Single-Page Application** in Microsoft Entra -2. Add delegated permission: `Files.ReadWrite.AppFolder` -3. Add redirect URI: `http://127.0.0.1:5173/oauth/microsoft/callback` -4. Enable personal Microsoft accounts for consumer OneDrive -5. Set env var: - ```sh - VITE_MICROSOFT_CLIENT_ID=your-application-client-id - ``` -6. In the app: **Settings → Connect OneDrive** +## Responsible use and disclosure -Uses OAuth Authorization Code + PKCE. Access tokens retained **in memory only**. - -> 💡 OAuth client IDs are public application configuration, not user secrets. +Read [`SECURITY.md`](SECURITY.md) before reporting a vulnerability. Never include real passwords, +Recovery Kits, OAuth tokens, or vault exports in an issue. --- -## 🔌 Browser Extension - -### Load in Chrome - -```sh -# Build the extension -CI=true pnpm --filter veyrakey-extension build - -# Load it -# 1. Open chrome://extensions -# 2. Enable Developer mode -# 3. Click "Load unpacked" -# 4. Select: apps/extension/.output/chrome-mv3 -``` - -### Load in Firefox - -```sh -# 1. Open about:debugging -# 2. Click "This Firefox" → "Load Temporary Add-on" -# 3. Select: apps/extension/.output/firefox-mv2/manifest.json -``` - -> After every extension rebuild, reload already-open login pages so Chrome invalidates old content scripts. - ---- - -## 🛠 Development - -### Commands - -```sh -# Development -pnpm dev:web # Start web app at http://127.0.0.1:5173 -pnpm dev:extension # Start extension watch mode -pnpm dev:api # Start Cloudflare Worker dev server - -# Quality checks -pnpm lint # Biome format + lint check -pnpm format # Biome auto-format -pnpm typecheck # tsc across all packages -pnpm test # Vitest run (146 tests) -pnpm test:watch # Vitest watch mode - -# Build -pnpm build # Build all packages + apps - -# Release validation -pnpm release:package # Package extension ZIPs -pnpm release:manifest # Write checksummed release manifest -pnpm release:status # Verify public release gates -pnpm check # Full pipeline: lint → typecheck → test → build → verify -``` - -### Environment Variables - -| Variable | App | Description | -|---|---|---| -| `VITE_GOOGLE_CLIENT_ID` | web, extension | Google OAuth client ID for Drive BYOS | -| `VITE_MICROSOFT_CLIENT_ID` | web, extension | Microsoft Entra client ID for OneDrive | - -Copy `.env.example` to `.env.local` in `apps/web/` or `apps/extension/`. - -### Tech Stack - -| Layer | Technology | -|---|---| -| **Frontend** | React 19 · Vite · TypeScript 7 | -| **Extension** | WXT · Manifest V3 · Chrome + Firefox | -| **API** | Hono · Cloudflare Workers | -| **Crypto** | libsodium-wrappers-sumo 0.8.4 · WebCrypto | -| **Storage** | IndexedDB (encrypted) | -| **Testing** | Vitest · @testing-library/react · fake-indexeddb · fast-check | -| **Tooling** | pnpm workspaces · Biome · TypeScript strict mode | -| **Package Manager** | pnpm 11.10.0 (exact pins, frozen lockfile) | - ---- - -## 🧪 Testing - -```sh -pnpm test -# ✓ 146 tests passing across 29 test files -``` - -### Test Categories - -| Package | Tests | What's Covered | -|---|---|---| -| `@zk-wallet/crypto` | 41 | Argon2id vectors, HKDF RFC 5869, XChaCha20 rounds, PRF enrollment/evaluation, nonce uniqueness | -| `@zk-wallet/vault` | 78 | Full V1→V2 lifecycle, key rotation, compartment step-up, concurrency, recovery drill, conflict detection | -| `@zk-wallet/security` | 42 | Autofill decisions, form detection, HIBP prefix protocol, TOTP RFC vectors, password generation bias | -| `@zk-wallet/sync` | 15 | HLC ordering, DAG construction, conflict detection, merge determinism | -| `@zk-wallet/persistence` | 12 | IndexedDB CRUD, compare-and-replace, serialization safety | -| Extension | 8 | Manifests, autofill, session handling | -| Web App | 6 | Shell rendering, OAuth wiring | - -### Property-Based and Chaos Tests - -The test suite includes **fast-check** property tests and chaos corpus exercises: -- Arbitrary revision delivery ordering (sync convergence) -- Duplicate/retried writes (idempotency) -- Tampered/truncated/reordered ciphertext (authentication rejection) -- Wrong key / wrong vault / wrong schema (failure isolation) -- Parser limits and oversized inputs -- Interrupted compaction and partial state recovery - ---- - -## 📋 Implementation Status - -| # | Task | Status | -|---:|---|:---:| -| 1 | Secure walking skeleton | ✅ Complete | -| 2 | Vault crypto and unlock | ✅ Complete | -| 3 | Recovery, compartments, PRF unlock | ✅ Complete | -| 4 | Encrypted login/note CRUD | ✅ Complete | -| 5 | Immutable sync engine | ✅ Complete | -| 6 | Google Drive BYOS | ✅ Complete | -| 6A | Microsoft OneDrive BYOS | ✅ Locally complete | -| 7 | Secure MV3 extension sessions | ✅ Complete | -| 8 | Origin-safe autofill/capture | ✅ Complete | -| 9 | Password generation / TOTP / clipboard | ✅ Complete | -| 10 | Organization / encrypted search | ✅ Complete | -| 11 | Focused import / encrypted backup | ✅ Complete | -| 12 | Password-health dashboard / HIBP | ✅ Complete | -| 13 | Whole-system hardening / accessibility | ✅ Complete | -| 14 | Portfolio deployment / release | 🔄 Local RC complete | - -**146 tests · 29 test files · pnpm check ✅** - ---- - -## 📚 Documentation - -The [`docs/`](docs/) directory contains 40+ architecture and security documents: - -| Document | Description | -|---|---| -| [`00-project-overview.md`](docs/00-project-overview.md) | Mission and scope summary | -| [`02-system-architecture.md`](docs/02-system-architecture.md) | Component diagram, data flows | -| [`03-trust-and-threat-model.md`](docs/03-trust-and-threat-model.md) | Threat model and trust boundaries | -| [`04-cryptography-and-key-management.md`](docs/04-cryptography-and-key-management.md) | Full crypto specification | -| [`05-vault-data-model.md`](docs/05-vault-data-model.md) | Item schemas and revision format | -| [`06-byos-sync-protocol.md`](docs/06-byos-sync-protocol.md) | Sync protocol, merge policy | -| [`08-browser-extension-architecture.md`](docs/08-browser-extension-architecture.md) | Extension security model | -| [`09-password-security-features.md`](docs/09-password-security-features.md) | Autofill, TOTP, health dashboard | -| [`20-progress.md`](docs/20-progress.md) | Detailed task completion log | -| [`21-architecture-decisions.md`](docs/21-architecture-decisions.md) | 52 KB of ADRs | -| [`26-security-invariants.md`](docs/26-security-invariants.md) | Non-negotiable security rules | -| [`33-v1-release-runbook.md`](docs/33-v1-release-runbook.md) | Release verification steps | - ---- - -## 🔒 Security Policy - -See [`SECURITY.md`](SECURITY.md) for the vulnerability disclosure policy. - -**Key invariants that cannot be weakened:** -1. Master passwords, Recovery Kit secrets, PRF outputs, and vault keys never enter application-server storage or logs -2. Google account sign-in identifies the account; master password unlocks the vault locally -3. No server reset, escrow, or emergency-access backdoor -4. Every persistent vault object is encrypted and authenticated before leaving the trusted client runtime -5. No cloud AI receives vault data - ---- - -## 🗺 Roadmap - -VeyraKey v1 is a **portfolio project** demonstrating industry-grade cryptographic engineering. Planned future work (not v1 commitments): - -- 🔮 Software passkeys (credential compartment sync) -- 🔮 WebDAV BYOS provider -- 🔮 Document wallet (chunked encrypted documents) -- 🔮 Digital credential wallet (OpenID4VCI / OpenID4VP) -- 🔮 Safari extension support -- 🔮 Native desktop/mobile apps - -See [`docs/32-future-work.md`](docs/32-future-work.md) and [`docs/38-portfolio-flagship-roadmap.md`](docs/38-portfolio-flagship-roadmap.md). - ---- - -## ⚠️ Limitations - -- This project has **not received an independent security audit**. Use synthetic data during review. -- JavaScript runtimes do not guarantee deterministic memory zeroization (best-effort). -- Traffic analysis can reveal account activity, timing, and ciphertext sizes to the cloud provider. -- Physical biometric (Touch ID / Face ID) and real-account OAuth remain manual platform evidence gates. -- Free-tier infrastructure is not an SLA. - ---- - -## 📄 License - -MIT — see [LICENSE](LICENSE). - ---- - ---- - -## 🔎 Keywords & Discoverability - - - -VeyraKey is an **open-source, zero-knowledge password manager** built as a portfolio project demonstrating modern cryptographic engineering in TypeScript. Key search terms this project covers: - -- **Zero-knowledge password manager** — the server never possesses vault-decryption keys -- **End-to-end encrypted vault** — Argon2id KDF + XChaCha20-Poly1305-IETF + HKDF-SHA-256 -- **WebAuthn PRF biometric unlock** — Touch ID / Face ID as a hardware key wrapper, not just 2FA -- **BYOS (Bring Your Own Storage)** — Google Drive `appDataFolder` + Microsoft OneDrive -- **MV3 Browser Extension** — Chromium + Firefox Manifest V3 with exact-origin autofill -- **Self-hosted / open source Bitwarden alternative** — full local vault, no proprietary cloud -- **TypeScript monorepo** — strict pnpm workspace, Biome, Vitest, 146 tests -
- -⭐ **Star this repo** to help other developers find a real-world example of zero-knowledge cryptographic architecture in TypeScript. - -**[github.com/Iamsujithd/VeyraKey](https://github.com/Iamsujithd/VeyraKey)** - -Built with ❤️ using TypeScript · React · WebCrypto · libsodium · WebAuthn · Argon2id · XChaCha20 - -**VeyraKey v0.10.0** — Your keys. Your vault. Your cloud. - + VeyraKey
+ Built as an engineering portfolio project around explicit trust boundaries, reproducible evidence, + and honest capability claims.
diff --git a/apps/extension/entrypoints/autofill.content.ts b/apps/extension/entrypoints/autofill.content.ts index a8152b9..37bc0b5 100644 --- a/apps/extension/entrypoints/autofill.content.ts +++ b/apps/extension/entrypoints/autofill.content.ts @@ -21,11 +21,14 @@ import { generateAdaptiveRegistrationPassword, isCredentialField, isLoginAction, + isRegistrationEmailField, isRegistrationPasswordField, isUsernameField, OPEN_VAULT_MANAGER_TYPE, + PRIVATE_EMAIL_REQUEST_TYPE, PROFILE_AUTOFILL_REQUEST_TYPE, PROFILE_AUTOFILL_SELECT_TYPE, + type PrivateEmailResponse, type ProfileAutofillResponse, parseBiometricFillRequest, parseShowAutofillRequest, @@ -495,6 +498,28 @@ export default defineContentScript({ }); }; + const requestPrivateEmail = (anchor: HTMLInputElement) => { + void sendMessage({ + topUrl: location.href, + type: PRIVATE_EMAIL_REQUEST_TYPE, + userInitiated: true, + version: 1, + }).then((response) => { + if (response?.status === "value") { + fillProfileField(anchor, response.address); + return; + } + if (response?.status === "not-configured" || response?.status === "disabled") { + requestProfileSuggestions(anchor); + } + }); + }; + + const requestProfileOrPrivateEmail = (anchor: HTMLInputElement) => { + if (isRegistrationEmailField(anchor)) requestPrivateEmail(anchor); + else requestProfileSuggestions(anchor); + }; + const requestCardSuggestions = (anchor: HTMLInputElement) => { const field = cardFieldKind(anchor); if (field === null) return; @@ -693,7 +718,7 @@ export default defineContentScript({ target instanceof HTMLInputElement && profileFieldKind(target) !== null ) { - requestProfileSuggestions(target); + requestProfileOrPrivateEmail(target); } else if (event.isTrusted && window.top === window && isCredentialField(target)) { requestSuggestions(target); } @@ -733,7 +758,7 @@ export default defineContentScript({ event.target instanceof HTMLInputElement && profileFieldKind(event.target) !== null ) { - requestProfileSuggestions(event.target); + requestProfileOrPrivateEmail(event.target); } else if (isCredentialField(event.target)) { requestSuggestions(event.target); } @@ -803,7 +828,7 @@ export default defineContentScript({ } else if (cardFieldKind(anchor) !== null) { requestCardSuggestions(anchor); } else if (profileFieldKind(anchor) !== null) { - requestProfileSuggestions(anchor); + requestProfileOrPrivateEmail(anchor); } else { requestSuggestions(anchor); } @@ -861,7 +886,7 @@ export default defineContentScript({ } else if (cardFieldKind(active) !== null) { requestCardSuggestions(active); } else if (profileFieldKind(active) !== null) { - requestProfileSuggestions(active); + requestProfileOrPrivateEmail(active); } else { requestSuggestions(active); } diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts index 21c0cfc..84f1845 100644 --- a/apps/extension/entrypoints/background.ts +++ b/apps/extension/entrypoints/background.ts @@ -9,6 +9,7 @@ import { type IdentityProfileItem, type LoginItem, type PaymentCardItem, + type SecureNoteItem, type VaultItem, } from "@zk-wallet/vault"; import { @@ -21,6 +22,7 @@ import { type CaptureResponse, type CardAutofillResponse, credentialFingerprint, + type PrivateEmailResponse, type ProfileAutofillResponse, parseAuthenticatedAutofillSelectRequest, parseAutofillFilledRequest, @@ -33,6 +35,7 @@ import { parseCardAutofillSelectRequest, parseManualAutofillRequest, parseOpenVaultManagerRequest, + parsePrivateEmailRequest, parseProfileAutofillRequest, parseProfileAutofillSelectRequest, parseUsernameObservedRequest, @@ -40,6 +43,12 @@ import { } from "../src/autofill"; import { readAutofillMetadataIndex, writeAutofillMetadataIndex } from "../src/autofillIndex"; import { createExtensionDevicePrfProvider } from "../src/devicePrf"; +import { + type CreatedPrivateEmailAlias, + createPrivateEmailAlias, + PRIVATE_EMAIL_SETTINGS_TAG, + parsePrivateEmailSettingsNote, +} from "../src/privateEmail"; import { ExtensionSessionCoordinator } from "../src/session"; export default defineBackground(() => { @@ -88,6 +97,30 @@ export default defineBackground(() => { }; const pendingKey = (tabId: number) => `zk-wallet.pending-capture.v1.${tabId}`; const recentFillKey = (tabId: number) => `zk-wallet.recent-fill.v1.${tabId}`; + const privateEmailKey = (tabId: number) => `zk-wallet.private-email.v1.${tabId}`; + type PendingPrivateEmail = CreatedPrivateEmailAlias & { readonly expiresAt: number }; + const loadPendingPrivateEmail = async ( + tabId: number, + topUrl: string, + ): Promise => { + const key = privateEmailKey(tabId); + const value = (await browser.storage.session.get(key))[key]; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const alias = value as Partial; + if ( + typeof alias.address !== "string" || + typeof alias.createdAt !== "string" || + typeof alias.createdForOrigin !== "string" || + typeof alias.expiresAt !== "number" || + !["addy", "plus", "simplelogin"].includes(alias.provider ?? "") || + alias.expiresAt <= Date.now() || + alias.createdForOrigin !== new URL(topUrl).origin + ) { + await browser.storage.session.remove(key); + return null; + } + return alias as PendingPrivateEmail; + }; type RecentFill = { readonly expiresAt: number; readonly fingerprint: string }; const rememberRecentFill = async ( tabId: number, @@ -221,8 +254,26 @@ export default defineBackground(() => { } if (decision.action === "save") { if (service.createLogin === undefined) return { status: "unavailable", version: 1 }; + const pendingAlias = await loadPendingPrivateEmail(tabId, pending.capture.topUrl); await service.createLogin({ breachCheck: await breachCheckFor(pending.capture.password), + ...(pendingAlias !== null && + pendingAlias.address.toLocaleLowerCase() === pending.username.trim().toLocaleLowerCase() + ? { + emailAlias: { + address: pendingAlias.address, + createdAt: pendingAlias.createdAt, + createdForOrigin: pendingAlias.createdForOrigin, + provider: pendingAlias.provider, + ...(pendingAlias.providerAliasId === undefined + ? {} + : { providerAliasId: pendingAlias.providerAliasId }), + ...(pendingAlias.sourceEmail === undefined + ? {} + : { sourceEmail: pendingAlias.sourceEmail }), + }, + } + : {}), notes: "", password: pending.capture.password, title: decision.displayHost, @@ -238,15 +289,19 @@ export default defineBackground(() => { ...(existing.favorite === undefined ? {} : { favorite: existing.favorite }), ...(existing.folder === undefined ? {} : { folder: existing.folder }), breachCheck: await breachCheckFor(pending.capture.password), + ...(existing.emailAlias === undefined ? {} : { emailAlias: existing.emailAlias }), notes: existing.notes, password: pending.capture.password, + ...(existing.passkeys === undefined ? {} : { passkeys: existing.passkeys }), ...(existing.tags === undefined ? {} : { tags: existing.tags }), title: existing.title, + ...(existing.totpUri === undefined ? {} : { totpUri: existing.totpUri }), uris: existing.uris, username: pending.username, }); } await deletePending(tabId); + await browser.storage.session.remove(privateEmailKey(tabId)); await browser.action.setPopup({ popup: "popup.html", tabId }); await unlockedLogins(); return { action: decision.action, status: "saved", version: 1 }; @@ -329,6 +384,7 @@ export default defineBackground(() => { | AutofillResponse | CardAutofillResponse | CaptureResponse + | PrivateEmailResponse | ProfileAutofillResponse | undefined > => { @@ -357,6 +413,49 @@ export default defineBackground(() => { await rememberRecentFill(sender.tab.id, filledReceipt); return; } + const privateEmailRequest = parsePrivateEmailRequest(message); + if (privateEmailRequest !== null) { + if (!trustedOrigin(privateEmailRequest.topUrl, sender) || sender.tab?.id === undefined) { + return { status: "unavailable", version: 1 }; + } + const cached = await loadPendingPrivateEmail(sender.tab.id, privateEmailRequest.topUrl); + if (cached !== null) { + return { + address: cached.address, + provider: cached.provider, + status: "value", + version: 1, + }; + } + const items = await unlockedItems(); + if (items === null) return { status: "locked", version: 1 }; + const settingsNote = items.find( + (item): item is SecureNoteItem => + item.type === "secure-note" && item.tags?.includes(PRIVATE_EMAIL_SETTINGS_TAG) === true, + ); + if (settingsNote === undefined) return { status: "not-configured", version: 1 }; + const settings = parsePrivateEmailSettingsNote(settingsNote.note); + if (settings === null) return { status: "not-configured", version: 1 }; + if (!settings.autoFill) return { status: "disabled", version: 1 }; + try { + const alias = await createPrivateEmailAlias(settings, privateEmailRequest.topUrl, { + randomBytes(length) { + const output = new Uint8Array(length); + crypto.getRandomValues(output); + return output; + }, + }); + await browser.storage.session.set({ + [privateEmailKey(sender.tab.id)]: { + ...alias, + expiresAt: Date.now() + 30 * 60 * 1_000, + } satisfies PendingPrivateEmail, + }); + return { address: alias.address, provider: alias.provider, status: "value", version: 1 }; + } catch { + return { status: "unavailable", version: 1 }; + } + } const profileSelection = parseProfileAutofillSelectRequest(message); if (profileSelection !== null) { if (!trustedOrigin(profileSelection.topUrl, sender)) { diff --git a/apps/extension/src/autofill.test.ts b/apps/extension/src/autofill.test.ts index db9f0e0..e92a609 100644 --- a/apps/extension/src/autofill.test.ts +++ b/apps/extension/src/autofill.test.ts @@ -24,9 +24,11 @@ import { generateStrongRegistrationPassword, isCredentialField, isLoginAction, + isRegistrationEmailField, isRegistrationPasswordField, isUsernameField, MANUAL_AUTOFILL_REQUEST_TYPE, + PRIVATE_EMAIL_REQUEST_TYPE, PROFILE_AUTOFILL_REQUEST_TYPE, PROFILE_AUTOFILL_SELECT_TYPE, parseAuthenticatedAutofillSelectRequest, @@ -40,6 +42,7 @@ import { parseCardAutofillRequest, parseCardAutofillSelectRequest, parseManualAutofillRequest, + parsePrivateEmailRequest, parseProfileAutofillRequest, parseProfileAutofillSelectRequest, parseUsernameObservedRequest, @@ -794,6 +797,49 @@ describe("extension automatic autofill", () => { expect(age === null ? null : profileFieldKind(age)).toBe("age"); }); + it("offers private email only with strong registration evidence", () => { + document.body.innerHTML = ` +
+

Create account

+ + + +
+
+ + +
+
+ + + +
+ `; + expect(isRegistrationEmailField(document.querySelector("#signup-email"))).toBe(true); + expect(isRegistrationEmailField(document.querySelector("#newsletter-email"))).toBe(false); + expect(isRegistrationEmailField(document.querySelector("#login-email"))).toBe(false); + }); + + it("supports passwordless multi-step signup but rejects ambiguous standalone email fields", () => { + document.body.innerHTML = ` +
+

Join Acme

+ + +
+
+ +
+
+ + +
+ `; + expect(isRegistrationEmailField(document.querySelector("#step-email"))).toBe(true); + expect(isRegistrationEmailField(document.querySelector("#plain-email"))).toBe(false); + expect(isRegistrationEmailField(document.querySelector("#recovery-email"))).toBe(false); + }); + it("strictly validates profile lookup and selection messages", () => { const request = { field: "city", @@ -816,6 +862,21 @@ describe("extension automatic autofill", () => { expect(parseProfileAutofillSelectRequest({ ...selection, extra: true })).toBeNull(); }); + it("accepts only user-initiated HTTPS private-email requests", () => { + const request = { + topUrl: "https://signup.example.test/register", + type: PRIVATE_EMAIL_REQUEST_TYPE, + userInitiated: true, + version: 1, + }; + expect(parsePrivateEmailRequest(request)).toEqual(request); + expect( + parsePrivateEmailRequest({ ...request, topUrl: "http://signup.example.test" }), + ).toBeNull(); + expect(parsePrivateEmailRequest({ ...request, userInitiated: false })).toBeNull(); + expect(parsePrivateEmailRequest({ ...request, extra: true })).toBeNull(); + }); + it("captures a completed login and rejects malformed capture messages", () => { document.body.innerHTML = `
diff --git a/apps/extension/src/autofill.ts b/apps/extension/src/autofill.ts index b81fe65..1aef964 100644 --- a/apps/extension/src/autofill.ts +++ b/apps/extension/src/autofill.ts @@ -14,6 +14,7 @@ export const CAPTURE_PENDING_TYPE = "zk-wallet.capture-pending.v1" as const; export const USERNAME_OBSERVED_TYPE = "zk-wallet.username-observed.v1" as const; export const PROFILE_AUTOFILL_REQUEST_TYPE = "zk-wallet.profile-autofill-request.v1" as const; export const PROFILE_AUTOFILL_SELECT_TYPE = "zk-wallet.profile-autofill-select.v1" as const; +export const PRIVATE_EMAIL_REQUEST_TYPE = "zk-wallet.private-email-request.v1" as const; export const CARD_AUTOFILL_REQUEST_TYPE = "zk-wallet.card-autofill-request.v1" as const; export const CARD_AUTOFILL_SELECT_TYPE = "zk-wallet.card-autofill-select.v1" as const; export const SHOW_AUTOFILL_TYPE = "zk-wallet.show-autofill.v1" as const; @@ -119,6 +120,10 @@ export interface ProfileAutofillSelectRequest extends OriginRequest { readonly type: typeof PROFILE_AUTOFILL_SELECT_TYPE; } +export interface PrivateEmailRequest extends OriginRequest { + readonly type: typeof PRIVATE_EMAIL_REQUEST_TYPE; +} + export interface CardAutofillRequest extends OriginRequest { readonly field: CardFieldKind; readonly type: typeof CARD_AUTOFILL_REQUEST_TYPE; @@ -159,6 +164,18 @@ export type CardAutofillResponse = } | { readonly status: "value"; readonly value: string; readonly version: 1 }; +export type PrivateEmailResponse = + | { + readonly status: "disabled" | "locked" | "not-configured" | "unavailable"; + readonly version: 1; + } + | { + readonly address: string; + readonly provider: "addy" | "plus" | "simplelogin"; + readonly status: "value"; + readonly version: 1; + }; + export type AutofillResponse = | { readonly status: "no-match" | "unavailable"; readonly version: 1 } | { readonly status: "filled"; readonly version: 1 } @@ -505,6 +522,16 @@ export function parseProfileAutofillSelectRequest( : null; } +export function parsePrivateEmailRequest(value: unknown): PrivateEmailRequest | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const request = value as Record; + return Object.keys(request).sort().join(",") === "topUrl,type,userInitiated,version" && + request.type === PRIVATE_EMAIL_REQUEST_TYPE && + validOriginRequest(request) + ? (request as unknown as PrivateEmailRequest) + : null; +} + const CARD_FIELDS = new Set([ "billingAddress", "cardNumber", @@ -695,6 +722,68 @@ function registrationHint(input: HTMLInputElement, includeAutocomplete = true): return `${input.id} ${input.name} ${includeAutocomplete ? input.autocomplete : ""} ${input.getAttribute("aria-label") ?? ""} ${formHint}`.toLocaleLowerCase(); } +function registrationFormEvidence(input: HTMLInputElement): { + readonly negative: boolean; + readonly positive: boolean; +} { + const form = input.form; + if (form === null) return { negative: false, positive: false }; + const controls = [ + ...form.querySelectorAll( + 'button, input[type="submit"], [role="button"], h1, h2, legend', + ), + ] + .map((element) => `${element.textContent ?? ""} ${element.getAttribute("value") ?? ""}`) + .join(" "); + const structural = `${form.id} ${form.className} ${form.getAttribute("name") ?? ""} ${form.getAttribute("aria-label") ?? ""} ${form.getAttribute("action") ?? ""}`; + const hint = `${structural} ${controls}`.toLocaleLowerCase(); + return { + negative: + /(?:^|[^a-z])(?:contact|forgot|log[-_\s]*in|newsletter|recover(?:y)?|reset[-_\s]*password|search|sign[-_\s]*in|subscribe)(?:[^a-z]|$)/u.test( + hint, + ), + positive: + /(?:^|[^a-z])(?:create[-_\s]*(?:an?[-_\s]*)?account|get[-_\s]*started|join|register|registration|sign[-_\s]*up|signup)(?:[^a-z]|$)/u.test( + hint, + ), + }; +} + +/** + * Returns true only when an email control belongs to a page that presents strong registration + * evidence. This deliberately rejects generic newsletter, contact, checkout, and login email + * fields so private aliases are never generated merely because a site used autocomplete=email. + */ +export function isRegistrationEmailField(element: Element | null): element is HTMLInputElement { + if ( + !(element instanceof HTMLInputElement) || + !element.isConnected || + element.disabled || + element.readOnly || + element.type === "password" + ) { + return false; + } + const autocomplete = element.autocomplete.trim().split(/\s+/u).at(-1) ?? ""; + const hint = + `${element.type} ${element.name} ${element.id} ${element.placeholder} ${element.getAttribute("aria-label") ?? ""}`.toLocaleLowerCase(); + if (autocomplete !== "email" && element.type !== "email" && !/\be-?mail\b/u.test(hint)) { + return false; + } + const form = element.form; + if (form === null) return false; + if (form.querySelector('input[type="password"][autocomplete="current-password"]') !== null) { + return false; + } + const evidence = registrationFormEvidence(element); + const passwords = [...form.querySelectorAll('input[type="password"]')]; + const hasRegistrationPassword = passwords.some((password) => + isRegistrationPasswordField(password), + ); + if (hasRegistrationPassword) return !evidence.negative; + return evidence.positive && !evidence.negative; +} + export function isRegistrationPasswordField(element: Element | null): element is HTMLInputElement { if ( !(element instanceof HTMLInputElement) || diff --git a/apps/extension/src/googleDrive.test.ts b/apps/extension/src/googleDrive.test.ts index b49bf01..5e08480 100644 --- a/apps/extension/src/googleDrive.test.ts +++ b/apps/extension/src/googleDrive.test.ts @@ -15,6 +15,19 @@ describe("extension Google Drive OAuth boundary", () => { "https://extension-id.chromiumapp.org/oauth/google", ); expect(url.searchParams.get("state")).toBe("expected-state"); + expect(url.searchParams.has("prompt")).toBe(false); + }); + + it("forces account selection only when the user switches Google accounts", () => { + const url = new URL( + buildExtensionGoogleOAuthUrl({ + clientId: "fixture.apps.googleusercontent.com", + redirectUri: "https://extension-id.chromiumapp.org/oauth/google", + selectAccount: true, + state: "expected-state", + }), + ); + expect(url.searchParams.get("prompt")).toBe("select_account"); }); it("strictly validates the returned state and bearer token", () => { diff --git a/apps/extension/src/googleDrive.ts b/apps/extension/src/googleDrive.ts index a2c8ae4..8898c8f 100644 --- a/apps/extension/src/googleDrive.ts +++ b/apps/extension/src/googleDrive.ts @@ -16,6 +16,7 @@ import type { VaultClient, VaultSyncResult } from "@zk-wallet/ui"; import type { VaultClient as CoreVaultClient } from "@zk-wallet/vault"; const DEVICE_ID_KEY = "zk-wallet-extension-device-id-v1"; +const DRIVE_CONNECTED_KEY = "veyrakey-google-drive-connected-v1"; const DRIVE_NAMESPACE_BYTES = 16; const MAX_TOKEN_LENGTH = 8_192; @@ -27,6 +28,7 @@ interface OAuthToken { export function buildExtensionGoogleOAuthUrl(options: { readonly clientId: string; readonly redirectUri: string; + readonly selectAccount?: boolean; readonly state: string; }): string { const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); @@ -38,6 +40,7 @@ export function buildExtensionGoogleOAuthUrl(options: { scope: GOOGLE_DRIVE_APPDATA_SCOPE, state: options.state, }).toString(); + if (options.selectAccount) url.searchParams.set("prompt", "select_account"); return url.href; } @@ -73,6 +76,7 @@ class ExtensionGoogleTokenProvider implements DriveAccessTokenProvider { constructor( readonly clientId: string, readonly redirectUri: string, + readonly selectAccount = false, ) {} async getAccessToken(): Promise { @@ -87,6 +91,7 @@ class ExtensionGoogleTokenProvider implements DriveAccessTokenProvider { url: buildExtensionGoogleOAuthUrl({ clientId: this.clientId, redirectUri: this.redirectUri, + selectAccount: this.selectAccount, state, }), }); @@ -118,14 +123,17 @@ export function withExtensionGoogleDriveSync( ): CoreVaultClient & VaultClient { let tokens: ExtensionGoogleTokenProvider | null = null; let configuredClientId = ""; - const tokenProvider = (clientId: string) => { - if (tokens === null || configuredClientId !== clientId) { + let accountEmail: string | null = null; + const tokenProvider = (clientId: string, selectAccount = false) => { + if (tokens === null || configuredClientId !== clientId || selectAccount) { tokens?.disconnect(); tokens = new ExtensionGoogleTokenProvider( clientId, browser.identity.getRedirectURL("oauth/google"), + selectAccount, ); configuredClientId = clientId; + if (selectAccount) accountEmail = null; } return tokens; }; @@ -135,6 +143,27 @@ export function withExtensionGoogleDriveSync( ...(namespace === undefined ? {} : { namespace }), tokenProvider: tokenProvider(clientId), }); + const readAccountEmail = async (accessToken: string): Promise => { + try { + const response = await globalThis.fetch( + "https://www.googleapis.com/drive/v3/about?fields=user(emailAddress)", + { headers: { authorization: `Bearer ${accessToken}` } }, + ); + if (!response.ok) return null; + const value = (await response.json()) as { + readonly user?: { readonly emailAddress?: unknown }; + }; + const email = value.user?.emailAddress; + return typeof email === "string" && email.length <= 320 ? email : null; + } catch { + return null; + } + }; + const authorize = async (clientId: string, selectAccount = false) => { + const accessToken = await tokenProvider(clientId, selectAccount).getAccessToken(); + accountEmail = await readAccountEmail(accessToken); + return accessToken; + }; const namespace = async (rootKey: Uint8Array, vaultId: string) => { const value = await cryptoProvider.hkdfSha256( rootKey, @@ -154,6 +183,14 @@ export function withExtensionGoogleDriveSync( tokens?.disconnect(); tokens = null; configuredClientId = ""; + accountEmail = null; + localStorage.removeItem(DRIVE_CONNECTED_KEY); + }, + getGoogleDriveAccount() { + return accountEmail; + }, + isGoogleDriveConnected() { + return localStorage.getItem(DRIVE_CONNECTED_KEY) === "true"; }, async restoreFromGoogleDrive(request: { readonly clientId: string; @@ -164,21 +201,44 @@ export function withExtensionGoogleDriveSync( throw new Error("Encrypted archive restore is unavailable"); } const drive = provider(request.clientId.trim()); - await tokenProvider(request.clientId.trim()).getAccessToken(); + await authorize(request.clientId.trim()); const archive = await drive.readEncryptedRecoveryArchive(); if (archive === null) throw new Error("Google Drive recovery archive was not found"); + localStorage.setItem(DRIVE_CONNECTED_KEY, "true"); return await service.restoreEncryptedArchive({ archive, newMasterPassword: request.newMasterPassword, recoveryKit: request.recoveryKit, }); }, - async syncGoogleDrive(request: { readonly clientId: string }): Promise { + async restoreFromGoogleDriveWithMasterPassword(request: { + readonly clientId: string; + readonly masterPassword: string; + readonly selectAccount?: boolean; + }) { + if (service.restoreEncryptedArchiveWithMasterPassword === undefined) { + throw new Error("Master-password archive restore is unavailable"); + } + const clientId = request.clientId.trim(); + await authorize(clientId, request.selectAccount); + const archive = await provider(clientId).readEncryptedRecoveryArchive(); + if (archive === null) throw new Error("Google Drive vault was not found"); + const restored = await service.restoreEncryptedArchiveWithMasterPassword({ + archive, + masterPassword: request.masterPassword, + }); + localStorage.setItem(DRIVE_CONNECTED_KEY, "true"); + return restored; + }, + async syncGoogleDrive(request: { + readonly clientId: string; + readonly selectAccount?: boolean; + }): Promise { if (service.exportSessionMaterial === undefined) throw new Error("Vault session unavailable"); const material = service.exportSessionMaterial(); try { const clientId = request.clientId.trim(); - await tokenProvider(clientId).getAccessToken(); + await authorize(clientId, request.selectAccount); const drive = provider(clientId, await namespace(material.rootKey, material.vaultId)); const result = await syncVaultItems({ codec: createEncryptedVaultSyncCodec(cryptoProvider, material.rootKey, material.vaultId), @@ -192,7 +252,8 @@ export function withExtensionGoogleDriveSync( throw new Error("Encrypted recovery archive export is unavailable"); } await drive.writeEncryptedRecoveryArchive(await service.exportEncryptedArchive()); - return result; + localStorage.setItem(DRIVE_CONNECTED_KEY, "true"); + return { ...result, ...(accountEmail === null ? {} : { accountEmail }) }; } finally { zeroBytes(material.rootKey); } diff --git a/apps/extension/src/manifest.test.ts b/apps/extension/src/manifest.test.ts index bef3500..547735f 100644 --- a/apps/extension/src/manifest.test.ts +++ b/apps/extension/src/manifest.test.ts @@ -24,6 +24,8 @@ describe("extension manifest", () => { expect(extensionManifest.host_permissions).toEqual([ "https://*.googleapis.com/*", "https://api.pwnedpasswords.com/*", + "https://app.simplelogin.io/*", + "https://app.addy.io/*", ]); }); @@ -34,6 +36,8 @@ describe("extension manifest", () => { expect(policy).toContain("object-src 'none'"); expect(policy).toContain("connect-src 'self' https://*.googleapis.com"); expect(policy).toContain("https://api.pwnedpasswords.com"); + expect(policy).toContain("https://app.simplelogin.io"); + expect(policy).toContain("https://app.addy.io"); expect(policy).not.toMatch(/(?:^|[\s;])'unsafe-eval'(?:[\s;]|$)/u); expect(policy).not.toContain("unsafe-inline"); }); diff --git a/apps/extension/src/manifest.ts b/apps/extension/src/manifest.ts index 8a85892..ff9464d 100644 --- a/apps/extension/src/manifest.ts +++ b/apps/extension/src/manifest.ts @@ -8,10 +8,15 @@ export const extensionManifest = { }, content_security_policy: { extension_pages: - "script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://*.googleapis.com https://api.pwnedpasswords.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + "script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://*.googleapis.com https://api.pwnedpasswords.com https://app.simplelogin.io https://app.addy.io; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", }, description: "A browser-first zero-knowledge security and identity wallet.", - host_permissions: ["https://*.googleapis.com/*", "https://api.pwnedpasswords.com/*"], + host_permissions: [ + "https://*.googleapis.com/*", + "https://api.pwnedpasswords.com/*", + "https://app.simplelogin.io/*", + "https://app.addy.io/*", + ], icons: { 16: "icons/icon-16.png", 32: "icons/icon-32.png", diff --git a/apps/extension/src/privateEmail.test.ts b/apps/extension/src/privateEmail.test.ts new file mode 100644 index 0000000..03cbfaf --- /dev/null +++ b/apps/extension/src/privateEmail.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createPlusAddress, + createPrivateEmailAlias, + parsePrivateEmailSettings, +} from "./privateEmail"; + +const random = { randomBytes: (length: number) => new Uint8Array(length).fill(1) }; + +describe("private email aliases", () => { + it("creates a site-scoped plus address without changing the base inbox", () => { + expect( + createPlusAddress("person@example.com", "https://shop.example.test/register", random), + ).toMatch(/^person\+veyrakey-shop-[a-z0-9]+@example\.com$/u); + }); + + it("strictly parses encrypted provider settings", () => { + expect( + parsePrivateEmailSettings({ + autoFill: true, + baseEmail: "person@example.com", + provider: "plus", + version: 1, + }), + ).toMatchObject({ provider: "plus" }); + expect( + parsePrivateEmailSettings({ + apiCode: "secret", + autoFill: true, + provider: "simplelogin", + plaintextToken: "duplicate-secret", + version: 1, + }), + ).toBeNull(); + }); + + it("refuses aliases for insecure or credential-bearing origins", async () => { + await expect( + createPrivateEmailAlias( + { autoFill: true, baseEmail: "person@example.com", provider: "plus", version: 1 }, + "http://shop.example.test/register", + random, + ), + ).rejects.toThrow("exact HTTPS origin"); + await expect( + createPrivateEmailAlias( + { autoFill: true, baseEmail: "person@example.com", provider: "plus", version: 1 }, + "https://user:password@shop.example.test/register", + random, + ), + ).rejects.toThrow("exact HTTPS origin"); + }); + + it("uses SimpleLogin's authenticated random-alias endpoint", async () => { + const fetcher = vi.fn( + async () => + new Response(JSON.stringify({ email: "alias@sl.example", id: 42 }), { status: 201 }), + ); + const alias = await createPrivateEmailAlias( + { apiCode: "api-code", autoFill: true, provider: "simplelogin", version: 1 }, + "https://shop.example.test/register", + random, + fetcher, + ); + expect(alias.address).toBe("alias@sl.example"); + expect(fetcher).toHaveBeenCalledWith( + expect.objectContaining({ hostname: "app.simplelogin.io" }), + expect.objectContaining({ headers: expect.objectContaining({ Authentication: "api-code" }) }), + ); + }); + + it("uses Addy.io's bearer-token alias endpoint", async () => { + const fetcher = vi.fn( + async () => + new Response(JSON.stringify({ data: { email: "alias@addy.example", id: "id-1" } }), { + status: 201, + }), + ); + const alias = await createPrivateEmailAlias( + { + apiToken: "token", + autoFill: true, + domain: "addy.example", + provider: "addy", + version: 1, + }, + "https://shop.example.test/register", + random, + fetcher, + ); + expect(alias).toMatchObject({ address: "alias@addy.example", providerAliasId: "id-1" }); + expect(fetcher).toHaveBeenCalledWith( + "https://app.addy.io/api/v1/aliases", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); +}); diff --git a/apps/extension/src/privateEmail.ts b/apps/extension/src/privateEmail.ts new file mode 100644 index 0000000..69ef6b9 --- /dev/null +++ b/apps/extension/src/privateEmail.ts @@ -0,0 +1,269 @@ +import type { RandomSource } from "@zk-wallet/security"; + +export const PRIVATE_EMAIL_SETTINGS_TAG = "veyrakey:private-email-settings" as const; +export const PRIVATE_EMAIL_SETTINGS_TITLE = "VeyraKey Private Email Settings" as const; +export const PRIVATE_EMAIL_SETTINGS_FOLDER = "VeyraKey System" as const; + +interface CommonSettings { + readonly autoFill: boolean; + readonly version: 1; +} + +export type PrivateEmailSettings = + | (CommonSettings & { readonly baseEmail: string; readonly provider: "plus" }) + | (CommonSettings & { readonly apiCode: string; readonly provider: "simplelogin" }) + | (CommonSettings & { + readonly apiToken: string; + readonly domain: string; + readonly provider: "addy"; + }); + +export interface CreatedPrivateEmailAlias { + readonly address: string; + readonly createdAt: string; + readonly createdForOrigin: string; + readonly provider: PrivateEmailSettings["provider"]; + readonly providerAliasId?: string; + readonly sourceEmail?: string; +} + +export class PrivateEmailError extends Error { + constructor( + readonly code: "CONFIGURATION" | "NETWORK" | "PROVIDER_RESPONSE", + message: string, + ) { + super(message); + this.name = "PrivateEmailError"; + } +} + +function nonEmpty(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= maximum; +} + +function validEmail(value: string): boolean { + const at = value.lastIndexOf("@"); + return at > 0 && at < value.length - 1 && !/\s/u.test(value); +} + +function exactHttpsOrigin(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.username !== "" || url.password !== "") throw new Error(); + return url.origin; + } catch { + throw new PrivateEmailError( + "CONFIGURATION", + "Private email aliases can only be created for an exact HTTPS origin", + ); + } +} + +export function parsePrivateEmailSettings(value: unknown): PrivateEmailSettings | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const candidate = value as Record; + if (candidate.version !== 1 || typeof candidate.autoFill !== "boolean") return null; + if ( + candidate.provider === "plus" && + Object.keys(candidate).every((key) => + ["autoFill", "baseEmail", "provider", "version"].includes(key), + ) && + nonEmpty(candidate.baseEmail, 320) && + validEmail(candidate.baseEmail.trim()) + ) { + return { + autoFill: candidate.autoFill, + baseEmail: candidate.baseEmail.trim(), + provider: "plus", + version: 1, + }; + } + if ( + candidate.provider === "simplelogin" && + Object.keys(candidate).every((key) => + ["apiCode", "autoFill", "provider", "version"].includes(key), + ) && + nonEmpty(candidate.apiCode, 2_048) + ) { + return { + apiCode: candidate.apiCode.trim(), + autoFill: candidate.autoFill, + provider: "simplelogin", + version: 1, + }; + } + if ( + candidate.provider === "addy" && + Object.keys(candidate).every((key) => + ["apiToken", "autoFill", "domain", "provider", "version"].includes(key), + ) && + nonEmpty(candidate.apiToken, 2_048) && + nonEmpty(candidate.domain, 253) && + !/[\s/@]/u.test(candidate.domain) + ) { + return { + apiToken: candidate.apiToken.trim(), + autoFill: candidate.autoFill, + domain: candidate.domain.trim().toLocaleLowerCase(), + provider: "addy", + version: 1, + }; + } + return null; +} + +export function parsePrivateEmailSettingsNote(note: string): PrivateEmailSettings | null { + try { + return parsePrivateEmailSettings(JSON.parse(note)); + } catch { + return null; + } +} + +export function serializePrivateEmailSettings(settings: PrivateEmailSettings): string { + const parsed = parsePrivateEmailSettings(settings); + if (parsed === null) + throw new PrivateEmailError("CONFIGURATION", "Private email settings are invalid"); + return JSON.stringify(parsed); +} + +function randomToken(random: RandomSource, length = 8): string { + const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; + const bytes = random.randomBytes(length); + return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join(""); +} + +function siteSlug(origin: string): string { + try { + return ( + ( + new URL(origin).hostname + .replace(/^www\./u, "") + .split(".") + .at(0) ?? "" + ) + .replace(/[^a-z0-9]+/giu, "-") + .replace(/^-|-$/gu, "") + .toLocaleLowerCase() + .slice(0, 18) || "site" + ); + } catch { + throw new PrivateEmailError("CONFIGURATION", "The website origin is invalid"); + } +} + +export function createPlusAddress(baseEmail: string, origin: string, random: RandomSource): string { + const normalized = baseEmail.trim(); + if (!validEmail(normalized)) { + throw new PrivateEmailError( + "CONFIGURATION", + "A valid base email is required for plus addressing", + ); + } + const at = normalized.lastIndexOf("@"); + const local = normalized.slice(0, at); + const domain = normalized.slice(at + 1); + const suffix = `veyrakey-${siteSlug(origin)}-${randomToken(random)}`; + const available = 64 - local.length - 1; + if (available < 8) { + throw new PrivateEmailError( + "CONFIGURATION", + "The base email local part is too long for plus addressing", + ); + } + return `${local}+${suffix.slice(0, available)}@${domain}`; +} + +function asObject(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +async function providerJson(response: Response): Promise> { + if (!response.ok) { + throw new PrivateEmailError("NETWORK", `Alias provider returned HTTP ${response.status}`); + } + const body = asObject(await response.json().catch(() => null)); + if (body === null) + throw new PrivateEmailError("PROVIDER_RESPONSE", "Alias provider returned invalid JSON"); + return body; +} + +export async function createPrivateEmailAlias( + settings: PrivateEmailSettings, + origin: string, + random: RandomSource, + fetcher: typeof fetch = fetch, + now: () => Date = () => new Date(), +): Promise { + const parsed = parsePrivateEmailSettings(settings); + if (parsed === null) + throw new PrivateEmailError("CONFIGURATION", "Private email settings are invalid"); + const createdAt = now().toISOString(); + const createdForOrigin = exactHttpsOrigin(origin); + if (parsed.provider === "plus") { + return { + address: createPlusAddress(parsed.baseEmail, createdForOrigin, random), + createdAt, + createdForOrigin, + provider: "plus", + sourceEmail: parsed.baseEmail, + }; + } + if (parsed.provider === "simplelogin") { + const endpoint = new URL("https://app.simplelogin.io/api/alias/random/new"); + endpoint.searchParams.set("hostname", new URL(createdForOrigin).hostname); + endpoint.searchParams.set("mode", "word"); + const body = await providerJson( + await fetcher(endpoint, { + body: JSON.stringify({ note: `Created by VeyraKey for ${createdForOrigin}` }), + headers: { Authentication: parsed.apiCode, "Content-Type": "application/json" }, + method: "POST", + }), + ); + if (!nonEmpty(body.email, 320) || !validEmail(body.email)) { + throw new PrivateEmailError( + "PROVIDER_RESPONSE", + "SimpleLogin did not return an alias address", + ); + } + return { + address: body.email, + createdAt, + createdForOrigin, + provider: "simplelogin", + ...(typeof body.id === "number" || typeof body.id === "string" + ? { providerAliasId: String(body.id) } + : {}), + }; + } + const body = await providerJson( + await fetcher("https://app.addy.io/api/v1/aliases", { + body: JSON.stringify({ + description: `Created by VeyraKey for ${createdForOrigin}`, + domain: parsed.domain, + format: "random_words", + }), + headers: { + Accept: "application/json", + Authorization: `Bearer ${parsed.apiToken}`, + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + }, + method: "POST", + }), + ); + const data = asObject(body.data) ?? body; + const address = typeof data.email === "string" ? data.email : data.address; + if (!nonEmpty(address, 320) || !validEmail(address)) { + throw new PrivateEmailError("PROVIDER_RESPONSE", "Addy.io did not return an alias address"); + } + return { + address, + createdAt, + createdForOrigin, + provider: "addy", + ...(typeof data.id === "string" ? { providerAliasId: data.id } : {}), + }; +} diff --git a/apps/extension/src/session.ts b/apps/extension/src/session.ts index 287210f..ca266f4 100644 --- a/apps/extension/src/session.ts +++ b/apps/extension/src/session.ts @@ -168,6 +168,7 @@ export class ExtensionSessionCoordinator { const SESSION_CREATING_METHODS = new Set([ "createVault", "restoreVault", + "restoreEncryptedArchiveWithMasterPassword", "unlock", "unlockWithDevice", "unlockWithRecoveryKit", diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 6eb7ded..655f6ff 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -31,9 +31,7 @@ describe("web app", () => { it("wires the shared vault flow to the web surface", async () => { render(); - expect( - await screen.findByRole("heading", { name: "Create your local vault" }), - ).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Set up your vault" })).toBeInTheDocument(); expect(screen.getByText("Web application")).toBeInTheDocument(); }); }); diff --git a/docs/17-constraints-and-non-goals.md b/docs/17-constraints-and-non-goals.md index d474dd6..8c8090b 100644 --- a/docs/17-constraints-and-non-goals.md +++ b/docs/17-constraints-and-non-goals.md @@ -26,7 +26,7 @@ Recovery uses user-held material. Support staff and server administrators cannot - WebDAV or multiple production sync providers. - Cards, identities, addresses, attachments, payment autofill, and custom fields. -- Software-passkey storage. +- Passkey private-key custody or WebAuthn assertion signing in the WebExtension. - SSH key storage. - Secure Send. - Document storage/intelligence and digital-credential protocols. @@ -41,7 +41,7 @@ Recovery uses user-held material. Support staff and server administrators cannot - Emergency/delegate access. - Server-managed password reset or escrow. - Continuous paid email/dark-web breach monitoring. -- Email aliases/two-way relay until domain and mail-provider architecture is separately approved. +- A project-operated alias domain, SMTP ingress, or two-way email relay. - CCA eSign creation or any paid signing API. - Guaranteed secure deletion from provider backups or recipient devices. - Production SLA on free tiers. @@ -68,5 +68,5 @@ Recovery uses user-held material. Support staff and server administrators cannot - iCloud provider. - Native SSH agent. - Document and digital-credential wallet expansions described in `32-future-work.md`. -- Email alias service after domain/provider/business model decisions. +- A native credential-provider target capable of creating and signing passkeys. - Persistent collaboration only after a separate group-key/access-revocation design. diff --git a/docs/20-progress.md b/docs/20-progress.md index ccc9c18..6fc0719 100644 --- a/docs/20-progress.md +++ b/docs/20-progress.md @@ -363,3 +363,24 @@ Created baseline context documents `00` through `30`, `docs/README.md`, and root artifact/permission/source-map/secret/size/SBOM checks. Fresh 0.10.0 Chrome and Firefox ZIPs were produced. The optional live-CDP smoke rerun was unavailable because no test Chrome was listening on `127.0.0.1:9223`; physical biometrics and live provider consent remain manual gates. +- **Private email and authenticator inventory (2026-08-03):** Added signup-only plus addressing and + optional user-configured SimpleLogin/Addy.io alias creation. Provider secrets are stored only in + an encrypted reserved vault note; generated aliases are exact-origin/tab bound and are attached to + accepted encrypted login saves. Login records now support bounded public passkey references while + explicitly rejecting private-key material; TOTP remains locally generated encrypted MFA data. The + browser does not claim native passkey creation/signing. The complete local gate passes 38 test + files/250 tests, strict typechecking across all workspace packages, Chrome and Firefox MV3 + production builds, and artifact/permission/source-map/secret/size/SBOM verification. Fresh + 0.10.0 Chrome, Firefox, and source ZIPs were produced; physical authenticators and provider + consent remain external verification gates. +- **Account and settings repair (2026-08-03):** Split Private Email, Cloud Sync, Import & Backup, + and Vault Security into mutually exclusive views and added a global semantic-hidden guard so + component display rules cannot expose inactive settings. Setup now clearly offers a no-account + local vault or a Google-connected encrypted vault. A clean device can select the owning Google + account and open the downloaded authenticated archive with the existing master password; account + switching and sign-out are explicit, and sign-out preserves the local encrypted copy. Touch ID + setup is shown only on an unenrolled browser, while enrolled device rows expose status and + individual revocation. Manual passkey metadata forms were removed in favor of the native WebAuthn + boundary. Regression coverage includes isolated settings, account state transitions, OAuth + account selection, existing-password archive restore, wrong-password rollback, and device-action + exclusivity. diff --git a/docs/21-architecture-decisions.md b/docs/21-architecture-decisions.md index 2fb8963..c83d521 100644 --- a/docs/21-architecture-decisions.md +++ b/docs/21-architecture-decisions.md @@ -144,6 +144,30 @@ This is a concise decision log. During implementation, add dated ADR sections wi Content from external sources was rephrased for compliance with licensing restrictions. +## ADR-036: Encrypted private-email configuration and authenticator-held passkey references + +- **Date:** 2026-08-03. +- **Status:** Accepted for the browser release. +- **Decision — aliases:** Plus addressing is the default no-service method after a user supplies a + delivery inbox. Optional SimpleLogin and Addy.io integrations use user-owned tokens and their + documented HTTPS APIs. Provider settings are encrypted as a reserved secure note, excluded from + the visible library, and never placed in the locked suggestion index. Alias generation is limited + to recognized signup email fields after trusted interaction, cached per exact origin/tab, and + recorded with the login only when the normal credential save is accepted. +- **Decision — MFA:** TOTP remains encrypted login content and is calculated locally. +- **Decision — passkeys:** Browser records contain public authenticator references only: RP ID, + account/display labels, authenticator category, optional public credential ID, discoverability, + and creation time. Strict parsing rejects secret/private-key fields. WebAuthn ceremonies remain + with the browser/platform authenticator. +- **Consequences:** The product offers useful signup privacy and a synchronized credential inventory + without operating email infrastructure or making impossible WebExtension custody claims. Plus + aliases depend on mail-provider behavior; third-party aliases depend on the user's provider + account and availability. A true passkey provider remains a separate signed native-platform task. +- **Rejected:** Provider tokens in extension storage, alias generation on login/current-password + forms, overwriting typed addresses, silent password-record creation, storing passkey private keys + as text, intercepting WebAuthn with the remote-desktop proxy API, and describing metadata as an + authenticating credential. + ## ADR-036: Cloud-first portable vault, registration generation, and encrypted identity profiles - **Date:** 2026-07-28. diff --git a/docs/22-research-sources.md b/docs/22-research-sources.md index 2ecc7f4..8f0c909 100644 --- a/docs/22-research-sources.md +++ b/docs/22-research-sources.md @@ -9,6 +9,17 @@ Use primary/official sources first and re-check current versions before implemen ## Browser extension and passkeys +- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) — authenticators manage credential + private keys and assertions; the relying-party/browser surface does not export private keys. +- [Chrome `webAuthenticationProxy`](https://developer.chrome.com/docs/extensions/reference/api/webAuthenticationProxy) — remote-desktop WebAuthn proxy API, not a general password-manager passkey-provider surface. + +## Private email providers + +- [SimpleLogin API documentation](https://github.com/simple-login/app/blob/master/docs/api.md) — API + authentication and random-alias creation contract. +- [Addy.io API documentation](https://app.addy.io/docs/) — bearer authentication and alias creation + contract. + - [WebAuthn Level 3 PRF extension](https://www.w3.org/TR/webauthn-3/#prf-extension) — credential-bound 32-byte PRF outputs, input processing, user verification, and registration/assertion semantics. - [WebAuthn client capabilities](https://www.w3.org/TR/webauthn-3/#sctn-getClientCapabilities) — `extension:` runtime capability reporting; client support alone does not prove authenticator support. - [MDN WebAuthn extensions](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/WebAuthn_extensions#prf) — practical PRF registration/assertion input and output shapes and unsupported behavior. diff --git a/docs/26-security-invariants.md b/docs/26-security-invariants.md index b61d489..64a73c8 100644 --- a/docs/26-security-invariants.md +++ b/docs/26-security-invariants.md @@ -42,33 +42,36 @@ These invariants are mandatory acceptance properties. A change that violates one 27. HTTP, opaque, sandboxed, and cross-origin frame fills are denied by default. 28. Native passkey fallback remains available; software passkeys do not silently downgrade RP security. 29. Permissions remain least-privilege and user-visible. +30. Alias-provider tokens are encrypted vault records; they never enter rebuildable suggestion indexes, logs, or page DOM. +31. Private-email generation is limited to recognized signup email fields and never silently replaces user-entered text. +32. A WebExtension stores only bounded public passkey references. Authenticator private keys and assertion material are never requested, imported, exported, or simulated. ## Document invariants -30. Original bytes and hash are preserved separately from OCR/derivatives. -31. Preview/OCR/parsers do not fetch remote resources based on document content. -32. Plaintext thumbnails/indexes are not persisted. -33. OCR/classification output is untrusted until user-confirmed. -34. Redaction output is flattened/tested; hidden removed content is not retained. -35. Signature validity is distinct from signer trust, status, and claim truth. +33. Original bytes and hash are preserved separately from OCR/derivatives. +34. Preview/OCR/parsers do not fetch remote resources based on document content. +35. Plaintext thumbnails/indexes are not persisted. +36. OCR/classification output is untrusted until user-confirmed. +37. Redaction output is flattened/tested; hidden removed content is not retained. +38. Signature validity is distinct from signer trust, status, and claim truth. ## Credential invariants -36. Original credential bytes/token and exact profile version are preserved. -37. Format adapters do not convert ordinary documents into issuer-backed credentials. -38. Issuer signature, issuer identity, issuer authorization, status, holder binding, and purpose suitability are separate decisions. -39. No silent credential presentation. -40. Presentation shows requested/released claims and correlation handles before consent. -41. Remote credential resources are constrained, validated, cached, and consent/policy controlled. -42. SD-JWT is not described as fully unlinkable. -43. Unsupported attestation/mdoc/DC API/DigiLocker behavior remains disabled and clearly reported. +39. Original credential bytes/token and exact profile version are preserved. +40. Format adapters do not convert ordinary documents into issuer-backed credentials. +41. Issuer signature, issuer identity, issuer authorization, status, holder binding, and purpose suitability are separate decisions. +42. No silent credential presentation. +43. Presentation shows requested/released claims and correlation handles before consent. +44. Remote credential resources are constrained, validated, cached, and consent/policy controlled. +45. SD-JWT is not described as fully unlinkable. +46. Unsupported attestation/mdoc/DC API/DigiLocker behavior remains disabled and clearly reported. ## Server and privacy invariants -44. D1/R2 never contain decryptable vault content or transfer secrets. -45. BYOS OAuth tokens/WebDAV credentials remain client-side. -46. Logs/telemetry contain no sensitive payloads, user origins, secrets, claims, filenames, or raw parser errors. -47. Secure Send keys remain in URL fragment/out-of-band and never reach server requests. -48. Relay expiry/deletion is not marketed as revoking downloaded copies. -49. Cloud AI/third-party OCR never receives document content. -50. Marketing/security UX accurately states zero-knowledge, verification, audit, and free-tier limitations. +47. D1/R2 never contain decryptable vault content or transfer secrets. +48. BYOS OAuth tokens/WebDAV credentials remain client-side. +49. Logs/telemetry contain no sensitive payloads, user origins, secrets, claims, filenames, or raw parser errors. +50. Secure Send keys remain in URL fragment/out-of-band and never reach server requests. +51. Relay expiry/deletion is not marketed as revoking downloaded copies. +52. Cloud AI/third-party OCR never receives document content. +53. Marketing/security UX accurately states zero-knowledge, verification, audit, and free-tier limitations. diff --git a/docs/36-enterprise-password-manager-status.md b/docs/36-enterprise-password-manager-status.md index 2ece78a..d3e9986 100644 --- a/docs/36-enterprise-password-manager-status.md +++ b/docs/36-enterprise-password-manager-status.md @@ -35,6 +35,13 @@ release contract, not a marketing wish list. keyboard-visible controls. - Weak, reused, and breached recommendations are actionable. Password age is deliberately not inferred from item edit time, so the product does not display a misleading “Old” classification. +- Signup-only private-email AutoFill uses plus addressing by default after the user configures a + delivery inbox, or a user-owned SimpleLogin/Addy.io API token. Configuration is stored as an + encrypted system note, generated aliases are origin-bound, and an accepted credential save + records the alias with that encrypted login. +- Login items may hold TOTP seeds and bounded public references to passkeys held by a platform, + security key, or external provider. The manager never represents a reference as a usable private + passkey. ## Deliberate security boundaries @@ -57,7 +64,8 @@ Full passkey creation, storage, signing, conditional suggestions, and cross-devi behavior require a native credential-provider target, platform entitlements, RP/origin validation, secure private-key storage, signature-counter policy, browser registration, and a native fallback. The WebExtension therefore detects and leaves existing site/browser passkey flows intact; it does -not intercept WebAuthn or claim to store passkey private keys. +not intercept WebAuthn or claim to store passkey private keys. It records public RP/account/device +metadata only, so users can inventory authenticator-held credentials alongside passwords and TOTP. The next valid implementation target is: diff --git a/docs/39-private-email-and-passkey-boundary.md b/docs/39-private-email-and-passkey-boundary.md new file mode 100644 index 0000000..3e46773 --- /dev/null +++ b/docs/39-private-email-and-passkey-boundary.md @@ -0,0 +1,100 @@ +# Private Email and Passkey Boundary + +Date: 2026-08-03 + +This document is the release contract for private-email aliases, TOTP, and passkey records. It +separates browser functionality from native credential-provider work that a WebExtension cannot +honestly provide. + +## Private email + +The extension supports three user-selected methods: + +1. **Plus addressing.** The user configures one delivery inbox. On a recognized signup form the + client generates an origin-labelled address such as `name+signup-example-random@domain`. This + requires the user's mail provider to support plus delivery; the project operates no mail server. +2. **SimpleLogin.** The user supplies their own API token. The extension requests a random alias + through SimpleLogin's documented API. +3. **Addy.io.** The user supplies their own API token and alias domain. The extension creates an + alias through Addy.io's documented API. + +Provider selection and tokens are stored in a reserved secure note. The note uses the same +independently keyed, authenticated, immutable encryption and BYOS synchronization as other vault +items. It is excluded from the user library and never copied into browser storage, the locked +suggestion index, logs, or page DOM. + +Generation occurs only for a trusted user interaction on an HTTPS top-level page and only when the +field classifier recognizes an email field in a registration/new-password form. Existing non-empty +field content is not overwritten. One generated address is cached briefly per tab and exact origin +to prevent repeated provider calls during dynamic form replacement. When the user approves the +normal credential-save prompt, the alias address, provider, creation time, and origin are stored +with that encrypted login. Declining the credential save does not silently create a password record. + +Plus addressing is convenient but is not sender isolation: sites can remove the `+tag`, the delivery +inbox remains visible in the address, and not every provider supports it. SimpleLogin/Addy.io provide +actual aliases and forwarding, but availability, retention, abuse controls, and account recovery +belong to those user-selected services. + +## MFA and passkeys + +TOTP already works as encrypted `otpauth://` data attached to a login. The client derives the +short-lived verification code locally; the seed is never sent to the application server. + +Passkey records in the WebExtension are deliberately **references**, not credentials. A login can +record the RP ID, account label, authenticator type, optional public credential ID, discoverability, +and creation time. Strict schema validation rejects unknown fields and secret/private-key-shaped +payloads. These records help users find which device or provider owns a passkey and synchronize that +inventory as encrypted vault data. + +WebAuthn authenticators create and retain passkey private keys. The browser API does not export those +keys to an extension. Chrome's `webAuthenticationProxy` API is intended for remote-desktop proxying +and suspends the browser's native WebAuthn handling; it is not a general password-manager provider +surface. This extension therefore does not intercept registration/assertion ceremonies, synthesize +signatures, or claim that a public reference can authenticate. + +A real cross-device passkey provider requires a separately signed native credential-provider target +for each supported platform, secure private-key custody, RP/origin and user-verification policy, +platform entitlements/registration, an authenticated native bridge to encrypted sync, and physical +device testing. Until that component exists, the browser leaves native passkeys fully available. + +The manager deliberately does not expose RP IDs, credential IDs, or authenticator metadata as a +setup form. Passkey creation and approval stay in the website/browser's native WebAuthn sheet, where +Touch ID, Windows Hello, or the selected security key performs user verification. VeyraKey may show +an already-linked public reference beside the encrypted login, but it never asks the user to paste a +private credential or pretends that the reference can sign in. + +## Vault and account modes + +VeyraKey has two explicit storage states rather than a mandatory application account: + +1. **Local vault.** No registration or OAuth is required. The encrypted vault remains in this + browser profile and can be migrated to Google Drive later. +2. **Google-connected vault.** Google OAuth grants access only to the hidden Drive app-data area. + VeyraKey uploads authenticated encrypted archive data, never the master password or plaintext + keys. Sync, account switching, and sign-out are separate user actions; signing out leaves the + local encrypted copy intact. + +On a clean device, the user chooses the Google account that owns the app-data archive and supplies +the existing vault master password. Google locates and authorizes the ciphertext; the existing +password decrypts and authenticates it locally. There is no VeyraKey server account or server-side +password reset. A Recovery Kit remains the fallback for an unavailable or forgotten master +password. + +Biometric enrollment is also local to one browser profile. An unenrolled device shows one setup +action. An enrolled device shows its active status and a per-device revoke action instead of a +second redundant setup button. Biometric material is never synchronized through Drive; every new +device enrolls independently after the encrypted vault is opened. + +## Verification + +- Strict settings, provider-response, plus-address, message-schema, alias-metadata, and passkey- + reference tests. +- Manifest permission/CSP tests for the two optional provider APIs. +- Full workspace unit/integration suite, strict TypeScript checks, Chrome/Firefox MV3 builds, and the + existing release scanners. +- UI regressions for isolated settings panels, local/no-account setup, Google account switching and + sign-out, existing-password clean-device restore, and mutually exclusive device setup/revocation. +- Archive integration proves that a clean device can restore every encrypted revision with the + existing master password and that a wrong password leaves no partial local vault. +- Live provider consent, provider quotas, physical biometric ceremonies, and native credential- + provider signing remain external release gates and cannot be manufactured by unit tests. diff --git a/packages/persistence/src/archive.integration.test.ts b/packages/persistence/src/archive.integration.test.ts index c2cc69a..5e78638 100644 --- a/packages/persistence/src/archive.integration.test.ts +++ b/packages/persistence/src/archive.integration.test.ts @@ -93,4 +93,57 @@ describe("atomic encrypted archive recovery", () => { ).rejects.toBeDefined(); await expect(new IndexedDbVaultHeaderRepository({ databaseName }).read()).resolves.toBeNull(); }); + + it("opens the same encrypted archive on a clean device with the existing master password", async () => { + const source = service(`archive-password-source-${crypto.randomUUID()}`); + const created = await source.createVault("existing master password"); + if (created.status !== "unlocked" || created.recovery.status !== "pending") { + throw new Error("Expected Recovery Kit"); + } + await source.verifyRecoveryKit(created.recovery.recoveryKit); + await source.createLogin({ + notes: "", + password: "device-two-secret", + title: "Multi-device Example", + uris: ["https://multi-device.example.test"], + username: "person@example.test", + }); + const archive = await source.exportEncryptedArchive?.(); + if (archive === undefined) throw new Error("Expected archive export"); + + const restored = service(`archive-password-target-${crypto.randomUUID()}`); + await expect( + restored.restoreEncryptedArchiveWithMasterPassword?.({ + archive, + masterPassword: "existing master password", + }), + ).resolves.toMatchObject({ status: "unlocked" }); + await expect(restored.listItems()).resolves.toEqual([ + expect.objectContaining({ + password: "device-two-secret", + title: "Multi-device Example", + }), + ]); + restored.lock(); + await expect(restored.unlock("existing master password")).resolves.toMatchObject({ + status: "unlocked", + }); + }); + + it("does not create a local vault when the existing master password is wrong", async () => { + const source = service(`archive-wrong-password-source-${crypto.randomUUID()}`); + await source.createVault("correct existing password"); + const archive = await source.exportEncryptedArchive?.(); + if (archive === undefined) throw new Error("Expected archive export"); + const databaseName = `archive-wrong-password-target-${crypto.randomUUID()}`; + const target = service(databaseName); + + await expect( + target.restoreEncryptedArchiveWithMasterPassword?.({ + archive, + masterPassword: "wrong password", + }), + ).rejects.toBeDefined(); + await expect(new IndexedDbVaultHeaderRepository({ databaseName }).read()).resolves.toBeNull(); + }); }); diff --git a/packages/ui/src/VaultScreen.lifecycle.test.tsx b/packages/ui/src/VaultScreen.lifecycle.test.tsx index f187807..a53aff8 100644 --- a/packages/ui/src/VaultScreen.lifecycle.test.tsx +++ b/packages/ui/src/VaultScreen.lifecycle.test.tsx @@ -62,9 +62,7 @@ describe("VaultScreen lifecycle safety", () => { fireEvent.click(screen.getByRole("button", { name: "Retry vault loading" })); - expect( - await screen.findByRole("heading", { name: "Create your local vault" }), - ).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Set up your vault" })).toBeInTheDocument(); expect(client.initialize).toHaveBeenCalledTimes(2); }); diff --git a/packages/ui/src/VaultScreen.task3.test.tsx b/packages/ui/src/VaultScreen.task3.test.tsx index 1a25100..037d340 100644 --- a/packages/ui/src/VaultScreen.task3.test.tsx +++ b/packages/ui/src/VaultScreen.task3.test.tsx @@ -76,7 +76,7 @@ describe("VaultScreen Task 3 flows", () => { it("blocks normal use on a one-time Recovery Kit display until the explicit drill succeeds", async () => { const { client, mocks } = task3Client({ status: "needs-setup" }); render(); - await screen.findByRole("heading", { name: "Create your local vault" }); + await screen.findByRole("heading", { name: "Set up your vault" }); fireEvent.change(screen.getByLabelText("Master password"), { target: { value: "correct horse battery staple" }, @@ -150,7 +150,7 @@ describe("VaultScreen Task 3 flows", () => { ); render(); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Vault Security" })); expect(screen.getByText(/document compartment is sealed/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Unlock document compartment" })); @@ -253,7 +253,7 @@ describe("VaultScreen Task 3 flows", () => { const { client, emit } = task3Client(unlockedState); render(); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Vault Security" })); fireEvent.change(screen.getByLabelText("Master password for device enrollment"), { target: { value: "enrollment secret" }, @@ -280,7 +280,7 @@ describe("VaultScreen Task 3 flows", () => { await screen.findByRole("heading", { name: "Replace your Recovery Kit" }); emit(unlockedState); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Vault Security" })); expect(screen.getByLabelText("Master password for device enrollment")).toHaveValue(""); fireEvent.change(screen.getByLabelText("Master password for device enrollment"), { target: { value: "second enrollment secret" }, @@ -294,7 +294,7 @@ describe("VaultScreen Task 3 flows", () => { await screen.findByRole("heading", { name: "Unlock" }); emit(unlockedState); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Vault Security" })); expect(screen.getByLabelText("Master password for device enrollment")).toHaveValue(""); fireEvent.click(screen.getByRole("button", { name: "Unlock document compartment" })); @@ -306,7 +306,7 @@ describe("VaultScreen Task 3 flows", () => { mocks.enrollDevice.mockRejectedValueOnce(new Error("enrollment failed")); render(); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Vault Security" })); const enrollmentPassword = screen.getByLabelText("Master password for device enrollment"); fireEvent.change(enrollmentPassword, { target: { value: "temporary secret" } }); @@ -323,7 +323,7 @@ describe("VaultScreen Task 3 flows", () => { const { client, mocks } = task3Client(state); render(); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Vault Security" })); fireEvent.change(screen.getByLabelText("Current master password"), { target: { value: "old password" }, @@ -342,10 +342,9 @@ describe("VaultScreen Task 3 flows", () => { }), ); - expect( - screen.getByText(/revocation prevents future use after updated state is available/i), - ).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Revoke enrolled device" })); + expect(screen.getByText(/Active · available for local vault unlock/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Set up Touch ID" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); await waitFor(() => expect(mocks.revokeDevice).toHaveBeenCalledWith("device-slot-1")); }); }); diff --git a/packages/ui/src/VaultScreen.test.tsx b/packages/ui/src/VaultScreen.test.tsx index 0cc0c85..726e771 100644 --- a/packages/ui/src/VaultScreen.test.tsx +++ b/packages/ui/src/VaultScreen.test.tsx @@ -104,9 +104,7 @@ describe("VaultScreen", () => { ); expect(screen.getByRole("status")).toHaveTextContent(/preparing vault/i); - expect( - await screen.findByRole("heading", { name: "Create your local vault" }), - ).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Set up your vault" })).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("Master password"), { target: { value: "correct horse battery staple" }, @@ -132,7 +130,7 @@ describe("VaultScreen", () => { it("prevents mismatched confirmation without invoking cryptography", async () => { const vaultClient = client("needs-setup"); render(); - await screen.findByRole("heading", { name: "Create your local vault" }); + await screen.findByRole("heading", { name: "Set up your vault" }); fireEvent.change(screen.getByLabelText("Master password"), { target: { value: "one password" }, @@ -162,11 +160,11 @@ describe("VaultScreen", () => { surface="Browser extension" />, ); - await screen.findByRole("heading", { name: "Create your local vault" }); + await screen.findByRole("heading", { name: "Set up your vault" }); - expect(screen.getByRole("radio", { name: /Google Drive/u })).toBeChecked(); - fireEvent.click(screen.getByRole("radio", { name: /This device only/u })); - expect(screen.getByText(/Local-only mode is selected/u)).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /Continue with Google/u })).toBeChecked(); + fireEvent.click(screen.getByRole("radio", { name: /Use without an account/u })); + expect(screen.getByText(/No registration or cloud connection/u)).toBeInTheDocument(); }); it("collapses wrong-password and corruption failures into a safe unlock error", async () => { @@ -490,7 +488,7 @@ describe("VaultScreen", () => { vaultClient.importItems = importItems; render(); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Import & Backup" })); fireEvent.click(screen.getByText("Import passwords")); fireEvent.change(screen.getByLabelText("File contents"), { @@ -538,18 +536,123 @@ describe("VaultScreen", () => { />, ); await screen.findByRole("heading", { name: "Passwords" }); - fireEvent.click(screen.getByRole("button", { name: "Cloud & Data" })); + fireEvent.click(screen.getByRole("button", { name: "Cloud Sync" })); - const connect = screen.getByRole("button", { name: "Sync or migrate to Google Drive" }); + const connect = screen.getByRole("button", { name: "Continue with Google" }); expect(connect).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Sign out" })).toBeNull(); fireEvent.click(connect); await waitFor(() => expect(syncGoogleDrive).toHaveBeenCalledWith({ clientId: "fixture.apps.googleusercontent.com", + selectAccount: true, }), ); expect(await screen.findByText(/2 encrypted revision\(s\), 1 uploaded/u)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sync now" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Use another Google account" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Continue with Google" })).toBeNull(); + }); + + it("shows only sync and disconnect when Google Drive is already connected", async () => { + const vaultClient = client("unlocked"); + vaultClient.isGoogleDriveConnected = () => true; + vaultClient.syncGoogleDrive = vi.fn(async () => ({ + conflicts: [], + itemCount: 0, + quarantined: 0, + revisionCount: 0, + uploaded: 0, + })); + vaultClient.disconnectGoogleDrive = vi.fn(); + render( + , + ); + await screen.findByRole("heading", { name: "Passwords" }); + fireEvent.click(screen.getByRole("button", { name: "Cloud Sync" })); + + expect(screen.getByRole("button", { name: "Sync now" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Use another Google account" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Continue with Google" })).toBeNull(); + }); + + it("keeps Private Email, Cloud Sync, and Import & Backup in separate settings views", async () => { + const vaultClient = client("unlocked"); + vaultClient.syncGoogleDrive = vi.fn(async () => ({ + conflicts: [], + itemCount: 0, + quarantined: 0, + revisionCount: 0, + uploaded: 0, + })); + render( + , + ); + await screen.findByRole("heading", { name: "Passwords" }); + + fireEvent.click(screen.getByRole("button", { name: "Private Email" })); + expect(screen.getByRole("heading", { name: "Private Email" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Google Drive" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Import & Backup" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Cloud Sync" })); + expect(screen.getByRole("heading", { name: "Google Drive" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Private Email" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Import & Backup" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Import & Backup" })); + expect(screen.getByRole("heading", { name: "Import & Backup" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Private Email" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Google Drive" })).toBeNull(); + }); + + it("exposes passkeys and authenticator codes as a first-class vault section", async () => { + const vaultClient = client("unlocked"); + const listItems = vaultClient.listItems; + if (listItems === undefined) throw new Error("Expected listItems support"); + vi.mocked(listItems).mockResolvedValue([ + { + createdAt: "2026-08-01T00:00:00.000Z", + id: "secured-login", + notes: "", + password: "synthetic-password", + passkeys: [ + { + createdAt: "2026-08-01T00:00:00.000Z", + displayName: "Work laptop", + provider: "platform", + rpId: "accounts.example.test", + userName: "person@example.test", + }, + ], + revisionId: "revision", + title: "Example account", + totpUri: "otpauth://totp/Example:person?secret=JBSWY3DPEHPK3PXP&issuer=Example", + type: "login", + updatedAt: "2026-08-01T00:00:00.000Z", + uris: ["https://accounts.example.test"], + username: "person@example.test", + }, + ]); + render(); + await screen.findByRole("heading", { name: "Passwords" }); + fireEvent.click(screen.getByRole("button", { name: "Passkeys & MFA" })); + + expect(screen.getByRole("heading", { name: "Passkeys & MFA" })).toBeInTheDocument(); + expect(screen.getByText("Work laptop")).toBeInTheDocument(); + expect(screen.getByText(/accounts\.example\.test · platform/u)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show code" })).toBeInTheDocument(); }); it("restores a clean profile directly from the encrypted Drive recovery archive", async () => { @@ -564,8 +667,9 @@ describe("VaultScreen", () => { surface="Web application" />, ); - await screen.findByRole("heading", { name: "Create your local vault" }); - fireEvent.click(screen.getByRole("button", { name: "Restore from encrypted BYOS state" })); + await screen.findByRole("heading", { name: "Set up your vault" }); + fireEvent.click(screen.getByRole("button", { name: "I already have a vault" })); + fireEvent.click(screen.getByText("Use a Recovery Kit or encrypted backup")); fireEvent.change(screen.getByLabelText("Recovery Kit"), { target: { value: "recovery-fixture" }, @@ -576,7 +680,7 @@ describe("VaultScreen", () => { fireEvent.change(screen.getByLabelText("Confirm new master password"), { target: { value: "new-password" }, }); - fireEvent.click(screen.getByRole("button", { name: "Restore directly from Google Drive" })); + fireEvent.click(screen.getByRole("button", { name: "Recover Google vault with Recovery Kit" })); await waitFor(() => expect(restoreFromGoogleDrive).toHaveBeenCalledWith({ @@ -587,4 +691,33 @@ describe("VaultScreen", () => { ); expect(await screen.findByRole("heading", { name: "Passwords" })).toBeInTheDocument(); }); + + it("opens an existing Google vault with its current master password", async () => { + const vaultClient = client("needs-setup"); + const restoreFromGoogleDriveWithMasterPassword = vi.fn(async () => viewState("unlocked")); + vaultClient.restoreFromGoogleDriveWithMasterPassword = restoreFromGoogleDriveWithMasterPassword; + render( + , + ); + await screen.findByRole("heading", { name: "Set up your vault" }); + fireEvent.click(screen.getByRole("button", { name: "I already have a vault" })); + + fireEvent.change(screen.getByLabelText("Existing master password"), { + target: { value: "existing-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Sign in with Google and open vault" })); + + await waitFor(() => + expect(restoreFromGoogleDriveWithMasterPassword).toHaveBeenCalledWith({ + clientId: "fixture.apps.googleusercontent.com", + masterPassword: "existing-password", + selectAccount: true, + }), + ); + expect(await screen.findByRole("heading", { name: "Passwords" })).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/VaultScreen.tsx b/packages/ui/src/VaultScreen.tsx index 9dc595c..70620e7 100644 --- a/packages/ui/src/VaultScreen.tsx +++ b/packages/ui/src/VaultScreen.tsx @@ -91,6 +91,25 @@ export interface PaymentCardInputView { readonly title: string; } +export interface LoginEmailAliasView { + readonly address: string; + readonly createdAt: string; + readonly createdForOrigin: string; + readonly provider: "addy" | "plus" | "simplelogin"; + readonly providerAliasId?: string; + readonly sourceEmail?: string; +} + +export interface PasskeyReferenceView { + readonly createdAt: string; + readonly credentialId?: string; + readonly discoverable?: boolean; + readonly displayName: string; + readonly provider: "external" | "platform" | "security-key"; + readonly rpId: string; + readonly userName: string; +} + export interface VaultItemHistoryEntryView { readonly item: VaultItemView | null; readonly operation: "create" | "delete" | "update"; @@ -119,9 +138,11 @@ export interface VaultClient { | { readonly checkedAt: string; readonly count: number; readonly status: "found" } | { readonly checkedAt: string; readonly status: "not-found" | "unavailable" }; readonly favorite?: boolean; + readonly emailAlias?: LoginEmailAliasView; readonly folder?: string; readonly notes: string; readonly password: string; + readonly passkeys?: readonly PasskeyReferenceView[]; readonly tags?: readonly string[]; readonly title: string; readonly totpUri?: string; @@ -143,6 +164,8 @@ export interface VaultClient { exportEncryptedArchive?(): Promise; getState(): VaultViewState; initialize(): Promise; + isGoogleDriveConnected?(): boolean; + getGoogleDriveAccount?(): string | null; importItems?( items: readonly ( | { @@ -178,11 +201,20 @@ export interface VaultClient { readonly newMasterPassword: string; readonly recoveryKit: string; }): Promise; + restoreEncryptedArchiveWithMasterPassword?(request: { + readonly archive: unknown; + readonly masterPassword: string; + }): Promise; restoreFromGoogleDrive?(request: { readonly clientId: string; readonly newMasterPassword: string; readonly recoveryKit: string; }): Promise; + restoreFromGoogleDriveWithMasterPassword?(request: { + readonly clientId: string; + readonly masterPassword: string; + readonly selectAccount?: boolean; + }): Promise; restoreFromOneDrive?(request: { readonly clientId: string; readonly newMasterPassword: string; @@ -200,7 +232,10 @@ export interface VaultClient { credential: StepUpCredential, ): Promise; subscribe(listener: (state: VaultViewState) => void): () => void; - syncGoogleDrive?(request: { readonly clientId: string }): Promise; + syncGoogleDrive?(request: { + readonly clientId: string; + readonly selectAccount?: boolean; + }): Promise; syncOneDrive?(request: { readonly clientId: string }): Promise; unlock(masterPassword: string): Promise; unlockWithDevice(slotId: string): Promise; @@ -213,9 +248,11 @@ export interface VaultClient { | { readonly checkedAt: string; readonly count: number; readonly status: "found" } | { readonly checkedAt: string; readonly status: "not-found" | "unavailable" }; readonly favorite?: boolean; + readonly emailAlias?: LoginEmailAliasView; readonly folder?: string; readonly notes: string; readonly password: string; + readonly passkeys?: readonly PasskeyReferenceView[]; readonly tags?: readonly string[]; readonly title: string; readonly totpUri?: string; @@ -248,6 +285,7 @@ export interface VaultClient { } export interface VaultSyncResult { + readonly accountEmail?: string; readonly conflicts: readonly { readonly itemId: string; readonly revisionIds: readonly string[]; @@ -278,11 +316,13 @@ export type VaultItemView = | { readonly checkedAt: string; readonly count: number; readonly status: "found" } | { readonly checkedAt: string; readonly status: "not-found" | "unavailable" }; readonly createdAt: string; + readonly emailAlias?: LoginEmailAliasView; readonly favorite?: boolean; readonly folder?: string; readonly id: string; readonly notes: string; readonly password: string; + readonly passkeys?: readonly PasskeyReferenceView[]; readonly revisionId: string; readonly tags?: readonly string[]; readonly title: string; @@ -369,6 +409,59 @@ const PRIMARY_PROFILE_FIELD_KEYS = new Set([ "phone", ]); +const PRIVATE_EMAIL_SETTINGS_TAG = "veyrakey:private-email-settings"; +const PRIVATE_EMAIL_SETTINGS_TITLE = "VeyraKey Private Email Settings"; +const PRIVATE_EMAIL_SETTINGS_FOLDER = "VeyraKey System"; + +type PrivateEmailProvider = "addy" | "plus" | "simplelogin"; + +function readPrivateEmailSettings(note: string): { + readonly apiSecret: string; + readonly autoFill: boolean; + readonly baseEmail: string; + readonly domain: string; + readonly provider: PrivateEmailProvider; +} | null { + try { + const value = JSON.parse(note) as Record; + if (value.version !== 1 || typeof value.autoFill !== "boolean") return null; + if (value.provider === "plus" && typeof value.baseEmail === "string") { + return { + apiSecret: "", + autoFill: value.autoFill, + baseEmail: value.baseEmail, + domain: "", + provider: "plus", + }; + } + if (value.provider === "simplelogin" && typeof value.apiCode === "string") { + return { + apiSecret: value.apiCode, + autoFill: value.autoFill, + baseEmail: "", + domain: "", + provider: "simplelogin", + }; + } + if ( + value.provider === "addy" && + typeof value.apiToken === "string" && + typeof value.domain === "string" + ) { + return { + apiSecret: value.apiToken, + autoFill: value.autoFill, + baseEmail: "", + domain: value.domain, + provider: "addy", + }; + } + return null; + } catch { + return null; + } +} + export interface VaultScreenProps { readonly client: VaultClient; readonly providerConfiguration?: { @@ -519,6 +612,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr defaultSetupDestination, ); const [encryptedByosState, setEncryptedByosState] = useState(""); + const [googleRestorePassword, setGoogleRestorePassword] = useState(""); const [restorePassword, setRestorePassword] = useState(""); const [restoreConfirmation, setRestoreConfirmation] = useState(""); const [stepUpCompartment, setStepUpCompartment] = useState(null); @@ -551,6 +645,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr const [itemUris, setItemUris] = useState(""); const [itemBody, setItemBody] = useState(""); const [itemTotpUri, setItemTotpUri] = useState(""); + const [itemPasskeys, setItemPasskeys] = useState([]); const [itemFolder, setItemFolder] = useState(""); const [itemTags, setItemTags] = useState(""); const [itemFavorite, setItemFavorite] = useState(false); @@ -575,8 +670,22 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr const googleClientId = providerConfiguration?.googleClientId?.trim() ?? ""; const microsoftClientId = providerConfiguration?.microsoftClientId?.trim() ?? ""; const [driveStatus, setDriveStatus] = useState(""); + const [googleDriveConnected, setGoogleDriveConnected] = useState( + () => client.isGoogleDriveConnected?.() ?? false, + ); + const [googleDriveAccount, setGoogleDriveAccount] = useState( + () => client.getGoogleDriveAccount?.() ?? null, + ); const [oneDriveStatus, setOneDriveStatus] = useState(""); - const [activeView, setActiveView] = useState<"settings" | "tools" | "vault">("vault"); + const [privateEmailProvider, setPrivateEmailProvider] = useState("plus"); + const [privateEmailBase, setPrivateEmailBase] = useState(""); + const [privateEmailApiSecret, setPrivateEmailApiSecret] = useState(""); + const [privateEmailDomain, setPrivateEmailDomain] = useState(""); + const [privateEmailAutoFill, setPrivateEmailAutoFill] = useState(true); + const [privateEmailStatus, setPrivateEmailStatus] = useState(""); + const [activeView, setActiveView] = useState< + "authenticators" | "cloud" | "data" | "private-email" | "settings" | "tools" | "vault" + >("vault"); const recoveryContext = screenState.status === "unlocked" ? screenState.recovery.status : null; const itemRecoveryStatus = screenState.status === "unlocked" ? screenState.recovery.status : null; @@ -677,6 +786,21 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setHealthFindings(Object.fromEntries(findings.map((finding) => [finding.id, finding]))); }, [items]); + useEffect(() => { + const settingsItem = items.find( + (item): item is Extract => + item.type === "secure-note" && item.tags?.includes(PRIVATE_EMAIL_SETTINGS_TAG) === true, + ); + if (settingsItem === undefined) return; + const settings = readPrivateEmailSettings(settingsItem.note); + if (settings === null) return; + setPrivateEmailProvider(settings.provider); + setPrivateEmailBase(settings.baseEmail); + setPrivateEmailApiSecret(settings.apiSecret); + setPrivateEmailDomain(settings.domain); + setPrivateEmailAutoFill(settings.autoFill); + }, [items]); + async function createVault(event: FormEvent) { event.preventDefault(); setError(null); @@ -804,6 +928,43 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr } } + async function restoreGoogleDriveWithMasterPassword() { + if ( + client.restoreFromGoogleDriveWithMasterPassword === undefined || + googleClientId.trim().length === 0 + ) { + setError("Google Drive restore is not configured in this app build."); + return; + } + if (googleRestorePassword.length === 0) { + setError("Enter the existing master password for this vault."); + return; + } + setOperation("restore"); + setError(null); + try { + const next = await client.restoreFromGoogleDriveWithMasterPassword({ + clientId: googleClientId.trim(), + masterPassword: googleRestorePassword, + selectAccount: true, + }); + setGoogleRestorePassword(""); + setScreenState(next); + setGoogleDriveConnected(true); + setGoogleDriveAccount(client.getGoogleDriveAccount?.() ?? null); + setShowRestore(false); + } catch (error) { + setError( + operationError( + error, + "Unable to open the Google Drive vault. Check the selected Google account and master password.", + ), + ); + } finally { + setOperation(null); + } + } + async function restoreOneDrive() { if (client.restoreFromOneDrive === undefined || microsoftClientId.trim().length === 0) { setError("OneDrive is not configured in this app build."); @@ -853,6 +1014,8 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setDriveStatus("Connecting the new encrypted vault to Google Drive…"); try { const result = await client.syncGoogleDrive({ clientId: googleClientId }); + setGoogleDriveConnected(true); + setGoogleDriveAccount(result.accountEmail ?? client.getGoogleDriveAccount?.() ?? null); setDriveStatus( `Cloud vault ready: ${result.revisionCount} encrypted revision(s), ${result.uploaded} uploaded.`, ); @@ -1090,6 +1253,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setItemUris(""); setItemBody(""); setItemTotpUri(""); + setItemPasskeys([]); setItemFolder(""); setItemTags(""); setItemFavorite(false); @@ -1112,6 +1276,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setItemUris(item.uris.join("\n")); setItemBody(item.notes); setItemTotpUri(item.totpUri ?? ""); + setItemPasskeys(item.passkeys ?? []); setProfileFields(EMPTY_PROFILE); setPaymentCard(EMPTY_PAYMENT_CARD); } else if (item.type === "identity-profile") { @@ -1120,6 +1285,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setItemUris(""); setItemBody(""); setItemTotpUri(""); + setItemPasskeys([]); setProfileFields( Object.fromEntries( PROFILE_FIELDS.map(({ key }) => [key, item[key]]), @@ -1132,6 +1298,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setItemUris(""); setItemBody(""); setItemTotpUri(""); + setItemPasskeys([]); setProfileFields(EMPTY_PROFILE); setPaymentCard({ billingAddress: item.billingAddress, @@ -1149,6 +1316,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setItemUris(""); setItemBody(item.note); setItemTotpUri(""); + setItemPasskeys([]); setProfileFields(EMPTY_PROFILE); setPaymentCard(EMPTY_PAYMENT_CARD); } @@ -1187,6 +1355,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr breachCheck: persistedBreachCheck(exposure), notes: itemBody, password: itemPassword, + ...(itemPasskeys.length === 0 ? {} : { passkeys: itemPasskeys }), title: itemTitle, totpUri: itemTotpUri.trim(), uris: itemUris @@ -1194,6 +1363,9 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr .map((uri) => uri.trim()) .filter(Boolean), username: itemUsername, + ...(editingItem?.type === "login" && editingItem.emailAlias !== undefined + ? { emailAlias: editingItem.emailAlias } + : {}), }; let saved: VaultItemView; if (editingItem?.type === "login") { @@ -1365,6 +1537,74 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr URL.revokeObjectURL(url); } + async function savePrivateEmailSettings(event: FormEvent) { + event.preventDefault(); + setError(null); + setPrivateEmailStatus(""); + let settings: Record; + if (privateEmailProvider === "plus") { + const baseEmail = privateEmailBase.trim(); + if (!/^[^\s@]+@[^\s@]+$/u.test(baseEmail)) { + setPrivateEmailStatus("Enter the inbox that should receive plus-addressed mail."); + return; + } + settings = { autoFill: privateEmailAutoFill, baseEmail, provider: "plus", version: 1 }; + } else if (privateEmailProvider === "simplelogin") { + if (privateEmailApiSecret.trim().length === 0) { + setPrivateEmailStatus("Enter your SimpleLogin API code."); + return; + } + settings = { + apiCode: privateEmailApiSecret.trim(), + autoFill: privateEmailAutoFill, + provider: "simplelogin", + version: 1, + }; + } else { + if ( + privateEmailApiSecret.trim().length === 0 || + !/^[^\s/@]+(?:\.[^\s/@]+)+$/u.test(privateEmailDomain.trim()) + ) { + setPrivateEmailStatus("Enter your Addy.io API token and alias domain."); + return; + } + settings = { + apiToken: privateEmailApiSecret.trim(), + autoFill: privateEmailAutoFill, + domain: privateEmailDomain.trim().toLocaleLowerCase(), + provider: "addy", + version: 1, + }; + } + setOperation("item"); + try { + const input = { + folder: PRIVATE_EMAIL_SETTINGS_FOLDER, + note: JSON.stringify(settings), + tags: [PRIVATE_EMAIL_SETTINGS_TAG], + title: PRIVATE_EMAIL_SETTINGS_TITLE, + }; + const existing = items.find( + (item): item is Extract => + item.type === "secure-note" && item.tags?.includes(PRIVATE_EMAIL_SETTINGS_TAG) === true, + ); + if (existing === undefined) { + if (client.createSecureNote === undefined) throw new Error("Secure notes unavailable"); + await client.createSecureNote(input); + } else { + if (client.updateSecureNote === undefined) + throw new Error("Secure note updates unavailable"); + await client.updateSecureNote(existing.id, existing.revisionId, input); + } + setItems((await client.listItems?.()) ?? []); + setPrivateEmailStatus("Private email settings saved in the encrypted vault."); + } catch { + setPrivateEmailStatus("Unable to save private email settings."); + } finally { + setOperation(null); + } + } + const busy = operation !== null; const recoveryDrill = screenState.status === "unlocked" && screenState.recovery.status === "pending"; @@ -1378,10 +1618,13 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr ? deviceSlotId : (deviceSlots[0]?.id ?? ""); const normalizedSearch = itemSearch.trim().toLocaleLowerCase(); + const libraryItems = items.filter( + (item) => !(item.type === "secure-note" && item.tags?.includes(PRIVATE_EMAIL_SETTINGS_TAG)), + ); const visibleItems = normalizedSearch === "" - ? items - : items.filter((item) => + ? libraryItems + : libraryItems.filter((item) => [ item.title, item.folder ?? "", @@ -1396,6 +1639,13 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr const loginItems = items.filter( (item): item is Extract => item.type === "login", ); + const authenticatorItems = loginItems.filter( + (item) => item.totpUri !== undefined || (item.passkeys?.length ?? 0) > 0, + ); + const authenticatorCount = authenticatorItems.reduce( + (count, item) => count + (item.totpUri === undefined ? 0 : 1) + (item.passkeys?.length ?? 0), + 0, + ); const securityRecommendations = loginItems.flatMap((item) => { const finding = healthFindings[item.id]; const breach = @@ -1558,6 +1808,8 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr const updated = await client.updateLogin(item.id, item.revisionId, { ...(item.favorite === undefined ? {} : { favorite: item.favorite }), ...(item.folder === undefined ? {} : { folder: item.folder }), + ...(item.emailAlias === undefined ? {} : { emailAlias: item.emailAlias }), + ...(item.passkeys === undefined ? {} : { passkeys: item.passkeys }), ...(item.tags === undefined ? {} : { tags: item.tags }), breachCheck: persistedBreachCheck(result), notes: item.notes, @@ -1578,7 +1830,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr ); } - async function syncGoogleDrive() { + async function syncGoogleDrive(selectAccount = false) { if (client.syncGoogleDrive === undefined || googleClientId.trim().length === 0) { setError("Google Drive is not configured in this app build."); return; @@ -1587,7 +1839,12 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr setError(null); setDriveStatus("Waiting for Google Drive authorization…"); try { - const result = await client.syncGoogleDrive({ clientId: googleClientId }); + const result = await client.syncGoogleDrive({ + clientId: googleClientId, + selectAccount, + }); + setGoogleDriveConnected(true); + setGoogleDriveAccount(result.accountEmail ?? client.getGoogleDriveAccount?.() ?? null); setItems((await client.listItems?.()) ?? []); setDriveStatus( `Sync complete: ${result.revisionCount} encrypted revision(s), ${result.uploaded} uploaded, ${result.conflicts.length} conflict(s), ${result.quarantined} quarantined.`, @@ -1595,6 +1852,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined; + if (code === "DRIVE_AUTH") setGoogleDriveConnected(false); setDriveStatus( code === "DRIVE_AUTH" ? "Google authorization expired or was revoked. Connect again to retry." @@ -1616,7 +1874,9 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr function disconnectGoogleDrive() { client.disconnectGoogleDrive?.(); - setDriveStatus("Google Drive disconnected. No OAuth token was persisted."); + setGoogleDriveConnected(false); + setGoogleDriveAccount(null); + setDriveStatus("Signed out of Google Drive. The local encrypted vault remains available."); } async function syncOneDrive() { @@ -1703,12 +1963,11 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr {screenState.status === "needs-setup" && !showRestore ? ( <>
-

Choose where your encrypted vault lives

-

Create your local vault

+

Choose how to use VeyraKey

+

Set up your vault

- Your keys are always created on this device. By default, only encrypted vault data - is copied to your personal cloud so the same vault can be restored on another - device. + Use a private local vault without an account, or connect your Google account for + encrypted sync and access from your other devices.

@@ -1727,8 +1986,10 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr type="radio" /> - Google Drive - Recommended · private app-data storage · multi-device restore + Continue with Google + + Recommended · encrypted sync · use the same vault on other devices + )} @@ -1760,8 +2021,10 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr type="radio" /> - This device only - Optional local-only mode · connect or migrate to cloud later + Use without an account + + Local vault on this device · connect to Google later if you choose + @@ -1791,8 +2054,8 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr

{setupDestination === "local" - ? "Local-only mode is selected. You can add or migrate to a cloud provider later." - : "Cloud connection starts after the Recovery Kit check. Your password and keys never leave this device."} + ? "No registration or cloud connection. Your encrypted vault stays on this device." + : "Google sign-in stores only encrypted vault data. Your master password and keys never leave this device."}

{operation === "create" ? ( @@ -1822,7 +2085,7 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr }} type="button" > - Restore from encrypted BYOS state + I already have a vault @@ -1832,88 +2095,120 @@ export function VaultScreen({ client, providerConfiguration, surface }: VaultScr <>

Clean-profile recovery

-

Restore encrypted vault

+

Open your existing vault

- Restore the same user vault on this device from Google Drive, OneDrive, or an - encrypted backup. Cloud authorization locates ciphertext; your Recovery Kit - authenticates and unwraps it locally. + Sign in to the Google account that stores your encrypted vault, then unlock it + locally with its existing master password.

-