A self-hosted, one-shot password-sharing service where the server never sees plaintext or the decryption key. A .NET 10 port of Pinterest's SnapPass, rewritten with a client-side-crypto threat model.
- Why
- How it works
- Security model
- Quick start
- Configuration
- Development
- Testing
- Deployment
- Architecture
- Contributing
- License
Password-sharing tools that encrypt on the server are only as trustworthy as the server itself. If the process memory, database, or logs leak, every secret in flight at that moment leaks with them. Snappass.NET does the encryption in the browser and keeps the decryption key out of every HTTP message, so even a complete database read-out does not reveal any secret.
- The sender opens the share page. The browser generates an AES-256-GCM key via the Web Crypto API.
- The sender picks a TTL (1 hour up to 3 months) and a view limit (1 / 2 / 3 / 5 / 10 / 20 / 50 / unlimited). Whichever bound is hit first destroys the secret; unlimited views still expire at TTL.
- The browser encrypts the plaintext locally and
POSTs only the ciphertext to the server, which stores(id, ciphertext, expires_at, remaining_views). - The share URL takes the form
https://host/s/<id>#<key>. The URL fragment after#is never sent in any HTTP request, so neither the server nor any proxy/log/link-preview crawler sees the key. - The recipient opens the link. The reveal page reads the key from
location.hash, fetches the ciphertext, and decrypts it locally. - Retrieval is atomic: in a single SQLite transaction the server either
decrements
remaining_viewsand returns the ciphertext, or — on the final permitted view — deletes the row and returns the ciphertext. A background service additionally purges expired rows every five minutes.
| Threat | Mitigation |
|---|---|
| Server compromise with full database read | Server stores only AES-256-GCM ciphertext; the key is never transmitted |
| Log leaks (application, reverse-proxy, WAF) | Key lives in the URL fragment, which is not sent in requests or the Referer header |
| Link-preview crawlers (Slack, Teams, iMessage, …) | Crawlers fetch the URL but not the fragment; they never see the key |
| Replay after view limit | Atomic read-then-decrement (or delete on final view) in a single SQLite transaction |
| Replay after TTL | Per-consume expiry check plus background purge; secure_delete=ON zero-wipes freed pages |
| Brute force on secret IDs | 128-bit random IDs (Guid.NewGuid().ToString("N")); endpoints are rate-limited per IP |
| Cross-site request forgery | Origin header match required on every state-changing POST; strict CSP; no same-origin cookies |
| Oversized or abusive payloads | Kestrel MaxRequestBodySize = 128 KiB; per-endpoint rate limiter |
Snappass.NET deliberately does not defend against:
- A malicious recipient forwarding the secret or the link before consuming it.
- Compromised sender or recipient devices (keyloggers, screen capture, malicious browser extensions with DOM access).
- Phishing or typosquatted domains that serve a modified page.
- A malicious operator who modifies the JavaScript served to clients — this tool requires trusting the operator.
- Traffic analysis: the operator sees source IPs, timing, and ciphertext size.
Requirements: Docker and Docker Compose.
git clone https://github.com/rasms/Snappass.NET.git
cd Snappass.NET
docker compose up -dThe service listens on http://localhost:8080. Put it behind a TLS-terminating
reverse proxy before exposing it publicly.
Pre-built images are published to GitHub Container Registry on every push to
main and on tagged releases:
docker pull ghcr.io/rasms/snappass.net:latestTo use the pre-built image without cloning the repo, create a
docker-compose.yml with image: instead of build::
services:
snappass:
image: ghcr.io/rasms/snappass.net:latest
ports:
- "8080:8080"
volumes:
- snappass-data:/data
environment:
ASPNETCORE_ENVIRONMENT: Production
Storage__DatabasePath: /data/database.sqlite
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
snappass-data:Pin a specific version (ghcr.io/rasms/snappass.net:vX.Y.Z or
sha-<7-char>) in production so docker compose pull doesn't surprise
you with an unreviewed upgrade.
All configuration is via environment variables (ASP.NET Core's standard
__ delimiter maps to nested JSON).
| Variable | Default | Description |
|---|---|---|
Storage__DatabasePath |
database.sqlite |
Path to the SQLite database file |
ASPNETCORE_URLS |
http://+:8080 (Docker) |
Kestrel bind address |
ASPNETCORE_ENVIRONMENT |
Production |
Development enables the developer exception page and skips HTTPS redirect |
ASPNETCORE_FORWARDEDHEADERS_ENABLED |
unset | Set to true behind a trusted reverse proxy |
Logging__LogLevel__Default |
Warning (Production) |
Minimum log level |
TTL options cover transient secrets (1 hour) through quarterly rotation windows (3 months):
| TTL value | Duration |
|---|---|
Hour |
1 hour |
Day |
1 day |
TwoDays |
2 days |
ThreeDays |
3 days |
Week |
7 days |
TwoWeeks |
14 days |
Month |
31 days |
ThreeMonths |
93 days |
View-limit options:
| Value | Meaning |
|---|---|
1 |
One-shot (default) — atomic read-then-delete, classic SnapPass |
2 |
Two permitted reads, then delete |
3 |
Three permitted reads, then delete |
5 |
Five permitted reads, then delete |
10 |
Ten permitted reads, then delete |
20 |
Twenty permitted reads, then delete |
50 |
Fifty permitted reads, then delete |
0 |
Unlimited reads within TTL — the row is destroyed only when the TTL fires |
The server does not expose the remaining view count to the recipient, to avoid leaking consumption state to anyone who merely holds the URL. Even the unlimited option is still bounded by TTL, so destructive read remains the upper bound on a secret's lifetime.
| Endpoint | Limit |
|---|---|
POST /api/secrets |
10/min per IP |
POST /api/secrets/:id/consume |
30/min per IP |
GET /api/secrets/:id/exists |
60/min per IP |
Limits are per-IP fixed windows, implemented in-process. Horizontal scaling across instances would require a shared backing store.
Requirements:
- .NET 10 SDK
- Node.js 20 (only for the frontend build)
dotnet run --project Snappass.NETThe Snappass.NET.csproj wires npm install and npm run build into
dotnet build via MSBuild targets, so a single dotnet run also builds the
TypeScript and Tailwind bundles.
Frontend sources live in Snappass.NET/src/ (TypeScript) and
Snappass.NET/Styles/ (Tailwind input). Built artifacts go to
Snappass.NET/wwwroot/js/ and Snappass.NET/wwwroot/css/ and are
.gitignored.
dotnet testSnappass.NET.UnitTest— 13 tests covering the store, the extended TTL matrix, one-shot / multi-view / unlimited-view semantics, and the background-purge behaviour.Snappass.NET.IntegrationTest— 14 tests (+1 skip) driving the full HTTP pipeline throughWebApplicationFactory<Program>: Origin checks, validation, security headers, routing, multi-view and unlimited-view round-trips, extended-TTL acceptance.
The Post_OversizedBody_Returns413 integration test is skipped because
TestServer is in-process and bypasses Kestrel's MaxRequestBodySize; the
413 guard only fires on real Kestrel and is smoke-tested manually.
The provided docker-compose.yml applies several hardening flags:
read_only: true— the container root filesystem is immutable at runtimetmpfs: [/tmp]— writable scratch only where neededcap_drop: [ALL]— no Linux capabilitiessecurity_opt: [no-new-privileges:true]— setuid and friends blocked- Named volume at
/datafor SQLite persistence - Non-root
appuser (default in the chiseled runtime image)
The chiseled runtime image has no shell, curl, or wget, so in-container
healthchecks are not available. Probe GET /healthz from your reverse
proxy or orchestrator instead:
snappass.example.com {
reverse_proxy localhost:8080
health_uri /healthz
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}If you rely on forwarded headers for rate-limit partitioning, configure
KnownProxies / KnownNetworks in Program.cs — the defaults are
intentionally empty to avoid trusting arbitrary proxies.
Images are published to ghcr.io/rasms/snappass.net with the following tags:
| Tag | Source |
|---|---|
latest |
latest push to main |
main |
same as latest |
vX.Y.Z |
semver-tagged release |
sha-<7-char> |
specific commit |
Images are built for linux/amd64 and linux/arm64.
- Server — ASP.NET Core 10 Minimal API. Three endpoints under
/api/secretsplus two HTML shells (/and/s/{id}). - Storage — SQLite with
journal_mode=WALandsecure_delete=ON. ABackgroundServicepurges expired rows every five minutes. - Frontend — TypeScript compiled with esbuild, Tailwind CSS 3.4. No jQuery, no Bootstrap, no runtime framework.
- Hardening — strict CSP (
default-src 'none'), HSTS with one-yearmax-age,X-Frame-Options: DENY,Referrer-Policy: no-referrer,Cache-Control: no-store,Serverheader suppressed.
Issues and pull requests are welcome. Before submitting:
dotnet build --configuration Release
dotnet test --configuration ReleaseKeep commits focused and include a short why in the message.
MIT. See LICENSE.
Derived from Pinterest SnapPass (MIT) and the original .NET port by generateui/Snappass.NET (MIT).