Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/separate-decryption-keys.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions Dockerfile.web
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
61 changes: 43 additions & 18 deletions SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 40 additions & 4 deletions packages/core/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand All @@ -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',
Expand Down
42 changes: 38 additions & 4 deletions packages/core/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',

@dillonstreator dillonstreator Jul 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the locale changes: rewording existing strings is fine where this feature actually changes behavior — e.g. success.description.main has to change since we are dropping the automatic clipboard copy, and success.description.password reads better with the new share flow. Just keep rewording limited to strings whose meaning is affected by this feature (rather than general copy edits), so the five-locale diff stays reviewable.

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',
Expand Down Expand Up @@ -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: {
Expand All @@ -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',
Expand Down
43 changes: 39 additions & 4 deletions packages/core/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand All @@ -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',
Expand Down
Loading