https://github.com/nckslvrmn/whisper
Simple service for one time secret (and file) sharing
https://github.com/nckslvrmn/whisper
aes-gcm-256 container cryptography docker encryption golang scrypt secret-management security
Last synced: 3 months ago
JSON representation
Simple service for one time secret (and file) sharing
- Host: GitHub
- URL: https://github.com/nckslvrmn/whisper
- Owner: nckslvrmn
- License: mit
- Created: 2025-01-03T20:32:18.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2026-01-21T03:37:30.000Z (5 months ago)
- Last Synced: 2026-01-21T13:52:20.508Z (5 months ago)
- Topics: aes-gcm-256, container, cryptography, docker, encryption, golang, scrypt, secret-management, security
- Language: Go
- Homepage: https://secrets.slvr.io
- Size: 197 KB
- Stars: 4
- Watchers: 1
- Forks: 1
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Whisper
> 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.
[](https://go.dev/)
[](LICENSE)
[](https://en.wikipedia.org/wiki/ChaCha20-Poly1305)
[](https://en.wikipedia.org/wiki/Argon2)
[](https://webassembly.org/)
## Features
- **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 via `wasm-unsafe-eval` only; SRI hashes on all CDN resources
## Quick Start
### Docker Compose
`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.
```bash
docker compose up -d
```
### Build from Source
Prerequisites: Go >= 1.23, Rust toolchain, `wasm-pack` 0.14.0.
```bash
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.
## Configuration
### Environment Variables
#### AWS
| 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`) |
#### Google Cloud
| Variable | Required | Description |
|----------|:--------:|-------------|
| `GCP_PROJECT_ID` | Yes | Google Cloud project ID |
| `FIRESTORE_DATABASE` | Yes | Firestore database name |
| `GCS_BUCKET` | Yes | Cloud Storage bucket name |
#### Local Storage (default fallback)
Mount a volume at `/data` to persist the SQLite database and encrypted files.
Storage priority: AWS → Google Cloud → Local.
## Authentication
### AWS
Use IAM roles (recommended), environment variables (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), or the default credential chain.
Required IAM permissions:
```json
{
"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/*"
}
]
}
```
### Google Cloud
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`.
## Cryptographic Design
### WASM Module (Rust)
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 |
### Key Derivation
```
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`.
### Encryption
- **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)
### Salt-in-Passphrase Architecture
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).
### What the Server Stores
```
{
"passwordHash": "<64-char lowercase hex — HKDF auth_key>",
"encryptedData": "",
"nonce": "",
"header": "",
"encryptedMetadata": "",
"isFile": true | false,
"viewCount": 1–10 (optional),
"ttl": (optional)
}
```
The server never stores or returns the salt, the passphrase, or any key material.
## API Reference
All endpoints accept and return JSON. Rate limit: 100 requests/IP. Body limit: 10 MB.
### POST /encrypt
Store an encrypted text secret.
**Request**
```json
{
"passwordHash": "<64-char hex>",
"encryptedData": "",
"nonce": "",
"header": "",
"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**
```json
{ "status": "success", "secretId": "<16-char alphanumeric ID>" }
```
### POST /encrypt_file
Store an encrypted file secret. Same fields as `/encrypt`, plus:
```json
{
"encryptedFile": "",
"encryptedMetadata": ""
}
```
### POST /decrypt
Retrieve and consume an encrypted secret.
**Request**
```json
{
"secret_id": "<16-char alphanumeric ID>",
"passwordHash": "<64-char hex>"
}
```
**Response** (text secret)
```json
{
"encryptedData": "",
"nonce": "",
"header": "",
"isFile": false
}
```
**Response** (file secret)
```json
{
"encryptedData": "",
"encryptedFile": "",
"encryptedMetadata": "",
"nonce": "",
"header": "",
"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.
## Using the API Directly (No Frontend)
If you want to create secret bundles without the browser UI — for scripting, CLI tools, or server-to-server use — you need to replicate the client-side crypto. The following pseudocode shows the full flow.
### Storing a Text Secret
```
# 1. Generate random material
salt = random_bytes(16)
passphrase = random_printable_chars(32) # from charset a-z A-Z 0-9 !#$%&*+-=?@_~
nonce = random_bytes(24)
header = random_bytes(16)
# 2. Derive keys
root_key = Argon2id(password=passphrase, salt=salt,
m=65536, t=2, p=1, keylen=32)
enc_key = HKDF-SHA256(ikm=root_key, salt=salt,
info="whisper-encryption-v1", length=32)
auth_key = HKDF-SHA256(ikm=root_key, salt=salt,
info="whisper-auth-v1", length=32)
# 3. Encrypt
ciphertext = XChaCha20-Poly1305.Encrypt(key=enc_key, nonce=nonce,
plaintext=secret_text,
aad=header)
# 4. Encode for transport
passwordHash = hex_encode(auth_key) # 64 lowercase hex chars
encryptedData = url_safe_base64(ciphertext)
nonceB64 = url_safe_base64(nonce)
headerB64 = url_safe_base64(header)
# 5. POST to server
response = POST /encrypt {
"passwordHash": passwordHash,
"encryptedData": encryptedData,
"nonce": nonceB64,
"header": headerB64,
"viewCount": 1,
"ttl": unix_timestamp(now + 7 days)
}
secretId = response["secretId"]
# 6. Build the display passphrase to share with the recipient
# The first 24 chars are URL-safe base64 of the salt (ceil(16/3)*4 = 24).
# The next 32 chars are the raw passphrase.
display_passphrase = url_safe_base64(salt) + passphrase # 56 chars total
# Share secretId + display_passphrase with the recipient through a secure channel.
# The salt never touches the server at any point.
```
### Retrieving a Text Secret
```
# The recipient has: secretId, display_passphrase (56 chars)
# 1. Split the display passphrase
salt_b64 = display_passphrase[0:24] # first 24 chars
passphrase = display_passphrase[24:] # remaining 32 chars
salt = url_safe_base64_decode(salt_b64)
# 2. Derive auth key to authenticate with the server
root_key = Argon2id(password=passphrase, salt=salt,
m=65536, t=2, p=1, keylen=32)
auth_key = HKDF-SHA256(ikm=root_key, salt=salt,
info="whisper-auth-v1", length=32)
passwordHash = hex_encode(auth_key)
# 3. Fetch from server
response = POST /decrypt {
"secret_id": secretId,
"passwordHash": passwordHash
}
# 4. Derive encryption key and decrypt locally
enc_key = HKDF-SHA256(ikm=root_key, salt=salt,
info="whisper-encryption-v1", length=32)
nonce = url_safe_base64_decode(response["nonce"])
header = url_safe_base64_decode(response["header"])
ciphertext = url_safe_base64_decode(response["encryptedData"])
plaintext = XChaCha20-Poly1305.Decrypt(key=enc_key, nonce=nonce,
ciphertext=ciphertext,
aad=header)
```
### Storing a File Secret
```
# Same key derivation as text. Additionally:
file_bytes = read_file("secret.pdf")
meta_nonce = random_bytes(24) # separate nonce for metadata!
file_nonce = random_bytes(24)
encrypted_file = XChaCha20-Poly1305.Encrypt(key=enc_key, nonce=file_nonce,
plaintext=file_bytes, aad=header)
metadata_json = json({"file_name": "secret.pdf", "file_type": "application/pdf"})
encrypted_meta = XChaCha20-Poly1305.Encrypt(key=enc_key, nonce=meta_nonce,
plaintext=metadata_json, aad=header)
# Prepend meta_nonce to the metadata ciphertext blob
encrypted_metadata_blob = meta_nonce + encrypted_meta
response = POST /encrypt_file {
"passwordHash": hex_encode(auth_key),
"nonce": url_safe_base64(file_nonce),
"header": url_safe_base64(header),
"encryptedFile": standard_base64(encrypted_file),
"encryptedMetadata": standard_base64(encrypted_metadata_blob),
"viewCount": 1,
"ttl": unix_timestamp(now + 7 days)
}
```
**Important**: File data uses `standard_base64` (with `+`, `/`, and `=` padding).
Nonces and headers use `url_safe_base64` (with `-`, `_`). Match the encoding exactly
or the server will reject the request or clients will fail to decode.
## Security Architecture
### Content Security Policy
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.
### Other Security Controls
- **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**: `passwordHash` comparison uses `crypto/subtle`
- **SRI hashes**: All Bootstrap and Font Awesome CDN resources are pinned with `integrity=` hashes
### Known Limitations
- 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-pack` was archived in July 2025; 0.14.0 is the last release
## Production Deployment
### Nginx
```nginx
server {
listen 443 ssl http2;
server_name secrets.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes
4. Open a Pull Request
## License
MIT License — see [LICENSE](LICENSE) for details.
## Acknowledgments
- Go backend: [Echo Framework](https://echo.labstack.com/)
- Rust crypto: [RustCrypto](https://github.com/RustCrypto) crates (chacha20poly1305, argon2, hkdf, sha2)
- WASM toolchain: [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) / [wasm-pack](https://github.com/rustwasm/wasm-pack)
- Cloud storage: [AWS SDK Go v2](https://github.com/aws/aws-sdk-go-v2), [Google Cloud Go SDK](https://github.com/googleapis/google-cloud-go)
- UI: [Bootstrap 5.3.8](https://getbootstrap.com/), [Font Awesome 7](https://fontawesome.com/)