A secure, anonymous secret-sharing application. Secrets are encrypted entirely in the browser (or CLI) before reaching the server — the server stores only ciphertext and never sees a plaintext secret or passphrase.
- Preact SPA — dark-themed single-page application served by the PHP server; no separate static host required
- JSON API —
POST /api/createandPOST /api/retrievewith structured JSON request/response bodies - Configurable TTL — secrets expire after 1 hour, 6 hours, 24 hours, 3 days, or 7 days
- View limits — optionally restrict a secret to 1, 3, 5, or 10 retrievals; enforced atomically via a Redis Lua script
- Health endpoint —
GET /healthchecks Redis connectivity; used by Kubernetes liveness and readiness probes - Rate limiting —
/api/retrieveis limited to 10 requests per minute per IP; responses includeX-RateLimit-*headers - Metrics — creation and retrieval counts and byte totals are recorded per hour in Redis (90-day retention)
- Legacy redirects —
POST /createandPOST /retrieveissue308 Permanent Redirectto the/api/*equivalents
All cryptography runs client-side using the Web Crypto API (browser) or libsodium (CLI):
| Primitive | Usage |
|---|---|
| AES-GCM-256 | Secret encryption/decryption |
| PBKDF2-SHA-256 (10 000 iterations) | Key derivation from passphrase + random salt |
| PBKDF2-SHA-256 (10 000 iterations, fixed pepper) | Verifier derivation for server-side authentication |
| bcrypt | Server-side hashing of the verifier before storage |
The server stores only the encrypted payload and a bcrypt-hashed verifier. The plaintext secret and the passphrase never leave the client.
| Tool | Purpose |
|---|---|
| Docker + Docker Compose | Running the server and Redis locally |
| Make | Convenience targets |
| PHP 8.4+ | Running the server or CLI outside Docker |
| Composer | Installing PHP dependencies |
| Node.js 20+ | Building the frontend SPA |
| Kubernetes 1.19+ | Production deployment |
| Helm 3.0+ | Kubernetes chart management |
# Build the server image and start the server + Redis
make server-up
# Stop all containers
make server-downThe server listens on http://localhost:8080 by default.
# Install Composer dependencies locally (via Docker, no local PHP required)
make server-install
# Build the server container image only
make server-buildThe Preact SPA lives in frontend/ and is built with Vite. Build output lands in server/static/dist/ and is committed to the repository.
cd frontend
# Install dependencies
npm install
# Start the Vite dev server (proxies API calls to the PHP server)
npm run dev
# Build for production (output → server/static/dist/)
npm run build
# Run ESLint
npm run lint
# Run Vitest unit tests
npm testNote: Always run
npm run buildafter frontend changes and commit the updatedserver/static/dist/directory.
# Install Composer dependencies locally (via Docker, no local PHP required)
make cli-installPHP (server):
cd server
composer testJavaScript (frontend):
cd frontend
npm testThe full OpenAPI 2.0 specification is in swagger.yml.
| Method | Path | Description |
|---|---|---|
GET |
/ |
Secret creation page (SPA) |
GET |
/secret |
Secret retrieval page (SPA) |
GET |
/secret/{secretId} |
Pre-populated retrieval page (SPA) |
Returns Redis connectivity status. Used by Kubernetes probes.
200 OK — Redis reachable:
{ "status": "ok" }503 Service Unavailable — Redis unreachable:
{ "status": "error", "message": "Connection refused [tcp://redis:6379]" }Create a new secret. The request body must be JSON.
Request:
{
"encrypted_secret": "<hex(salt)>$<hex(verifier)>$<hex(nonce+ciphertext)>",
"ttl": 86400,
"max_views": 1
}| Field | Type | Required | Description |
|---|---|---|---|
encrypted_secret |
string | ✓ | Client-side encrypted payload in hex(salt)$hex(verifier)$hex(nonce+ciphertext) format |
ttl |
integer | Lifetime in seconds. Allowed: 3600, 21600, 86400, 259200, 604800. Default: 86400 |
|
max_views |
integer | Maximum retrieval count. Allowed: 0 (unlimited), 1, 3, 5, 10. Default: 0 |
201 Created:
{
"id": "a1b2c3d4e5f6",
"expires_at": 1735689600,
"max_views": 1
}400 Bad Request — malformed JSON
413 Payload Too Large — body exceeds 100 KB
422 Unprocessable Entity — disallowed ttl or max_views value
Retrieve a secret. Rate-limited to 10 requests per minute per IP. All responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
Request:
{
"id": "a1b2c3d4e5f6",
"verifier": "<hex-encoded PBKDF2 verifier>"
}200 OK:
{
"encrypted_secret": "<hex(salt+nonce+ciphertext)>",
"views_remaining": 0,
"expires_at": 1735689600
}views_remaining is null for unlimited secrets. When it reaches 0, all keys for the secret are deleted atomically.
400 Bad Request — malformed JSON
401 Unauthorized — verifier does not match
404 Not Found — secret not found or expired
429 Too Many Requests — rate limit exceeded
POST /create and POST /retrieve both respond with 308 Permanent Redirect to /api/create and /api/retrieve respectively. Update any integrations to use the /api/* paths directly.
The CLI tool is a Symfony Console application located in cli/. It requires PHP 8.4+ with the sodium and curl extensions.
Encrypt a secret locally and store it on the server.
php cli/cli.php secret:create <secret> <password>Arguments:
| Argument | Description |
|---|---|
secret |
Plaintext secret to protect |
password |
Passphrase used to encrypt the secret |
Example:
php cli/cli.php secret:create "my API key: sk-abc123" "correct horse battery staple"Secret Created
==============
Secret ID: a1b2c3d4e5f6
Password: correct horse battery staple
URL: https://swordfish.displace.tech/secret/a1b2c3d4e5f6
Authenticate with the server, retrieve the ciphertext, and decrypt it locally.
php cli/cli.php secret:retrieve <secret-id> <password>Arguments:
| Argument | Description |
|---|---|
secret-id |
ID of the secret to retrieve |
password |
Passphrase used when the secret was created |
Example:
php cli/cli.php secret:retrieve a1b2c3d4e5f6 "correct horse battery staple"Secret Decrypted!
==============
my API key: sk-abc123
| Variable | Default | Description |
|---|---|---|
SWORDFISH_URL |
https://swordfish.displace.tech |
Base URL of the Swordfish server |
| Variable | Default | Description |
|---|---|---|
SERVER_PORT |
8080 |
HTTP listen port |
REDIS_HOST |
redis |
Redis hostname |
REDIS_PORT |
6379 |
Redis port |
The server image is a multi-stage build: Composer installs dependencies in a composer:latest builder stage, then the runtime stage uses php:8.4-cli with the redis PECL extension and pcntl.
# Build the image locally
make server-build
# Start server + Redis with dev volume mount (hot-reload)
make server-up
# Stop containers
make server-downThe docker-compose.dev.yml overlay mounts the local server/ directory into the container so PHP file changes take effect without rebuilding the image.
The application ships with a Helm chart at helm/swordfish/. It deploys the PHP server and a Redis subchart, with optional ingress and Keel-based auto-update support.
kubectl create namespace swordfish
helm install swordfish ./helm/swordfish -n swordfish \
--set server.imagePullSecrets.create=true \
--set server.imagePullSecrets.github.username=YOUR_GITHUB_USERNAME \
--set server.imagePullSecrets.github.token=YOUR_GITHUB_PATAlternatively, create the pull secret manually and reference it:
kubectl create secret docker-registry ghcr-auth \
--docker-server=ghcr.io \
--docker-username=YOUR_GITHUB_USERNAME \
--docker-password=YOUR_GITHUB_PAT \
-n swordfish
helm install swordfish ./helm/swordfish -n swordfish \
--set server.imagePullSecrets.name=ghcr-auth| Parameter | Description | Default |
|---|---|---|
server.replicaCount |
Number of server replicas | 1 |
server.image.repository |
Server image repository | ghcr.io/displacetech/swordfish/server |
server.image.tag |
Server image tag | latest |
server.image.sha |
Optional SHA override for the tag | "" |
server.imagePullSecrets.create |
Create a pull secret from provided credentials | false |
server.imagePullSecrets.name |
Name of an existing pull secret to use | "" |
server.imagePullSecrets.github.username |
GitHub username for the pull secret | "" |
server.imagePullSecrets.github.token |
GitHub PAT for the pull secret | "" |
server.service.type |
Kubernetes service type | ClusterIP |
server.service.port |
Service port | 8080 |
keel.policy |
Keel update policy (force, semver, glob, none) |
force |
keel.trigger |
Keel trigger type (poll, pubsub) |
poll |
keel.pollSchedule |
Poll schedule (cron or @every syntax) |
@every 5m |
keel.matchTag |
Only update when the new image tag matches the deployed tag | true |
redis.architecture |
Redis architecture | standalone |
redis.auth.enabled |
Enable Redis authentication | false |
ingress.enabled |
Enable ingress | false |
ingress.className |
Ingress class name | "" |
ingress.hosts |
Ingress hosts configuration | [{host: swordfish.local, paths: [{path: /, pathType: Prefix}]}] |
Example values.yaml with ingress and TLS:
server:
replicaCount: 2
image:
repository: ghcr.io/displacetech/swordfish/server
tag: "latest"
imagePullSecrets:
create: true
github:
username: YOUR_GITHUB_USERNAME
token: YOUR_GITHUB_PAT
ingress:
enabled: true
className: nginx
hosts:
- host: swordfish.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: swordfish-tls
hosts:
- swordfish.example.comhelm install swordfish ./helm/swordfish -n swordfish -f values.yamlhelm upgrade swordfish ./helm/swordfish -n swordfish
helm uninstall swordfish -n swordfishThe Helm chart annotates the Deployment for Keel, which polls GHCR and triggers a rolling restart when a new image digest is detected. The default force policy is suitable for latest-tagged images.
| Policy | When to use |
|---|---|
force |
latest tag — re-pulls whenever a new digest is pushed |
semver |
Semantically versioned tags (e.g., 1.2.3) |
glob |
Pattern-matched tags (e.g., sha-*) |
none |
Disable Keel automation entirely |
Override in values.yaml:
keel:
policy: "semver"
trigger: "poll"
pollSchedule: "@every 10m"
matchTag: "true"GitHub Actions (.github/workflows/build.yml) runs on every push or pull request touching server/**, frontend/**, or the workflow file itself.
| Job | Steps |
|---|---|
test |
Sets up PHP 8.4 + Redis, installs Composer deps, runs PHPUnit with coverage |
js-test |
Sets up Node.js 20, installs npm deps, runs Vitest with coverage |
build |
Builds the SPA (npm run build), then builds and pushes the Docker image to GHCR |
Images are tagged with the commit SHA on every push. The latest tag is also applied on pushes to main. Pull requests from forks build the image but do not push it.
- Fork the repository and create a feature branch.
- Make your changes. Run
npm run lintandnpm testinfrontend/, andcomposer testinserver/before committing. - If you change the frontend, rebuild with
npm run buildand commit the updatedserver/static/dist/. - Open a pull request against
main. Reference any related issue in the PR description.
Please read CODE_OF_CONDUCT.md before contributing.