diff --git a/.changeset/separate-decryption-keys.md b/.changeset/separate-decryption-keys.md new file mode 100644 index 0000000..9c6a031 --- /dev/null +++ b/.changeset/separate-decryption-keys.md @@ -0,0 +1,5 @@ +--- +'@crypt.fyi/web': minor +--- + +Add configurable out-of-band decryption keys, accessible manual key entry, and key-free QR codes while retaining legacy link compatibility. diff --git a/Dockerfile.web b/Dockerfile.web index 86611de..4901309 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -3,6 +3,9 @@ FROM node:22-alpine AS build ARG VITE_API_URL ENV VITE_API_URL=${VITE_API_URL} +ARG VITE_SEPARATE_DECRYPTION_KEY=false +ENV VITE_SEPARATE_DECRYPTION_KEY=${VITE_SEPARATE_DECRYPTION_KEY} + WORKDIR /app RUN corepack enable diff --git a/README.md b/README.md index 60fc590..edc8ada 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,9 @@ 1. Password is optionally provided 1. Encryption key and password are used to encrypt the secret 1. Encryption key and password are **hashed** and stored along with the encrypted secret for verification on retrieval - the raw key and password are **never** stored or transmitted on/to the server -1. The unique URL containing the decryption key is generated on the client -1. Share the URL with your recipient and separately the password if specified +1. A unique secret URL and decryption key are generated on the client +1. Depending on deployment configuration, the key is either placed in the URL fragment for compatibility or shown separately for out-of-band sharing +1. QR codes always contain only the key-free URL; share the decryption key, password, or both separately as needed 1. When accessed, only when the decryption key and password match via server-side verification of the hashes, the encrypted secret is shared and decrypted in the recipient's browser 1. Optionally, the secret is automatically destroyed after being read in an atomic read & delete operation guaranteeing only one person can access the secret 1. If retrieval doesn't happen within the TTL, the secret is automatically destroyed @@ -83,7 +84,27 @@ API_URL=https://{your-domain-here} docker compose up --build ``` > [!IMPORTANT] -> `--build` is required if `API_URL` is changed to ensure nginx and the web client are rebuilt with the correct configuration. +> `--build` is required if `API_URL` or `SEPARATE_DECRYPTION_KEY` is changed because both values are compiled into the web client. + +Set `SEPARATE_DECRYPTION_KEY=true` to keep newly generated decryption keys out of +share URLs: + +```bash +API_URL=https://{your-domain-here} SEPARATE_DECRYPTION_KEY=true docker compose up --build +``` + +The default is `false` so existing deployments retain combined fragment links. +In separated mode, recipients enter the key manually and should receive it +through a different channel from the URL. QR codes omit the key in both modes +because downloaded or screenshotted QR images commonly outlive the secret. +Direct web builds can enable the same behavior with +`VITE_SEPARATE_DECRYPTION_KEY=true`; only the exact value `true` enables it. + +Current `#key` links remain supported. Deprecated `?key=...` links are accepted +only as a read-side compatibility fallback: the bootstrap removes the key from +browser history before router initialization and the UI shows a warning. The +initial HTTP request may already have exposed a query-string key to servers or +intermediaries, so clients must never generate that format. ### Railway diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 34888df..08a993a 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -89,23 +89,48 @@ Abstract 6. Server releases encrypted content only upon hash verification 7. Client performs decryption locally using the original key/password -### 2.4. URL Fragment Security +### 2.4. URL and Out-of-Band Key Security - The decryption key MUST be passed in the URL fragment (after the # - symbol) to prevent transmission to the server: + A client MAY deliver the decryption key in either of these forms: - Example URL structure: - ``` - https://crypt.fyi/v/{vaultId}#{base64-encoded-key} - ``` + 1. A combined compatibility link using a URL fragment: + + ``` + https://crypt.fyi/{vaultId}#{base64-encoded-key} + ``` + + 2. A key-free URL, with the decryption key delivered out of band: + + ``` + https://crypt.fyi/{vaultId} + ``` + + Retrieval clients MUST accept fragment keys and MUST provide an + accessible way to enter a missing key manually. A manually entered key + MUST NOT be written back to the URL. + + URL fragments are not sent in HTTP requests, which protects a fragment + key from web-server access logs and network intermediaries. A combined + link is still a single bearer capability: anyone who obtains the full + link obtains both the vault identifier and key. Deployments that need + credential separation SHOULD generate key-free URLs and send the key + through a different channel. + + Clients: - URL fragments are processed entirely client-side by the browser and - are never sent to the server in HTTP requests. This ensures that: + - MUST NOT generate a raw key in a URL query parameter or path + - MUST keep QR-code payloads key-free in every mode + - MUST NOT persist raw keys in browser storage, caches, analytics, or logs + - MUST NOT include raw keys in errors or client-cache identifiers + - SHOULD mask displayed keys by default and reveal or copy them only after + an explicit user action - - Web server logs never contain decryption keys - - Network intermediaries cannot observe keys - - Server-side code cannot accidentally log or process keys - - The zero-knowledge architecture is maintained + For read-side compatibility only, a client MAY accept a deprecated + `key` query parameter. It MUST prefer a fragment key when both are + present, remove every `key` query parameter from browser history before + initializing client routing or analytics, warn the user that the initial + request may already have exposed the key, and never regenerate or share + the deprecated format. ### 2.5. Server Separation @@ -115,15 +140,15 @@ Abstract #### 2.5.1. Web Server - MUST serve only static files (HTML, CSS, JS) - - MUST be configured to strip URL query parameters and fragments - from request logging + - MUST exclude or redact URL query parameters from request logging + (fragments are never present in HTTP requests) - MUST be configured with strict Content Security Policy (CSP) - SHOULD run on a separate server/hosting platform from API server #### 2.5.2. API Server - MUST handle only encrypted data operations - - MUST NOT receive or process URLs containing decryption keys + - MUST NOT receive or process raw decryption keys - MUST only receive hashed keys for verification (SHA-512) - MUST operate independently from web server @@ -254,8 +279,8 @@ Abstract - Server MUST NOT be able to decrypt content under any circumstances - No user accounts or authentication MUST be required - Server MUST NOT log sensitive data or encryption keys - - Decryption keys MUST NOT be transmitted to server due to URL - fragment usage + - Raw decryption keys MUST NOT be transmitted to the server, whether a + client obtains them from a fragment or out-of-band entry - Client MUST prove key possession through cryptographic hash verification - Hash MUST NOT be reversible to obtain the original key or password diff --git a/docker-compose.yaml b/docker-compose.yaml index e5bf190..733cbc1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,6 +37,7 @@ services: dockerfile: Dockerfile.web args: - VITE_API_URL=${API_URL:-http://localhost:${SERVER_PORT:-4321}} + - VITE_SEPARATE_DECRYPTION_KEY=${SEPARATE_DECRYPTION_KEY:-false} environment: - API_URL=${API_URL:-http://localhost:${SERVER_PORT:-4321}} ports: diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 8926ff7..74bbf36 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -149,16 +149,32 @@ export const de: TranslationKeys = { success: { title: 'Geheimnis erstellt!', description: { - main: 'Ihr Geheimnis wurde erstellt und die URL wurde in die Zwischenablage kopiert', - password: 'Teilen Sie die URL und das Passwort mit dem gewünschten Empfänger', + main: 'Ihr Geheimnis wurde erstellt. Teilen Sie die unten stehende URL mit dem gewünschten Empfänger.', + password: 'Senden Sie das Passwort über einen separaten Kanal.', + separateKey: + 'Senden Sie den Entschlüsselungsschlüssel über einen anderen Kanal als die URL.', }, + secretUrl: 'Geheimnis-URL', + decryptionKey: 'Entschlüsselungsschlüssel', urlCopied: 'URL in die Zwischenablage kopiert', + keyCopied: 'Entschlüsselungsschlüssel in die Zwischenablage kopiert', secretDeleted: 'Geheimnis gelöscht', qrDownloaded: 'QR-Code heruntergeladen', qrDownloadFailed: 'QR-Code konnte nicht heruntergeladen werden: {{error}}', qrCode: { title: 'Geheimnis-URL QR-Code', - description: 'Laden Sie den QR-Code der Geheimnis-URL herunter und teilen Sie ihn', + description: + 'Dieser QR-Code enthält nur die Geheimnis-URL. Senden Sie den Entschlüsselungsschlüssel separat.', + }, + actions: { + showUrl: 'Geheimnis-URL anzeigen', + hideUrl: 'Geheimnis-URL ausblenden', + showKey: 'Entschlüsselungsschlüssel anzeigen', + hideKey: 'Entschlüsselungsschlüssel ausblenden', + shareUrl: 'Geheimnis-URL teilen', + copyUrl: 'Geheimnis-URL kopieren', + copyKey: 'Entschlüsselungsschlüssel kopieren', + showQr: 'QR-Code ohne Schlüssel anzeigen', }, createAnother: 'Weiteres erstellen', deleteSecret: 'Geheimnis löschen', @@ -186,6 +202,7 @@ export const de: TranslationKeys = { fileSizeExceeded: 'Datei ist zu groß. Maximale Größe ist {{max}}.', fileReadError: 'Fehler beim Lesen der Datei', fileReadAborted: 'Dateilesen wurde abgebrochen', + createFailed: 'Das Geheimnis konnte nicht erstellt werden. Versuchen Sie es erneut.', }, }, view: { @@ -210,7 +227,26 @@ export const de: TranslationKeys = { title: 'Passwort eingeben', placeholder: 'Geben Sie das Passwort ein', description: 'Dieses Geheimnis ist mit einem Passwort geschützt - fragen Sie den Absender', - error: 'Falsches Passwort', + error: 'Falscher Entschlüsselungsschlüssel oder falsches Passwort', + }, + key: { + title: 'Entschlüsselungsschlüssel eingeben', + label: 'Entschlüsselungsschlüssel', + placeholder: 'Vom Absender erhaltenen Schlüssel eingeben', + description: + 'Bitten Sie den Absender über einen separaten Kanal um den Entschlüsselungsschlüssel. Der Schlüssel wird nur in diesem Browser verwendet.', + required: 'Geben Sie einen Entschlüsselungsschlüssel ein.', + error: + 'Dieser Entschlüsselungsschlüssel konnte das Geheimnis nicht öffnen. Prüfen Sie den Schlüssel und versuchen Sie es erneut.', + show: 'Entschlüsselungsschlüssel anzeigen', + hide: 'Entschlüsselungsschlüssel ausblenden', + change: 'Anderen Schlüssel eingeben', + submit: 'Geheimnis entsperren', + }, + legacyKey: { + warning: + 'Dieser veraltete Link enthielt den Entschlüsselungsschlüssel in der Abfragezeichenfolge, wodurch er in Serverprotokolle gelangt sein könnte. Er wurde jetzt aus dem Browserverlauf entfernt.', + learnMore: 'Mehr über das sicherere Linkformat erfahren', }, content: { fileShared: 'Eine Datei wurde mit Ihnen geteilt', diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index a6d7b9e..f9e844e 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -147,16 +147,31 @@ export const en: TranslationKeys = { success: { title: 'Secret Created!', description: { - main: 'Your secret has been created and the URL has been copied to your clipboard', - password: 'Share the URL and password with the desired recipient', + main: 'Your secret has been created. Share the URL below with the intended recipient.', + password: 'Send the password through a separate channel.', + separateKey: 'Send the decryption key through a separate channel from the URL.', }, + secretUrl: 'Secret URL', + decryptionKey: 'Decryption key', urlCopied: 'URL copied to clipboard', + keyCopied: 'Decryption key copied to clipboard', secretDeleted: 'Secret deleted', qrDownloaded: 'QR code downloaded', qrDownloadFailed: 'Failed to download QR code: {{error}}', qrCode: { title: 'Secret URL QR Code', - description: 'Download and share the secret URL QR Code', + description: + 'This QR code contains only the secret URL. Send the decryption key separately.', + }, + actions: { + showUrl: 'Show secret URL', + hideUrl: 'Hide secret URL', + showKey: 'Show decryption key', + hideKey: 'Hide decryption key', + shareUrl: 'Share secret URL', + copyUrl: 'Copy secret URL', + copyKey: 'Copy decryption key', + showQr: 'Show key-free QR code', }, createAnother: 'Create Another', deleteSecret: 'Delete Secret', @@ -184,6 +199,7 @@ export const en: TranslationKeys = { fileSizeExceeded: 'File is too large. Maximum size is {{max}}.', fileReadError: 'Failed to read file', fileReadAborted: 'File reading was aborted', + createFailed: 'Failed to create the secret. Please try again.', }, }, view: { @@ -207,7 +223,25 @@ export const en: TranslationKeys = { title: 'Enter Password', placeholder: 'Enter the password', description: 'This secret is protected with a password - request from the sender', - error: 'Incorrect password', + error: 'Incorrect decryption key or password', + }, + key: { + title: 'Enter Decryption Key', + label: 'Decryption key', + placeholder: 'Enter the key provided by the sender', + description: + 'Ask the sender for the decryption key through a separate channel. The key is used only in this browser.', + required: 'Enter a decryption key.', + error: 'That decryption key could not unlock this secret. Check the key and try again.', + show: 'Show decryption key', + hide: 'Hide decryption key', + change: 'Enter a different key', + submit: 'Unlock secret', + }, + legacyKey: { + warning: + 'This outdated link put its decryption key in the query string, where it may have reached server logs. It has now been removed from browser history.', + learnMore: 'Learn about the safer link format', }, content: { fileShared: 'A file has been shared with you', diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 7fe37ad..355caed 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -149,16 +149,31 @@ export const es: TranslationKeys = { success: { title: '¡Secreto Creado!', description: { - main: 'Tu secreto ha sido creado y la URL ha sido copiada al portapapeles', - password: 'Comparte la URL y la contraseña con el destinatario deseado', + main: 'Tu secreto ha sido creado. Comparte la URL de abajo con el destinatario previsto.', + password: 'Envía la contraseña por un canal separado.', + separateKey: 'Envía la clave de descifrado por un canal diferente al de la URL.', }, + secretUrl: 'URL del secreto', + decryptionKey: 'Clave de descifrado', urlCopied: 'URL copiada al portapapeles', + keyCopied: 'Clave de descifrado copiada al portapapeles', secretDeleted: 'Secreto eliminado', qrDownloaded: 'Código QR descargado', qrDownloadFailed: 'Error al descargar el código QR: {{error}}', qrCode: { title: 'Código QR de la URL del Secreto', - description: 'Descarga y comparte el código QR de la URL del secreto', + description: + 'Este código QR contiene solo la URL del secreto. Envía la clave de descifrado por separado.', + }, + actions: { + showUrl: 'Mostrar la URL del secreto', + hideUrl: 'Ocultar la URL del secreto', + showKey: 'Mostrar la clave de descifrado', + hideKey: 'Ocultar la clave de descifrado', + shareUrl: 'Compartir la URL del secreto', + copyUrl: 'Copiar la URL del secreto', + copyKey: 'Copiar la clave de descifrado', + showQr: 'Mostrar el código QR sin clave', }, createAnother: 'Crear Otro', deleteSecret: 'Eliminar Secreto', @@ -186,6 +201,7 @@ export const es: TranslationKeys = { fileSizeExceeded: 'El archivo es demasiado grande. El tamaño máximo es {{max}}.', fileReadError: 'Error al leer el archivo', fileReadAborted: 'La lectura del archivo fue interrumpida', + createFailed: 'No se pudo crear el secreto. Inténtalo de nuevo.', }, }, view: { @@ -209,7 +225,26 @@ export const es: TranslationKeys = { title: 'Ingresar Contraseña', placeholder: 'Ingresa la contraseña', description: 'Este secreto está protegido con una contraseña - solicítala al remitente', - error: 'Contraseña incorrecta', + error: 'La clave de descifrado o la contraseña es incorrecta', + }, + key: { + title: 'Ingresa la clave de descifrado', + label: 'Clave de descifrado', + placeholder: 'Ingresa la clave proporcionada por el remitente', + description: + 'Pide al remitente la clave de descifrado por un canal separado. La clave se usa solo en este navegador.', + required: 'Ingresa una clave de descifrado.', + error: + 'Esa clave de descifrado no pudo desbloquear el secreto. Comprueba la clave e inténtalo de nuevo.', + show: 'Mostrar la clave de descifrado', + hide: 'Ocultar la clave de descifrado', + change: 'Ingresar otra clave', + submit: 'Desbloquear secreto', + }, + legacyKey: { + warning: + 'Este enlace antiguo incluyó la clave de descifrado en la cadena de consulta, donde pudo llegar a los registros del servidor. Ya se ha eliminado del historial del navegador.', + learnMore: 'Más información sobre el formato de enlace más seguro', }, content: { fileShared: 'Se ha compartido un archivo contigo', diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index da5ddcd..f99d0e7 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -149,16 +149,31 @@ export const fr: TranslationKeys = { success: { title: 'Secret Créé !', description: { - main: "Votre secret a été créé et l'URL a été copiée dans le presse-papiers", - password: "Partagez l'URL et le mot de passe avec le destinataire souhaité", + main: "Votre secret a été créé. Partagez l'URL ci-dessous avec le destinataire prévu.", + password: 'Envoyez le mot de passe par un canal séparé.', + separateKey: "Envoyez la clé de déchiffrement par un canal différent de celui de l'URL.", }, + secretUrl: 'URL du secret', + decryptionKey: 'Clé de déchiffrement', urlCopied: 'URL copiée dans le presse-papiers', + keyCopied: 'Clé de déchiffrement copiée dans le presse-papiers', secretDeleted: 'Secret supprimé', qrDownloaded: 'Code QR téléchargé', qrDownloadFailed: 'Échec du téléchargement du code QR : {{error}}', qrCode: { title: "Code QR de l'URL du Secret", - description: "Téléchargez et partagez le code QR de l'URL du secret", + description: + "Ce code QR contient uniquement l'URL du secret. Envoyez la clé de déchiffrement séparément.", + }, + actions: { + showUrl: "Afficher l'URL du secret", + hideUrl: "Masquer l'URL du secret", + showKey: 'Afficher la clé de déchiffrement', + hideKey: 'Masquer la clé de déchiffrement', + shareUrl: "Partager l'URL du secret", + copyUrl: "Copier l'URL du secret", + copyKey: 'Copier la clé de déchiffrement', + showQr: 'Afficher le code QR sans clé', }, createAnother: 'Créer un Autre', deleteSecret: 'Supprimer le Secret', @@ -187,6 +202,7 @@ export const fr: TranslationKeys = { fileSizeExceeded: 'Le fichier est trop volumineux. La taille maximale est {{max}}.', fileReadError: 'Échec de la lecture du fichier', fileReadAborted: 'La lecture du fichier a été interrompue', + createFailed: 'Impossible de créer le secret. Veuillez réessayer.', }, }, view: { @@ -210,7 +226,26 @@ export const fr: TranslationKeys = { title: 'Saisir le Mot de Passe', placeholder: 'Saisissez le mot de passe', description: "Ce secret est protégé par un mot de passe - demandez-le à l'expéditeur", - error: 'Mot de passe incorrect', + error: 'Clé de déchiffrement ou mot de passe incorrect', + }, + key: { + title: 'Saisir la clé de déchiffrement', + label: 'Clé de déchiffrement', + placeholder: "Saisissez la clé fournie par l'expéditeur", + description: + "Demandez la clé de déchiffrement à l'expéditeur par un canal séparé. La clé est utilisée uniquement dans ce navigateur.", + required: 'Saisissez une clé de déchiffrement.', + error: + 'Cette clé de déchiffrement ne permet pas de déverrouiller le secret. Vérifiez-la et réessayez.', + show: 'Afficher la clé de déchiffrement', + hide: 'Masquer la clé de déchiffrement', + change: 'Saisir une autre clé', + submit: 'Déverrouiller le secret', + }, + legacyKey: { + warning: + "Ce lien obsolète plaçait la clé de déchiffrement dans la chaîne de requête, où elle a pu atteindre les journaux du serveur. Elle a maintenant été supprimée de l'historique du navigateur.", + learnMore: 'En savoir plus sur le format de lien plus sûr', }, content: { fileShared: 'Un fichier a été partagé avec vous', diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index c9847b5..57060e3 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -143,16 +143,30 @@ export const zh: TranslationKeys = { success: { title: '密文已创建!', description: { - main: '您的密文已创建,URL 已复制到剪贴板', - password: '与预期接收者分享 URL 和密码', + main: '您的密文已创建。请将下方 URL 分享给预期接收者。', + password: '请通过单独的渠道发送密码。', + separateKey: '请通过与 URL 不同的渠道发送解密密钥。', }, + secretUrl: '密文 URL', + decryptionKey: '解密密钥', urlCopied: 'URL 已复制到剪贴板', + keyCopied: '解密密钥已复制到剪贴板', secretDeleted: '密文已删除', qrDownloaded: '二维码已下载', qrDownloadFailed: '下载二维码失败:{{error}}', qrCode: { title: '密文 URL 二维码', - description: '下载并分享密文 URL 二维码', + description: '此二维码仅包含密文 URL。请单独发送解密密钥。', + }, + actions: { + showUrl: '显示密文 URL', + hideUrl: '隐藏密文 URL', + showKey: '显示解密密钥', + hideKey: '隐藏解密密钥', + shareUrl: '分享密文 URL', + copyUrl: '复制密文 URL', + copyKey: '复制解密密钥', + showQr: '显示不含密钥的二维码', }, createAnother: '创建另一个', deleteSecret: '删除密文', @@ -179,6 +193,7 @@ export const zh: TranslationKeys = { fileSizeExceeded: '文件过大。最大大小为 {{max}}。', fileReadError: '读取文件失败', fileReadAborted: '文件读取已中止', + createFailed: '创建密文失败,请重试。', }, }, view: { @@ -201,7 +216,24 @@ export const zh: TranslationKeys = { title: '输入密码', placeholder: '输入密码', description: '此密文受密码保护 - 请向发送者索要密码', - error: '密码错误', + error: '解密密钥或密码错误', + }, + key: { + title: '输入解密密钥', + label: '解密密钥', + placeholder: '输入发送者提供的密钥', + description: '请通过单独的渠道向发送者索取解密密钥。该密钥仅在此浏览器中使用。', + required: '请输入解密密钥。', + error: '此解密密钥无法解锁密文。请检查密钥后重试。', + show: '显示解密密钥', + hide: '隐藏解密密钥', + change: '输入其他密钥', + submit: '解锁密文', + }, + legacyKey: { + warning: + '此旧版链接将解密密钥放在查询字符串中,密钥可能已进入服务器日志。现已从浏览器历史记录中移除。', + learnMore: '了解更安全的链接格式', }, content: { fileShared: '有人与您分享了一个文件', diff --git a/packages/core/src/i18n/types.ts b/packages/core/src/i18n/types.ts index a6d8c65..91eda15 100644 --- a/packages/core/src/i18n/types.ts +++ b/packages/core/src/i18n/types.ts @@ -139,8 +139,12 @@ export interface TranslationKeys { description: { main: string; password: string; + separateKey: string; }; + secretUrl: string; + decryptionKey: string; urlCopied: string; + keyCopied: string; secretDeleted: string; qrDownloaded: string; qrDownloadFailed: string; @@ -148,6 +152,16 @@ export interface TranslationKeys { title: string; description: string; }; + actions: { + showUrl: string; + hideUrl: string; + showKey: string; + hideKey: string; + shareUrl: string; + copyUrl: string; + copyKey: string; + showQr: string; + }; createAnother: string; deleteSecret: string; info: { @@ -173,6 +187,7 @@ export interface TranslationKeys { fileSizeExceeded: string; fileReadError: string; fileReadAborted: string; + createFailed: string; }; }; view: { @@ -197,6 +212,22 @@ export interface TranslationKeys { description: string; error: string; }; + key: { + title: string; + label: string; + placeholder: string; + description: string; + required: string; + error: string; + show: string; + hide: string; + change: string; + submit: string; + }; + legacyKey: { + warning: string; + learnMore: string; + }; content: { fileShared: string; downloadFile: string; diff --git a/packages/web/src/config.test.ts b/packages/web/src/config.test.ts new file mode 100644 index 0000000..1a868ba --- /dev/null +++ b/packages/web/src/config.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe('SEPARATE_DECRYPTION_KEY', () => { + it('is enabled only by the exact true build-time value', async () => { + vi.stubEnv('VITE_SEPARATE_DECRYPTION_KEY', 'true'); + + const { config } = await import('./config'); + + expect(config.SEPARATE_DECRYPTION_KEY).toBe(true); + }); + + it.each(['false', 'TRUE', '1', ''])('remains disabled for %j', async (value) => { + vi.stubEnv('VITE_SEPARATE_DECRYPTION_KEY', value); + + const { config } = await import('./config'); + + expect(config.SEPARATE_DECRYPTION_KEY).toBe(false); + }); +}); diff --git a/packages/web/src/config.ts b/packages/web/src/config.ts index 0fcaff6..49a1e14 100644 --- a/packages/web/src/config.ts +++ b/packages/web/src/config.ts @@ -1,5 +1,6 @@ export const config = Object.freeze({ API_URL: import.meta.env.VITE_API_URL ?? 'http://localhost:4321', + SEPARATE_DECRYPTION_KEY: import.meta.env.VITE_SEPARATE_DECRYPTION_KEY === 'true', IS_DEV: import.meta.env.DEV, IS_PROD: import.meta.env.PROD, CRYPT_FYI_GITHUB_URL: 'https://github.com/osbytes/crypt.fyi', diff --git a/packages/web/src/lib/clipboardCopy.test.ts b/packages/web/src/lib/clipboardCopy.test.ts new file mode 100644 index 0000000..7c24d42 --- /dev/null +++ b/packages/web/src/lib/clipboardCopy.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { clipboardCopy } from './clipboardCopy'; + +const copyFallback = vi.hoisted(() => vi.fn(() => true)); + +vi.mock('copy-to-clipboard', () => ({ + default: copyFallback, +})); + +afterEach(() => { + copyFallback.mockClear(); + vi.unstubAllGlobals(); +}); + +describe('clipboardCopy', () => { + it('never opens the legacy prompt fallback for an automatic copy', async () => { + vi.stubGlobal('navigator', {}); + + await expect(clipboardCopy('sensitive-value', { userInitiatedFallback: false })).resolves.toBe( + false, + ); + expect(copyFallback).not.toHaveBeenCalled(); + }); + + it('does not fall back after an automatic Clipboard API rejection', async () => { + vi.stubGlobal('navigator', { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error('denied')), + }, + }); + + await expect(clipboardCopy('sensitive-value', { userInitiatedFallback: false })).resolves.toBe( + false, + ); + expect(copyFallback).not.toHaveBeenCalled(); + }); + + it('retains the user-visible fallback for an explicit copy action', async () => { + vi.stubGlobal('navigator', {}); + + await expect(clipboardCopy('user-selected-value')).resolves.toBe(true); + expect(copyFallback).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/src/lib/clipboardCopy.ts b/packages/web/src/lib/clipboardCopy.ts index 64437a4..cd7f007 100644 --- a/packages/web/src/lib/clipboardCopy.ts +++ b/packages/web/src/lib/clipboardCopy.ts @@ -1,3 +1,28 @@ import copy from 'copy-to-clipboard'; -export const clipboardCopy = async (text: string) => copy(text); +interface ClipboardCopyOptions { + /** + * Permit the legacy, user-visible fallback when modern clipboard access is + * unavailable. Disable this for automatic copy attempts so secret material + * is never placed in an unexpected prompt. + */ + userInitiatedFallback?: boolean; +} + +export const clipboardCopy = async ( + text: string, + { userInitiatedFallback = true }: ClipboardCopyOptions = {}, +) => { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + if (!userInitiatedFallback) return false; + } + } else if (!userInitiatedFallback) { + return false; + } + + return copy(text); +}; diff --git a/packages/web/src/lib/legacyKeyBootstrap.test.ts b/packages/web/src/lib/legacyKeyBootstrap.test.ts new file mode 100644 index 0000000..b27aa5e --- /dev/null +++ b/packages/web/src/lib/legacyKeyBootstrap.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +function stubWindow(search: string, hash = '', pathname = '/vault-id') { + const location = { + hash, + pathname, + search, + }; + const replaceState = vi.fn((_state: unknown, _title: string, url: string) => { + const parsed = new URL(url, 'https://crypt.example'); + location.hash = parsed.hash; + location.pathname = parsed.pathname; + location.search = parsed.search; + }); + vi.stubGlobal('window', { + location, + history: { + replaceState, + state: { navigation: 'state' }, + }, + }); + return { location, replaceState }; +} + +describe('legacy key bootstrap', () => { + it('scrubs at bootstrap and keeps the key only through replayed initial renders', async () => { + const { replaceState } = stubWindow('?p=true&key=legacy-key&key=discarded'); + + const { consumeLegacyQueryKey } = await import('./legacyKeyBootstrap'); + + expect(replaceState).toHaveBeenCalledWith({ navigation: 'state' }, '', '/vault-id?p=true'); + expect(consumeLegacyQueryKey()).toEqual({ + legacyQueryKey: 'legacy-key', + hadLegacyQueryKey: true, + }); + expect(consumeLegacyQueryKey()).toEqual({ + legacyQueryKey: 'legacy-key', + hadLegacyQueryKey: true, + }); + + vi.runAllTimers(); + expect(consumeLegacyQueryKey()).toEqual({ + legacyQueryKey: null, + hadLegacyQueryKey: false, + }); + }); + + it('discards a query value when a fragment key already has priority', async () => { + const { replaceState } = stubWindow('?key=query-key&p=true', '#fragment-key'); + + const { consumeLegacyQueryKey } = await import('./legacyKeyBootstrap'); + + expect(replaceState).toHaveBeenCalledWith( + { navigation: 'state' }, + '', + '/vault-id?p=true#fragment-key', + ); + expect(consumeLegacyQueryKey()).toEqual({ + legacyQueryKey: null, + hadLegacyQueryKey: true, + }); + }); + + it('scrubs but never retains a key attached to a non-secret route', async () => { + const { replaceState } = stubWindow('?key=unrelated-key', '', '/new'); + + const { consumeLegacyQueryKey } = await import('./legacyKeyBootstrap'); + + expect(replaceState).toHaveBeenCalledWith({ navigation: 'state' }, '', '/new'); + expect(consumeLegacyQueryKey()).toEqual({ + legacyQueryKey: null, + hadLegacyQueryKey: false, + }); + }); +}); diff --git a/packages/web/src/lib/legacyKeyBootstrap.ts b/packages/web/src/lib/legacyKeyBootstrap.ts new file mode 100644 index 0000000..dff3d98 --- /dev/null +++ b/packages/web/src/lib/legacyKeyBootstrap.ts @@ -0,0 +1,101 @@ +import { stripLegacyKeyFromUrl } from './secretUrl'; + +export interface LegacyKeyCapture { + legacyQueryKey: string | null; + hadLegacyQueryKey: boolean; +} + +interface PendingLegacyKeyCapture extends LegacyKeyCapture { + pathname: string; +} + +let pendingCapture: PendingLegacyKeyCapture | null = null; +let pendingClearTimer: ReturnType | null = null; + +function isPotentialSecretPath(pathname: string): boolean { + const segments = pathname.split('/').filter(Boolean); + if (segments.length !== 1) return false; + + const segment = segments[0]; + return !['new', 'about', 'privacy'].includes(segment); +} + +/** + * Capture and scrub a deprecated query-string key before the router starts. + * + * The raw value remains only in this module closure until the view consumes it. + * If a fragment key exists, the query value is discarded immediately because + * fragments have priority. + */ +function captureLegacyQueryKey(): void { + if (pendingCapture || typeof window === 'undefined') return; + + const params = new URLSearchParams(window.location.search); + const hadLegacyQueryKey = params.has('key'); + if (!hadLegacyQueryKey) return; + + const isSecretPath = isPotentialSecretPath(window.location.pathname); + const hasFragmentKey = window.location.hash.replace(/^#/, '').trim().length > 0; + if (isSecretPath) { + pendingCapture = { + legacyQueryKey: hasFragmentKey ? null : params.get('key'), + hadLegacyQueryKey: true, + pathname: window.location.pathname, + }; + } + + const cleanUrl = stripLegacyKeyFromUrl( + window.location.pathname, + window.location.search, + window.location.hash, + ); + window.history.replaceState(window.history.state, '', cleanUrl); +} + +captureLegacyQueryKey(); + +function deferPendingCaptureDrop(captured: PendingLegacyKeyCapture): void { + if (pendingClearTimer !== null) return; + + // React may replay an initial render before committing it. Keep the capture + // available only through the current event-loop turn so both renders see the + // same compatibility value, then drop the module's extra raw-key reference. + pendingClearTimer = setTimeout(() => { + if (pendingCapture === captured) pendingCapture = null; + pendingClearTimer = null; + }, 0); +} + +/** + * Returns the bootstrap capture for the current pathname. + * + * Replayed initial renders may read the same capture during this event-loop + * turn. The module reference is dropped immediately afterward. + */ +export function consumeLegacyQueryKey(): LegacyKeyCapture { + if (typeof window === 'undefined') { + return { + legacyQueryKey: null, + hadLegacyQueryKey: false, + }; + } + + const captured = pendingCapture; + if (!captured || captured.pathname !== window.location.pathname) { + pendingCapture = null; + if (pendingClearTimer !== null) { + clearTimeout(pendingClearTimer); + pendingClearTimer = null; + } + return { + legacyQueryKey: null, + hadLegacyQueryKey: false, + }; + } + + deferPendingCaptureDrop(captured); + return { + legacyQueryKey: captured.legacyQueryKey, + hadLegacyQueryKey: captured.hadLegacyQueryKey, + }; +} diff --git a/packages/web/src/lib/secretUrl.test.ts b/packages/web/src/lib/secretUrl.test.ts new file mode 100644 index 0000000..b7155ee --- /dev/null +++ b/packages/web/src/lib/secretUrl.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { buildSecretLinks, resolveDecryptionKey, stripLegacyKeyFromUrl } from './secretUrl'; + +describe('buildSecretLinks', () => { + const input = { + origin: 'https://crypt.example', + id: 'vault-id', + key: '2.decryption-key', + passwordProtected: true, + }; + + it('separates the decryption key from the share URL when configured', () => { + const links = buildSecretLinks({ ...input, separateDecryptionKey: true }); + + expect(links).toEqual({ + shareUrl: 'https://crypt.example/vault-id?p=true', + qrUrl: 'https://crypt.example/vault-id?p=true', + }); + expect(new URL(links.shareUrl).searchParams.has('key')).toBe(false); + expect(JSON.stringify({ shareUrl: links.shareUrl, qrUrl: links.qrUrl })).not.toContain( + input.key, + ); + }); + + it('preserves combined fragment links without putting the key in the QR code', () => { + const links = buildSecretLinks({ ...input, separateDecryptionKey: false }); + + expect(links.shareUrl).toBe('https://crypt.example/vault-id?p=true#2.decryption-key'); + expect(links.qrUrl).toBe('https://crypt.example/vault-id?p=true'); + expect(links.qrUrl).not.toContain(input.key); + expect(new URL(links.shareUrl).searchParams.has('key')).toBe(false); + }); +}); + +describe('resolveDecryptionKey', () => { + it('prefers the fragment while still reporting a legacy query key for cleanup', () => { + expect(resolveDecryptionKey('#fragment-key', 'query-key')).toEqual({ + key: 'fragment-key', + source: 'fragment', + hadLegacyQueryKey: true, + }); + }); + + it('supports the deprecated query key when no fragment is present', () => { + expect(resolveDecryptionKey('', ' legacy-key ')).toEqual({ + key: 'legacy-key', + source: 'legacy-query', + hadLegacyQueryKey: true, + }); + }); + + it('reports a missing key without manufacturing a value', () => { + expect(resolveDecryptionKey('# ', null)).toEqual({ + key: '', + source: 'missing', + hadLegacyQueryKey: false, + }); + }); + + it('still requests cleanup for an empty legacy key parameter', () => { + expect(resolveDecryptionKey('', '')).toEqual({ + key: '', + source: 'missing', + hadLegacyQueryKey: true, + }); + }); +}); + +describe('stripLegacyKeyFromUrl', () => { + it('removes legacy keys while preserving unrelated state and a fragment', () => { + expect( + stripLegacyKeyFromUrl('/vault-id', '?key=first&p=true&key=second&locale=fr', '#fragment-key'), + ).toBe('/vault-id?p=true&locale=fr#fragment-key'); + }); +}); diff --git a/packages/web/src/lib/secretUrl.ts b/packages/web/src/lib/secretUrl.ts new file mode 100644 index 0000000..52cfd6c --- /dev/null +++ b/packages/web/src/lib/secretUrl.ts @@ -0,0 +1,87 @@ +export type DecryptionKeySource = 'fragment' | 'legacy-query' | 'manual' | 'missing'; + +export interface ResolvedDecryptionKey { + key: string; + source: DecryptionKeySource; + hadLegacyQueryKey: boolean; +} + +interface SecretLinksInput { + origin: string; + id: string; + key: string; + passwordProtected: boolean; + separateDecryptionKey: boolean; +} + +export interface SecretLinks { + shareUrl: string; + qrUrl: string; +} + +/** + * Builds the user-facing share artifacts without ever placing the key in a + * query parameter. QR codes intentionally remain key-free in both modes + * because they are commonly downloaded, screenshotted, and retained. + */ +export function buildSecretLinks({ + origin, + id, + key, + passwordProtected, + separateDecryptionKey, +}: SecretLinksInput): SecretLinks { + const url = new URL(`/${encodeURIComponent(id)}`, origin); + if (passwordProtected) { + url.searchParams.set('p', 'true'); + } + + const keylessUrl = url.toString(); + return { + shareUrl: separateDecryptionKey ? keylessUrl : `${keylessUrl}#${key}`, + qrUrl: keylessUrl, + }; +} + +/** + * Resolves current fragment links first, then the deprecated query-string + * format. Callers should remove a legacy query key from history immediately. + */ +export function resolveDecryptionKey( + fragment: string, + legacyQueryKey: string | null, +): ResolvedDecryptionKey { + const fragmentKey = fragment.replace(/^#/, '').trim(); + const queryKey = legacyQueryKey?.trim() ?? ''; + const hadLegacyQueryKey = legacyQueryKey !== null; + + if (fragmentKey) { + return { + key: fragmentKey, + source: 'fragment', + hadLegacyQueryKey, + }; + } + + if (queryKey) { + return { + key: queryKey, + source: 'legacy-query', + hadLegacyQueryKey: true, + }; + } + + return { + key: '', + source: 'missing', + hadLegacyQueryKey, + }; +} + +/** Removes every deprecated `key` query parameter while preserving the rest. */ +export function stripLegacyKeyFromUrl(pathname: string, search: string, hash: string): string { + const params = new URLSearchParams(search); + params.delete('key'); + const query = params.toString(); + return `${pathname}${query ? `?${query}` : ''}${hash}`; +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 61aff31..c408488 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -1,3 +1,5 @@ +// This side effect must run before the router reads the current location. +import './lib/legacyKeyBootstrap'; import { createRoot } from 'react-dom/client'; import './index.css'; import './i18n'; diff --git a/packages/web/src/pages/Create.tsx b/packages/web/src/pages/Create.tsx index 3cb4711..d2500dc 100644 --- a/packages/web/src/pages/Create.tsx +++ b/packages/web/src/pages/Create.tsx @@ -17,6 +17,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Select, SelectContent, @@ -64,6 +65,7 @@ import { useClient } from '@/context/client'; import { NumberInput } from '@/components/NumberInput'; import { TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip } from '@/components/ui/tooltip'; +import { buildSecretLinks } from '@/lib/secretUrl'; const VALID_FILE_TYPES = ['Files', 'text/plain', 'text/uri-list', 'text/html']; const MAX_FILE_SIZE = 1 * 1024 * 1024; @@ -421,6 +423,10 @@ export function CreatePage() { }, [watch]); const { client } = useClient(); + // Combined compatibility URLs contain the raw key. Keep both values out of + // React Query mutation data so they never become cache entries. + const createdShareUrlRef = useRef(''); + const createdDecryptionKeyRef = useRef(''); const createMutation = useMutation({ mutationFn: async (input: FormValues) => { @@ -440,20 +446,40 @@ export function CreatePage() { : undefined, }); - const url = `${window.location.origin}/${result.id}${input.p ? '?p=true' : ''}#${result.key}`; - await clipboardCopy(url); - toast.info(t('create.success.urlCopied')); + const links = buildSecretLinks({ + origin: window.location.origin, + id: result.id, + key: result.key, + passwordProtected: Boolean(input.p), + separateDecryptionKey: config.SEPARATE_DECRYPTION_KEY, + }); + createdShareUrlRef.current = links.shareUrl; + createdDecryptionKeyRef.current = result.key; + + if (config.SEPARATE_DECRYPTION_KEY) { + try { + const copied = await clipboardCopy(links.shareUrl, { userInitiatedFallback: false }); + if (copied) toast.info(t('create.success.urlCopied')); + } catch { + // Clipboard access can be denied. The explicit copy action remains + // available, and no secret material is included in the error path. + } + } return { - ...result, - url, + id: result.id, + dt: result.dt, + qrUrl: links.qrUrl, }; }, - onError(error) { - toast.error(error.message); + onError() { + toast.error(t('create.errors.createFailed')); }, + gcTime: 0, }); + const [isUrlMasked, setIsUrlMasked] = useState(true); + const [isKeyMasked, setIsKeyMasked] = useState(true); const [selectedFile, setSelectedFile] = useState(null); const handleFiles = async (files: File[]) => { @@ -547,6 +573,12 @@ export function CreatePage() { Object.entries(valuesToKeep).forEach(([field, value]) => { form.setValue(field as keyof FormValues, value); }); + setIsUrlMasked(true); + setIsKeyMasked(true); + setIsQrDialogOpen(false); + createdShareUrlRef.current = ''; + createdDecryptionKeyRef.current = ''; + createMutation.reset(); }; const deleteMutation = useMutation({ @@ -572,18 +604,22 @@ export function CreatePage() { }, }); - const [isUrlMasked, setIsUrlMasked] = useState(true); - let maskedUrl = createMutation.data?.url; - if (isUrlMasked && createMutation.data?.url) { - const url = new URL(createMutation.data.url); + let maskedUrl = createdShareUrlRef.current; + const createdId = createMutation.data?.id; + if (isUrlMasked && createdShareUrlRef.current && createdId) { + const url = new URL(createdShareUrlRef.current); const key = url.hash.slice(1); // Remove the # symbol if (key) { - maskedUrl = `${url.origin}/${'*'.repeat(createMutation.data.id.length)}${url.search}#${'*'.repeat(key.length)}`; + maskedUrl = `${url.origin}/${'*'.repeat(createdId.length)}${url.search}#${'*'.repeat(key.length)}`; } else { - maskedUrl = `${url.origin}/${'*'.repeat(createMutation.data.id.length)}${url.search}`; + maskedUrl = `${url.origin}/${'*'.repeat(createdId.length)}${url.search}`; } } + const maskedKey = + isKeyMasked && createdDecryptionKeyRef.current + ? '*'.repeat(createdDecryptionKeyRef.current.length) + : createdDecryptionKeyRef.current; const fileInputRef = useRef(null); @@ -597,7 +633,7 @@ export function CreatePage() { try { const dataUrl = await svgToImage(svg); const link = document.createElement('a'); - const hash = sha256(createMutation.data?.url ?? ''); + const hash = sha256(createMutation.data?.qrUrl ?? ''); link.download = `crypt.fyi-qr-${hash.slice(0, 8)}.png`; link.href = dataUrl; link.click(); @@ -1133,52 +1169,140 @@ export function CreatePage() {

