End-to-end encrypted secret sharing service with WebAssembly-powered client-side encryption. Share sensitive information with true zero-knowledge architecture — your secrets are encrypted in your browser before ever leaving your device.
- True End-to-End Encryption — All encryption/decryption happens in your browser via a Rust-compiled WebAssembly module
- XChaCha20-Poly1305 — Authenticated encryption with 192-bit nonces; no nonce-reuse risk
- Argon2id + HKDF key splitting — Memory-hard KDF with separate encryption and authentication keys derived via HKDF-SHA256
- Salt-in-passphrase architecture — The Argon2 salt is embedded in the display passphrase and never stored or transmitted to the server; the server cannot mount an offline brute-force attack even if compromised
- Self-destructing secrets — Configurable view limits and TTL expiry
- Text and file support — Share passwords, API keys, documents, or any sensitive file up to 10 MB
- Multi-storage backend — AWS (DynamoDB + S3), Google Cloud (Firestore + GCS), or local SQLite + filesystem
- Zero server trust — Server stores only ciphertext, nonce, header, and a 64-hex-char HKDF-derived auth key; plaintext and encryption keys never leave the browser
- Hardened CSP — No
unsafe-inline; WASM permitted viawasm-unsafe-evalonly; SRI hashes on all CDN resources
compose.yml in the repo root is the canonical deployment configuration. It defaults to the AWS backend; comments inside show how to switch to Google Cloud or local storage.
docker compose up -dPrerequisites: Go >= 1.23, Rust toolchain, wasm-pack 0.14.0.
git clone https://github.com/nckslvrmn/whisper.git
cd whisper
# Build the Rust WASM crypto module
make wasm
# Build the Go server
make server
# Or build the Docker image (handles both steps)
docker build -t whisper .make wasm invokes wasm-pack build --target web inside wasm/ and copies the
resulting crypto.js and crypto_bg.wasm into web/static/. The Dockerfile pins
wasm-pack at version 0.14.0 for reproducibility.
| Variable | Required | Description |
|---|---|---|
DYNAMO_TABLE |
Yes | DynamoDB table name |
S3_BUCKET |
Yes | S3 bucket name for encrypted files |
AWS_REGION |
No | AWS region (default: us-east-1) |
| Variable | Required | Description |
|---|---|---|
GCP_PROJECT_ID |
Yes | Google Cloud project ID |
FIRESTORE_DATABASE |
Yes | Firestore database name |
GCS_BUCKET |
Yes | Cloud Storage bucket name |
Mount a volume at /data to persist the SQLite database and encrypted files.
Storage priority: AWS → Google Cloud → Local.
Use IAM roles (recommended), environment variables (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), or the default credential chain.
Required IAM permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:DeleteItem", "dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:*:*:table/YOUR_TABLE_NAME"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
}
]
}Set GOOGLE_APPLICATION_CREDENTIALS to a service account key file, or rely on Application Default Credentials in GCP environments.
Required roles: roles/datastore.user, roles/storage.objectAdmin.
The crypto module lives in wasm/src/lib.rs and is compiled to WASM via wasm-pack. It exports four functions to JavaScript:
| Export | Purpose |
|---|---|
encryptText(text, viewCount?, ttlDays?, ttlTimestamp?) |
Encrypt a text secret |
encryptFile(fileDataB64, fileName, fileType, viewCount?, ttlDays?, ttlTimestamp?) |
Encrypt a file + metadata |
decryptText(encryptedDataB64, passphrase, nonceB64, saltB64, headerB64) |
Decrypt a text secret |
decryptFile(encryptedFileB64, encryptedMetadataB64, passphrase, nonceB64, saltB64, headerB64) |
Decrypt a file + metadata |
hashPassword(password, saltB64) |
Derive the auth key for a given passphrase + salt |
passphrase (32 random chars)
│
▼
Argon2id(passphrase, salt, m=64MB, t=2, p=1) ──► root_key (32 bytes)
│
▼
HKDF-SHA256(root_key, salt)
├──► enc_key (label "whisper-encryption-v1") — used for XChaCha20-Poly1305
└──► auth_key (label "whisper-auth-v1") — hex-encoded and stored as passwordHash
Why two keys? The original Go implementation derived one key from scrypt and used it for both encryption and as the server-side authentication hash. This meant the server effectively held the encryption key. HKDF splits the root into two independent 32-byte keys so the server's passwordHash reveals nothing about enc_key.
- Algorithm: XChaCha20-Poly1305 (192-bit nonce, 128-bit Poly1305 tag)
- Nonce: 24 random bytes per secret, stored alongside the ciphertext
- Header: 16 random bytes used as Additional Authenticated Data (AAD); stored alongside the ciphertext; prevents cross-context ciphertext reuse
- File metadata: Encrypted separately with its own random nonce (
meta_nonce) prepended to the metadata ciphertext blob — eliminating the nonce-reuse vulnerability present in the original Go implementation (which used the same nonce for both file data and metadata under AES-GCM)
The Argon2 salt (16 random bytes) is never stored or transmitted to the server. Instead, it is embedded directly in the display passphrase that users share:
display_passphrase = URL_SAFE_BASE64(salt) [24 chars] + random_chars [32 chars]
└─────────────────────────────────────────────────────────┘
56 chars total
When decrypting, the browser splits the display passphrase at character 24 to recover the salt and the actual Argon2 passphrase. No pre-flight request to the server is needed; decryption is a single round-trip.
Security consequence: An attacker who compromises the server's database obtains passwordHash, encryptedData, nonce, and header — but not the salt. Without the salt they cannot run Argon2 at all, making offline brute-force attacks impossible even from a fully compromised database. The attacker also needs the user's display passphrase (which contains the salt).
{
"passwordHash": "<64-char lowercase hex — HKDF auth_key>",
"encryptedData": "<URL-safe base64 ciphertext>",
"nonce": "<URL-safe base64, 24 bytes>",
"header": "<URL-safe base64, 16 bytes>",
"encryptedMetadata": "<base64, for file secrets only>",
"isFile": true | false,
"viewCount": 1–10 (optional),
"ttl": <unix timestamp> (optional)
}
The server never stores or returns the salt, the passphrase, or any key material.
All endpoints accept and return JSON. Rate limit: 100 requests/IP. Body limit: 10 MB.
Store an encrypted text secret.
Request
{
"passwordHash": "<64-char hex>",
"encryptedData": "<url-safe base64 ciphertext>",
"nonce": "<url-safe base64, 24 bytes>",
"header": "<url-safe base64, 16 bytes>",
"viewCount": 1,
"ttl": 1735689600
}viewCount (1–10) and ttl (Unix timestamp, max 30 days out) are optional when advanced features are enabled. When advanced features are disabled they are required.
Response
{ "status": "success", "secretId": "<16-char alphanumeric ID>" }Store an encrypted file secret. Same fields as /encrypt, plus:
{
"encryptedFile": "<standard base64 encrypted file bytes>",
"encryptedMetadata": "<standard base64 — meta_nonce || encrypted JSON metadata>"
}Retrieve and consume an encrypted secret.
Request
{
"secret_id": "<16-char alphanumeric ID>",
"passwordHash": "<64-char hex>"
}Response (text secret)
{
"encryptedData": "<url-safe base64 ciphertext>",
"nonce": "<url-safe base64>",
"header": "<url-safe base64>",
"isFile": false
}Response (file secret)
{
"encryptedData": "<url-safe base64>",
"encryptedFile": "<standard base64 encrypted file bytes>",
"encryptedMetadata": "<standard base64 — meta_nonce || encrypted metadata>",
"nonce": "<url-safe base64>",
"header": "<url-safe base64>",
"isFile": true
}The server validates passwordHash with a constant-time comparison. Each successful /decrypt call decrements the view counter; when it reaches zero, the secret is deleted. If ttl has expired the secret is also deleted and 404 is returned.
If you want to create and retrieve secrets programmatically — for scripting, CLI tools, or server-to-server use — use the Whisper SDK to handle the cryptographic details:
- Go SDK: whisper-go — Type-safe client with full support for text and file secrets, key derivation, and encryption/decryption.
The SDK encapsulates the salt-in-passphrase architecture, key derivation, and authenticated encryption so you don't have to.
The server sets a strict CSP with no unsafe-inline:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval' https://cdnjs.cloudflare.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com;
font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com;
img-src 'self' data:;
connect-src 'self' https://cdnjs.cloudflare.com;
frame-ancestors 'none';
base-uri 'self';
object-src 'none';
wasm-unsafe-eval is required for WebAssembly.instantiateStreaming() and permits
WASM bytecode compilation only — it does not enable eval() for JavaScript.
- HSTS:
max-age=31536000 - X-Frame-Options:
DENY - X-Content-Type-Options:
nosniff - Referrer-Policy:
strict-origin-when-cross-origin - Rate limiting: 100 requests/IP (in-memory)
- Body limit: 10 MB per request
- Request timeout: 30 seconds
- Constant-time comparison:
passwordHashcomparison usescrypto/subtle - SRI hashes: All Bootstrap and Font Awesome CDN resources are pinned with
integrity=hashes
- Argon2 runs synchronously on the browser's main thread (~1–2 s UI pause during key derivation)
- View-count decrement has a TOCTOU race; no atomic CAS is implemented in the storage layer
wasm-packwas archived in July 2025; 0.14.0 is the last release
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes
- Open a Pull Request
MIT License — see LICENSE for details.
- Go backend: Echo Framework
- Rust crypto: RustCrypto crates (chacha20poly1305, argon2, hkdf, sha2)
- WASM toolchain: wasm-bindgen / wasm-pack
- Cloud storage: AWS SDK Go v2, Google Cloud Go SDK
- UI: Bootstrap 5.3.8, Font Awesome 7