{"id":35429879,"url":"https://github.com/nckslvrmn/whisper","last_synced_at":"2026-03-16T04:06:22.331Z","repository":{"id":278765553,"uuid":"911777447","full_name":"nckslvrmn/whisper","owner":"nckslvrmn","description":"Simple service for one time secret (and file) sharing","archived":false,"fork":false,"pushed_at":"2026-01-21T03:37:30.000Z","size":202,"stargazers_count":4,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-01-21T13:52:20.508Z","etag":null,"topics":["aes-gcm-256","container","cryptography","docker","encryption","golang","scrypt","secret-management","security"],"latest_commit_sha":null,"homepage":"https://secrets.slvr.io","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nckslvrmn.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-01-03T20:32:18.000Z","updated_at":"2026-01-21T03:37:33.000Z","dependencies_parsed_at":"2026-01-03T06:07:29.584Z","dependency_job_id":null,"html_url":"https://github.com/nckslvrmn/whisper","commit_stats":null,"previous_names":["nckslvrmn/go_ots","nckslvrmn/whisper"],"tags_count":12,"template":false,"template_full_name":null,"purl":"pkg:github/nckslvrmn/whisper","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nckslvrmn%2Fwhisper","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nckslvrmn%2Fwhisper/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nckslvrmn%2Fwhisper/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nckslvrmn%2Fwhisper/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nckslvrmn","download_url":"https://codeload.github.com/nckslvrmn/whisper/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nckslvrmn%2Fwhisper/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29667139,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-20T19:49:36.704Z","status":"ssl_error","status_checked_at":"2026-02-20T19:44:05.372Z","response_time":59,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aes-gcm-256","container","cryptography","docker","encryption","golang","scrypt","secret-management","security"],"created_at":"2026-01-02T20:17:11.931Z","updated_at":"2026-03-16T04:06:22.317Z","avatar_url":"https://github.com/nckslvrmn.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Whisper\n\n\u003e 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.\n\n[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-00ADD8?logo=go)](https://go.dev/)\n[![License](https://img.shields.io/github/license/nckslvrmn/whisper)](LICENSE)\n[![Security](https://img.shields.io/badge/encryption-XChaCha20--Poly1305-green?logo=shield)](https://en.wikipedia.org/wiki/ChaCha20-Poly1305)\n[![KDF](https://img.shields.io/badge/KDF-Argon2id-blue)](https://en.wikipedia.org/wiki/Argon2)\n[![WASM](https://img.shields.io/badge/WASM-Rust-orange?logo=webassembly)](https://webassembly.org/)\n\n## Features\n\n- **True End-to-End Encryption** — All encryption/decryption happens in your browser via a Rust-compiled WebAssembly module\n- **XChaCha20-Poly1305** — Authenticated encryption with 192-bit nonces; no nonce-reuse risk\n- **Argon2id + HKDF key splitting** — Memory-hard KDF with separate encryption and authentication keys derived via HKDF-SHA256\n- **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\n- **Self-destructing secrets** — Configurable view limits and TTL expiry\n- **Text and file support** — Share passwords, API keys, documents, or any sensitive file up to 10 MB\n- **Multi-storage backend** — AWS (DynamoDB + S3), Google Cloud (Firestore + GCS), or local SQLite + filesystem\n- **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\n- **Hardened CSP** — No `unsafe-inline`; WASM permitted via `wasm-unsafe-eval` only; SRI hashes on all CDN resources\n\n## Quick Start\n\n### Docker Compose\n\n`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.\n\n```bash\ndocker compose up -d\n```\n\n### Build from Source\n\nPrerequisites: Go \u003e= 1.23, Rust toolchain, `wasm-pack` 0.14.0.\n\n```bash\ngit clone https://github.com/nckslvrmn/whisper.git\ncd whisper\n\n# Build the Rust WASM crypto module\nmake wasm\n\n# Build the Go server\nmake server\n\n# Or build the Docker image (handles both steps)\ndocker build -t whisper .\n```\n\n`make wasm` invokes `wasm-pack build --target web` inside `wasm/` and copies the\nresulting `crypto.js` and `crypto_bg.wasm` into `web/static/`. The Dockerfile pins\n`wasm-pack` at version 0.14.0 for reproducibility.\n\n## Configuration\n\n### Environment Variables\n\n#### AWS\n\n| Variable | Required | Description |\n|----------|:--------:|-------------|\n| `DYNAMO_TABLE` | Yes | DynamoDB table name |\n| `S3_BUCKET` | Yes | S3 bucket name for encrypted files |\n| `AWS_REGION` | No | AWS region (default: `us-east-1`) |\n\n#### Google Cloud\n\n| Variable | Required | Description |\n|----------|:--------:|-------------|\n| `GCP_PROJECT_ID` | Yes | Google Cloud project ID |\n| `FIRESTORE_DATABASE` | Yes | Firestore database name |\n| `GCS_BUCKET` | Yes | Cloud Storage bucket name |\n\n#### Local Storage (default fallback)\n\nMount a volume at `/data` to persist the SQLite database and encrypted files.\nStorage priority: AWS → Google Cloud → Local.\n\n## Authentication\n\n### AWS\n\nUse IAM roles (recommended), environment variables (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), or the default credential chain.\n\nRequired IAM permissions:\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\"dynamodb:PutItem\", \"dynamodb:GetItem\", \"dynamodb:DeleteItem\", \"dynamodb:UpdateItem\"],\n      \"Resource\": \"arn:aws:dynamodb:*:*:table/YOUR_TABLE_NAME\"\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\"s3:PutObject\", \"s3:GetObject\", \"s3:DeleteObject\"],\n      \"Resource\": \"arn:aws:s3:::YOUR_BUCKET_NAME/*\"\n    }\n  ]\n}\n```\n\n### Google Cloud\n\nSet `GOOGLE_APPLICATION_CREDENTIALS` to a service account key file, or rely on Application Default Credentials in GCP environments.\n\nRequired roles: `roles/datastore.user`, `roles/storage.objectAdmin`.\n\n## Cryptographic Design\n\n### WASM Module (Rust)\n\nThe crypto module lives in `wasm/src/lib.rs` and is compiled to WASM via `wasm-pack`. It exports four functions to JavaScript:\n\n| Export | Purpose |\n|--------|---------|\n| `encryptText(text, viewCount?, ttlDays?, ttlTimestamp?)` | Encrypt a text secret |\n| `encryptFile(fileDataB64, fileName, fileType, viewCount?, ttlDays?, ttlTimestamp?)` | Encrypt a file + metadata |\n| `decryptText(encryptedDataB64, passphrase, nonceB64, saltB64, headerB64)` | Decrypt a text secret |\n| `decryptFile(encryptedFileB64, encryptedMetadataB64, passphrase, nonceB64, saltB64, headerB64)` | Decrypt a file + metadata |\n| `hashPassword(password, saltB64)` | Derive the auth key for a given passphrase + salt |\n\n### Key Derivation\n\n```\npassphrase (32 random chars)\n    │\n    ▼\nArgon2id(passphrase, salt, m=64MB, t=2, p=1) ──► root_key (32 bytes)\n    │\n    ▼\nHKDF-SHA256(root_key, salt)\n    ├──► enc_key  (label \"whisper-encryption-v1\")  — used for XChaCha20-Poly1305\n    └──► auth_key (label \"whisper-auth-v1\")         — hex-encoded and stored as passwordHash\n```\n\n**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`.\n\n### Encryption\n\n- **Algorithm**: XChaCha20-Poly1305 (192-bit nonce, 128-bit Poly1305 tag)\n- **Nonce**: 24 random bytes per secret, stored alongside the ciphertext\n- **Header**: 16 random bytes used as Additional Authenticated Data (AAD); stored alongside the ciphertext; prevents cross-context ciphertext reuse\n- **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)\n\n### Salt-in-Passphrase Architecture\n\nThe 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:\n\n```\ndisplay_passphrase = URL_SAFE_BASE64(salt) [24 chars] + random_chars [32 chars]\n                     └─────────────────────────────────────────────────────────┘\n                                         56 chars total\n```\n\nWhen 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.\n\n**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).\n\n### What the Server Stores\n\n```\n{\n  \"passwordHash\":      \"\u003c64-char lowercase hex — HKDF auth_key\u003e\",\n  \"encryptedData\":     \"\u003cURL-safe base64 ciphertext\u003e\",\n  \"nonce\":             \"\u003cURL-safe base64, 24 bytes\u003e\",\n  \"header\":            \"\u003cURL-safe base64, 16 bytes\u003e\",\n  \"encryptedMetadata\": \"\u003cbase64, for file secrets only\u003e\",\n  \"isFile\":            true | false,\n  \"viewCount\":         1–10   (optional),\n  \"ttl\":               \u003cunix timestamp\u003e (optional)\n}\n```\n\nThe server never stores or returns the salt, the passphrase, or any key material.\n\n## API Reference\n\nAll endpoints accept and return JSON. Rate limit: 100 requests/IP. Body limit: 10 MB.\n\n### POST /encrypt\n\nStore an encrypted text secret.\n\n**Request**\n\n```json\n{\n  \"passwordHash\":  \"\u003c64-char hex\u003e\",\n  \"encryptedData\": \"\u003curl-safe base64 ciphertext\u003e\",\n  \"nonce\":         \"\u003curl-safe base64, 24 bytes\u003e\",\n  \"header\":        \"\u003curl-safe base64, 16 bytes\u003e\",\n  \"viewCount\":     1,\n  \"ttl\":           1735689600\n}\n```\n\n`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.\n\n**Response**\n\n```json\n{ \"status\": \"success\", \"secretId\": \"\u003c16-char alphanumeric ID\u003e\" }\n```\n\n### POST /encrypt_file\n\nStore an encrypted file secret. Same fields as `/encrypt`, plus:\n\n```json\n{\n  \"encryptedFile\":     \"\u003cstandard base64 encrypted file bytes\u003e\",\n  \"encryptedMetadata\": \"\u003cstandard base64 — meta_nonce || encrypted JSON metadata\u003e\"\n}\n```\n\n### POST /decrypt\n\nRetrieve and consume an encrypted secret.\n\n**Request**\n\n```json\n{\n  \"secret_id\":    \"\u003c16-char alphanumeric ID\u003e\",\n  \"passwordHash\": \"\u003c64-char hex\u003e\"\n}\n```\n\n**Response** (text secret)\n\n```json\n{\n  \"encryptedData\": \"\u003curl-safe base64 ciphertext\u003e\",\n  \"nonce\":         \"\u003curl-safe base64\u003e\",\n  \"header\":        \"\u003curl-safe base64\u003e\",\n  \"isFile\":        false\n}\n```\n\n**Response** (file secret)\n\n```json\n{\n  \"encryptedData\":     \"\u003curl-safe base64\u003e\",\n  \"encryptedFile\":     \"\u003cstandard base64 encrypted file bytes\u003e\",\n  \"encryptedMetadata\": \"\u003cstandard base64 — meta_nonce || encrypted metadata\u003e\",\n  \"nonce\":             \"\u003curl-safe base64\u003e\",\n  \"header\":            \"\u003curl-safe base64\u003e\",\n  \"isFile\":            true\n}\n```\n\nThe 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.\n\n## Using the API Directly (No Frontend)\n\nIf 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.\n\n### Storing a Text Secret\n\n```\n# 1. Generate random material\nsalt        = random_bytes(16)\npassphrase  = random_printable_chars(32)   # from charset a-z A-Z 0-9 !#$%\u0026*+-=?@_~\nnonce       = random_bytes(24)\nheader      = random_bytes(16)\n\n# 2. Derive keys\nroot_key    = Argon2id(password=passphrase, salt=salt,\n                       m=65536, t=2, p=1, keylen=32)\nenc_key     = HKDF-SHA256(ikm=root_key, salt=salt,\n                           info=\"whisper-encryption-v1\", length=32)\nauth_key    = HKDF-SHA256(ikm=root_key, salt=salt,\n                           info=\"whisper-auth-v1\",       length=32)\n\n# 3. Encrypt\nciphertext  = XChaCha20-Poly1305.Encrypt(key=enc_key, nonce=nonce,\n                                          plaintext=secret_text,\n                                          aad=header)\n\n# 4. Encode for transport\npasswordHash    = hex_encode(auth_key)           # 64 lowercase hex chars\nencryptedData   = url_safe_base64(ciphertext)\nnonceB64        = url_safe_base64(nonce)\nheaderB64       = url_safe_base64(header)\n\n# 5. POST to server\nresponse = POST /encrypt {\n  \"passwordHash\":  passwordHash,\n  \"encryptedData\": encryptedData,\n  \"nonce\":         nonceB64,\n  \"header\":        headerB64,\n  \"viewCount\":     1,\n  \"ttl\":           unix_timestamp(now + 7 days)\n}\nsecretId = response[\"secretId\"]\n\n# 6. Build the display passphrase to share with the recipient\n#    The first 24 chars are URL-safe base64 of the salt (ceil(16/3)*4 = 24).\n#    The next 32 chars are the raw passphrase.\ndisplay_passphrase = url_safe_base64(salt) + passphrase   # 56 chars total\n\n# Share secretId + display_passphrase with the recipient through a secure channel.\n# The salt never touches the server at any point.\n```\n\n### Retrieving a Text Secret\n\n```\n# The recipient has: secretId, display_passphrase (56 chars)\n\n# 1. Split the display passphrase\nsalt_b64    = display_passphrase[0:24]      # first 24 chars\npassphrase  = display_passphrase[24:]       # remaining 32 chars\nsalt        = url_safe_base64_decode(salt_b64)\n\n# 2. Derive auth key to authenticate with the server\nroot_key    = Argon2id(password=passphrase, salt=salt,\n                       m=65536, t=2, p=1, keylen=32)\nauth_key    = HKDF-SHA256(ikm=root_key, salt=salt,\n                           info=\"whisper-auth-v1\", length=32)\npasswordHash = hex_encode(auth_key)\n\n# 3. Fetch from server\nresponse = POST /decrypt {\n  \"secret_id\":    secretId,\n  \"passwordHash\": passwordHash\n}\n\n# 4. Derive encryption key and decrypt locally\nenc_key     = HKDF-SHA256(ikm=root_key, salt=salt,\n                           info=\"whisper-encryption-v1\", length=32)\nnonce       = url_safe_base64_decode(response[\"nonce\"])\nheader      = url_safe_base64_decode(response[\"header\"])\nciphertext  = url_safe_base64_decode(response[\"encryptedData\"])\n\nplaintext   = XChaCha20-Poly1305.Decrypt(key=enc_key, nonce=nonce,\n                                          ciphertext=ciphertext,\n                                          aad=header)\n```\n\n### Storing a File Secret\n\n```\n# Same key derivation as text. Additionally:\n\nfile_bytes       = read_file(\"secret.pdf\")\nmeta_nonce       = random_bytes(24)           # separate nonce for metadata!\nfile_nonce       = random_bytes(24)\n\nencrypted_file   = XChaCha20-Poly1305.Encrypt(key=enc_key, nonce=file_nonce,\n                                               plaintext=file_bytes, aad=header)\n\nmetadata_json    = json({\"file_name\": \"secret.pdf\", \"file_type\": \"application/pdf\"})\nencrypted_meta   = XChaCha20-Poly1305.Encrypt(key=enc_key, nonce=meta_nonce,\n                                               plaintext=metadata_json, aad=header)\n\n# Prepend meta_nonce to the metadata ciphertext blob\nencrypted_metadata_blob = meta_nonce + encrypted_meta\n\nresponse = POST /encrypt_file {\n  \"passwordHash\":      hex_encode(auth_key),\n  \"nonce\":             url_safe_base64(file_nonce),\n  \"header\":            url_safe_base64(header),\n  \"encryptedFile\":     standard_base64(encrypted_file),\n  \"encryptedMetadata\": standard_base64(encrypted_metadata_blob),\n  \"viewCount\":         1,\n  \"ttl\":               unix_timestamp(now + 7 days)\n}\n```\n\n**Important**: File data uses `standard_base64` (with `+`, `/`, and `=` padding).\nNonces and headers use `url_safe_base64` (with `-`, `_`). Match the encoding exactly\nor the server will reject the request or clients will fail to decode.\n\n## Security Architecture\n\n### Content Security Policy\n\nThe server sets a strict CSP with no `unsafe-inline`:\n\n```\ndefault-src 'self';\nscript-src  'self' 'wasm-unsafe-eval' https://cdnjs.cloudflare.com;\nstyle-src   'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com;\nfont-src    'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com;\nimg-src     'self' data:;\nconnect-src 'self' https://cdnjs.cloudflare.com;\nframe-ancestors 'none';\nbase-uri    'self';\nobject-src  'none';\n```\n\n`wasm-unsafe-eval` is required for `WebAssembly.instantiateStreaming()` and permits\nWASM bytecode compilation only — it does not enable `eval()` for JavaScript.\n\n### Other Security Controls\n\n- **HSTS**: `max-age=31536000`\n- **X-Frame-Options**: `DENY`\n- **X-Content-Type-Options**: `nosniff`\n- **Referrer-Policy**: `strict-origin-when-cross-origin`\n- **Rate limiting**: 100 requests/IP (in-memory)\n- **Body limit**: 10 MB per request\n- **Request timeout**: 30 seconds\n- **Constant-time comparison**: `passwordHash` comparison uses `crypto/subtle`\n- **SRI hashes**: All Bootstrap and Font Awesome CDN resources are pinned with `integrity=` hashes\n\n### Known Limitations\n\n- Argon2 runs synchronously on the browser's main thread (~1–2 s UI pause during key derivation)\n- View-count decrement has a TOCTOU race; no atomic CAS is implemented in the storage layer\n- `wasm-pack` was archived in July 2025; 0.14.0 is the last release\n\n## Production Deployment\n\n### Nginx\n\n```nginx\nserver {\n    listen 443 ssl http2;\n    server_name secrets.yourdomain.com;\n\n    ssl_certificate     /path/to/cert.pem;\n    ssl_certificate_key /path/to/key.pem;\n    ssl_protocols       TLSv1.2 TLSv1.3;\n\n    location / {\n        proxy_pass http://localhost:8080;\n        proxy_set_header Host              $host;\n        proxy_set_header X-Real-IP         $remote_addr;\n        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n## Contributing\n\n1. Fork the repository\n2. Create your feature branch (`git checkout -b feature/amazing-feature`)\n3. Commit your changes\n4. Open a Pull Request\n\n## License\n\nMIT License — see [LICENSE](LICENSE) for details.\n\n## Acknowledgments\n\n- Go backend: [Echo Framework](https://echo.labstack.com/)\n- Rust crypto: [RustCrypto](https://github.com/RustCrypto) crates (chacha20poly1305, argon2, hkdf, sha2)\n- WASM toolchain: [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) / [wasm-pack](https://github.com/rustwasm/wasm-pack)\n- 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)\n- UI: [Bootstrap 5.3.8](https://getbootstrap.com/), [Font Awesome 7](https://fontawesome.com/)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnckslvrmn%2Fwhisper","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnckslvrmn%2Fwhisper","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnckslvrmn%2Fwhisper/lists"}