{t('create.success.description.main')}

+ {config.SEPARATE_DECRYPTION_KEY && ( +

+ {t('create.success.description.separateKey')} +

+ )}

{form.watch('p') && t('create.success.description.password')}

-
- - - - + aria-label={ + isUrlMasked + ? t('create.success.actions.showUrl') + : t('create.success.actions.hideUrl') + } + aria-pressed={!isUrlMasked} + > + {isUrlMasked ? : } + + + +
+ + +
+ +
+ + + +
+
diff --git a/packages/web/src/pages/View.test.tsx b/packages/web/src/pages/View.test.tsx new file mode 100644 index 0000000..e5c008d --- /dev/null +++ b/packages/web/src/pages/View.test.tsx @@ -0,0 +1,131 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import type { ReactNode } from 'react'; +import { ErrorInvalidKeyAndOrPassword } from '@crypt.fyi/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ViewPage } from './View'; + +const useMutationMock = vi.hoisted(() => + vi.fn((_options: Record) => ({ + data: undefined, + error: null as Error | null, + isPending: false, + mutate: vi.fn(), + reset: vi.fn(), + })), +); + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children }: { children: ReactNode }) => {children}, + useParams: () => ({ id: 'vault-id' }), + useSearch: () => ({}), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: useMutationMock, + useQuery: () => ({ + data: undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/context/client', () => ({ + useClient: () => ({ + client: { + exists: vi.fn(), + read: vi.fn(), + }, + }), +})); + +vi.mock('@crypt.fyi/core', () => ({ + ErrorInvalidKeyAndOrPassword: class ErrorInvalidKeyAndOrPassword extends Error {}, + ErrorNotFound: class ErrorNotFound extends Error {}, + sleep: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +afterEach(() => { + vi.unstubAllGlobals(); + useMutationMock.mockClear(); +}); + +describe('ViewPage decryption key entry', () => { + it('renders an accessible key form when the URL has no key', () => { + vi.stubGlobal('window', { + location: { + hash: '', + pathname: '/vault-id', + search: '', + }, + }); + + const html = renderToStaticMarkup(); + + expect(html).toContain('for="decryption-key"'); + expect(html).toContain('id="decryption-key"'); + expect(html).toContain('type="password"'); + expect(html).toContain('autoComplete="off"'); + expect(html).toContain('aria-describedby="decryption-key-description"'); + expect(html).toContain('view.key.description'); + }); + + it('keeps a fragment key out of the React Query mutation key', () => { + const rawKey = 'fragment-key-must-not-enter-cache-identity'; + vi.stubGlobal('window', { + location: { + hash: `#${rawKey}`, + pathname: '/vault-id', + search: '', + }, + }); + + const html = renderToStaticMarkup(); + const mutationOptions = useMutationMock.mock.calls.at(-1)?.[0]; + + expect(html).toContain('view.actions.viewSecret'); + expect(mutationOptions).toEqual( + expect.objectContaining({ + mutationKey: ['vault-id', 'decrypt'], + gcTime: 0, + }), + ); + expect(JSON.stringify(mutationOptions)).not.toContain(rawKey); + }); + + it('offers manual recovery after a fragment key fails', () => { + useMutationMock.mockImplementationOnce(() => ({ + data: undefined, + error: new ErrorInvalidKeyAndOrPassword(), + isPending: false, + mutate: vi.fn(), + reset: vi.fn(), + })); + vi.stubGlobal('window', { + location: { + hash: '#wrong-fragment-key', + pathname: '/vault-id', + search: '', + }, + }); + + const html = renderToStaticMarkup(); + + expect(html).toContain('view.invalidLink.title'); + expect(html).toContain('view.key.change'); + }); +}); diff --git a/packages/web/src/pages/View.tsx b/packages/web/src/pages/View.tsx index 6330b3c..95a61bb 100644 --- a/packages/web/src/pages/View.tsx +++ b/packages/web/src/pages/View.tsx @@ -1,11 +1,11 @@ import { config } from '@/config'; import { useMutation, useQuery } from '@tanstack/react-query'; import { Link, useParams, useSearch } from '@tanstack/react-router'; -import invariant from 'tiny-invariant'; import { Card } from '@/components/ui/card'; import { useState, useRef, useEffect } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button/button'; import { toast } from 'sonner'; import { @@ -23,25 +23,46 @@ import { Loader } from '@/components/ui/loader'; import { ErrorInvalidKeyAndOrPassword, ErrorNotFound, sleep } from '@crypt.fyi/core'; import { useTranslation } from 'react-i18next'; import { useClient } from '@/context/client'; +import { resolveDecryptionKey } from '@/lib/secretUrl'; +import type { DecryptionKeySource } from '@/lib/secretUrl'; +import { consumeLegacyQueryKey } from '@/lib/legacyKeyBootstrap'; export function ViewPage() { const { t } = useTranslation(); - const { id } = useParams({ from: '/$id' }); const search = useSearch({ from: '/$id' }); - const isPasswordSet = search.p; - // TODO: Remove the search.key fallback once the `key` parameter is to no longer be supported. - const key = window.location.hash.slice(1)?.trim() || search.key; - invariant(key, '`key` is required in URL hash'); + const isPasswordSet = Boolean(search.p); - useKeyInSearchParamsDeprecationToast(); + // Keep the raw key outside render state and React Query. It exists only in + // the URL/entry control and this short-lived ref until decryption succeeds. + const decryptionKeyRef = useRef(''); + const keySourceRef = useRef('missing'); + const hadLegacyQueryKeyRef = useRef(false); + const hasReadInitialKeyRef = useRef(false); + if (!hasReadInitialKeyRef.current) { + const legacyCapture = consumeLegacyQueryKey(); + const resolved = resolveDecryptionKey(window.location.hash, legacyCapture.legacyQueryKey); + decryptionKeyRef.current = resolved.key; + keySourceRef.current = resolved.source; + hadLegacyQueryKeyRef.current = legacyCapture.hadLegacyQueryKey || resolved.hadLegacyQueryKey; + hasReadInitialKeyRef.current = true; + } - const [password, setPassword] = useState(''); - const [isDialogOpen, setIsDialogOpen] = useState(isPasswordSet); + const [hasDecryptionKey, setHasDecryptionKey] = useState( + () => decryptionKeyRef.current.length > 0, + ); + const [isDialogOpen, setIsDialogOpen] = useState( + () => isPasswordSet && decryptionKeyRef.current.length > 0, + ); const [isRevealed, setIsRevealed] = useState(false); const [hasUserConfirmed, setHasUserConfirmed] = useState(false); const [passwordError, setPasswordError] = useState(null); + const [keyEntryError, setKeyEntryError] = useState(null); const passwordInputRef = useRef(null); + const passwordRef = useRef(''); + + const hadLegacyQueryKey = hadLegacyQueryKeyRef.current; + useLegacyKeyWarning(hadLegacyQueryKey); const { client } = useClient(); @@ -49,38 +70,109 @@ export function ViewPage() { queryKey: [id, 'exists'], queryFn: async () => { await sleep(500, { enabled: config.IS_DEV }); - const exists = await client.exists(id); - return exists; + return client.exists(id); }, retry: () => false, - enabled: isPasswordSet, + enabled: isPasswordSet && hasDecryptionKey, }); + useEffect(() => { + if (existsQuery.data === false) { + decryptionKeyRef.current = ''; + passwordRef.current = ''; + } + }, [existsQuery.data]); const decryptMutation = useMutation({ - mutationKey: [id, key, password], + mutationKey: [id, 'decrypt'], mutationFn: async () => { - const result = await client.read(id, key, password); - return result; + const key = decryptionKeyRef.current; + if (!key) { + throw new Error('Decryption key is unavailable'); + } + return client.read(id, key, passwordRef.current); }, retry: () => false, + gcTime: 0, onSuccess() { + // JavaScript strings cannot be zeroed, but dropping our reference avoids + // retaining an additional key copy after it is no longer needed. + decryptionKeyRef.current = ''; + passwordRef.current = ''; + if (passwordInputRef.current) passwordInputRef.current.value = ''; setIsDialogOpen(false); setPasswordError(null); }, onError(error) { if (error instanceof ErrorInvalidKeyAndOrPassword) { + if (!isPasswordSet && keySourceRef.current === 'manual') { + decryptionKeyRef.current = ''; + keySourceRef.current = 'missing'; + setHasDecryptionKey(false); + setHasUserConfirmed(false); + setKeyEntryError(t('view.key.error')); + return; + } + if (!isPasswordSet) { + decryptionKeyRef.current = ''; + return; + } + setPasswordError(t('view.password.error')); setTimeout(() => { passwordInputRef.current?.focus(); }, 100); } else if (error instanceof ErrorNotFound) { + decryptionKeyRef.current = ''; + passwordRef.current = ''; setIsDialogOpen(false); - } else { - toast.error(error.message); } }, }); + const promptForDifferentKey = (error: string | null = null) => { + decryptionKeyRef.current = ''; + keySourceRef.current = 'missing'; + decryptMutation.reset(); + setHasDecryptionKey(false); + setHasUserConfirmed(false); + setIsDialogOpen(false); + passwordRef.current = ''; + if (passwordInputRef.current) passwordInputRef.current.value = ''; + setPasswordError(null); + setKeyEntryError(error); + }; + + const submitDecryptionKey = (key: string) => { + const normalizedKey = key.trim(); + if (!normalizedKey) { + setKeyEntryError(t('view.key.required')); + return; + } + + decryptMutation.reset(); + decryptionKeyRef.current = normalizedKey; + keySourceRef.current = 'manual'; + setKeyEntryError(null); + setHasDecryptionKey(true); + setHasUserConfirmed(true); + + if (isPasswordSet) { + setIsDialogOpen(true); + } else { + decryptMutation.mutate(); + } + }; + + if (!hasDecryptionKey) { + return ( + + ); + } + if (existsQuery.isLoading) { return ; } @@ -104,8 +196,12 @@ export function ViewPage() {

{t('view.connectionError.description')}

+
+ + +
); @@ -143,7 +244,8 @@ export function ViewPage() { ); } - // Show initial confirmation screen to require user input before fetching the secret + // A fragment link still requires an explicit click before fetching a + // non-password-protected secret. Submitting a manual key is that confirmation. if (!hasUserConfirmed && !isPasswordSet) { return (
@@ -276,40 +378,65 @@ export function ViewPage() {
{content} - + { + setIsDialogOpen(open); + if (!open) { + passwordRef.current = ''; + if (passwordInputRef.current) passwordInputRef.current.value = ''; + setPasswordError(null); + } + }} + > {t('view.password.title')}
{ - e.preventDefault(); + onSubmit={(event) => { + event.preventDefault(); + passwordRef.current = passwordInputRef.current?.value ?? ''; decryptMutation.mutate(); }} >
+ { - setPassword(e.target.value); + onChange={(event) => { + passwordRef.current = event.target.value; setPasswordError(null); }} required + autoComplete="off" autoFocus + aria-describedby="password-description" + aria-invalid={Boolean(passwordError)} + aria-errormessage={passwordError ? 'password-error' : undefined} className={cn( 'text-lg', passwordError && 'border-destructive focus-visible:ring-destructive', )} disabled={decryptMutation.isPending} /> - {passwordError &&

{passwordError}

} -

{t('view.password.description')}

+ {passwordError && ( + + )} +

+ {t('view.password.description')} +

-
+
+ @@ -321,37 +448,109 @@ export function ViewPage() { ); } -// Outdated clients may use the `key` parameter in the URL search parameters to generate the secret. -// This is deprecated and will be removed in the future. -// This hook shows a toast to the user to let them know that the secret sender may be using an -// outdated client to generate the secret. -function useKeyInSearchParamsDeprecationToast() { - const search = useSearch({ from: '/$id' }); - const searchKey = search.key; +interface DecryptionKeyPromptProps { + error: string | null; + isPending: boolean; + onSubmit: (key: string) => void; +} + +function DecryptionKeyPrompt({ error, isPending, onSubmit }: DecryptionKeyPromptProps) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const [isKeyRevealed, setIsKeyRevealed] = useState(false); + + return ( +
+ +

{t('view.key.title')}

+

+ {t('view.key.description')} +

+ { + event.preventDefault(); + const input = inputRef.current; + if (!input) return; + + const key = input.value; + input.value = ''; + onSubmit(key); + }} + > +
+ +
+ + +
+ {error && ( + + )} +
+
+ +
+ +
+
+ ); +} + +// Outdated clients may put the key in the query string, where it can reach +// servers and logs. The value is captured only for compatibility, removed from +// browser history immediately, and never rendered in this warning. +function useLegacyKeyWarning(hadLegacyQueryKey: boolean) { + const { t } = useTranslation(); + useEffect(() => { - if (searchKey) { - toast.warning( -
-

