Skip to content

nckslvrmn/whisper

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

103 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

Go Version License Security KDF WASM

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.

docker compose up -d

Build from Source

Prerequisites: 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.

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:

{
  "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":     "<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.

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

{
  "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>" }

POST /encrypt_file

Store an encrypted file secret. Same fields as /encrypt, plus:

{
  "encryptedFile":     "<standard base64 encrypted file bytes>",
  "encryptedMetadata": "<standard base64 — meta_nonce || encrypted JSON metadata>"
}

POST /decrypt

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.

Using the API with an SDK

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.

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

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 for details.

Acknowledgments