- Using key in the URL search parameters is deprecated. The secret sender may - be using an outdated client to generate the secret. -

-

- - See crypt.fyi/issues/100 - -

-
, - { - id: 'key-in-url-search-params-deprecated', - closeButton: true, - duration: Infinity, - }, - ); - } - }, [searchKey]); + if (!hadLegacyQueryKey) return; + + toast.warning( +
+

{t('view.legacyKey.warning')}

+

+ + {t('view.legacyKey.learnMore')} + +

+
, + { + id: 'key-in-url-search-params-deprecated', + closeButton: true, + duration: Infinity, + }, + ); + }, [hadLegacyQueryKey, t]); } diff --git a/packages/web/src/routes/$id.tsx b/packages/web/src/routes/$id.tsx index f7e88dd..279a472 100644 --- a/packages/web/src/routes/$id.tsx +++ b/packages/web/src/routes/$id.tsx @@ -1,11 +1,42 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useParams, useRouterState, useSearch } from '@tanstack/react-router'; import { ViewPage } from '@/pages/View'; +import { useEffect, useState } from 'react'; import { z } from 'zod'; export const Route = createFileRoute('/$id')({ validateSearch: z.object({ p: z.coerce.boolean().optional(), - key: z.string().optional(), }), - component: ViewPage, + remountDeps: ({ params, search }) => ({ + id: params.id, + passwordProtected: Boolean(search.p), + }), + component: ViewRoute, }); + +function ViewRoute() { + const { id } = useParams({ from: '/$id' }); + const search = useSearch({ from: '/$id' }); + const navigationKey = useRouterState({ + select: (state) => state.location.state.__TSR_key, + }); + const [nativeHashRevision, setNativeHashRevision] = useState(0); + + useEffect(() => { + const handleHashChange = () => setNativeHashRevision((revision) => revision + 1); + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + // Remount the key-holding view for router and native fragment navigations. + // This identity contains only public route metadata and opaque counters, + // never fragment contents or any other decryption-key material. + const viewInstanceKey = [ + navigationKey ?? 'initial', + id, + Boolean(search.p), + nativeHashRevision, + ].join(':'); + + return ; +} diff --git a/packages/web/src/routes/-$id.test.tsx b/packages/web/src/routes/-$id.test.tsx new file mode 100644 index 0000000..bc0206a --- /dev/null +++ b/packages/web/src/routes/-$id.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { Route } from './$id'; + +describe('secret view route lifecycle', () => { + it('remounts for a different secret or password-protection mode', () => { + const remountDeps = Route.options.remountDeps as (input: { + params: { id: string }; + search: { p?: boolean }; + }) => unknown; + + expect(remountDeps({ params: { id: 'first' }, search: {} })).toEqual({ + id: 'first', + passwordProtected: false, + }); + expect(remountDeps({ params: { id: 'second' }, search: { p: true } })).toEqual({ + id: 'second', + passwordProtected: true, + }); + }); +});