A proof-of-concept API that stores and retrieves sensitive data with searchable encryption using CipherStash Protect and a Bunny Edge Database (LibSQL/SQLite). Designed to run as a container on Bunny's Magic Container Deployment.
All data is encrypted before it reaches the database. The encryption keys never leave the application layer, and the database only ever stores ciphertext. Despite full encryption in use, the data remains queryable through CipherStash's searchable encryption primitives.
┌──────────────────────────────────────────────┐
│ Bunny Magic Container (Edge) │
│ │
Client ──────────────►│ Elysia (HTTP) │
X-API-Key │ │ │
X-Key / JSON body │ ▼ │
│ CipherStash Protect │
│ • encrypt/decrypt values │
│ • generate HMAC search terms │
│ │ │
│ ▼ │
│ LibSQL Client ──────► Bunny Database │
│ (SQLite on edge) │
│ Stores only │
│ ciphertext + HMACs │
└──────────────────────────────────────────────┘
Runtime: Bun Web framework: Elysia Encryption: @cipherstash/protect v10 Database: Bunny Database via @libsql/client (LibSQL, SQLite-compatible)
CipherStash Protect provides application-level encryption with the ability to query encrypted data without decrypting it. This is achieved through two mechanisms:
The schema defines two column types on the sensitive_data table:
const sensitiveData = csTable('sensitive_data', {
key: csColumn('key').equality(), // encrypted + searchable via equality
value: csColumn('value'), // encrypted only, not searchable
}).equality()columns generate an HMAC-based search term alongside the ciphertext. The HMAC is a deterministic, one-way hash derived from the plaintext — it cannot be reversed to recover the original value, but the same input always produces the same HMAC. This allows exact-match lookups (WHERE key = ?) without exposing the plaintext to the database.- Standard columns are encrypted with no search index. They can only be read after decryption in the application layer.
- Plaintext
keyandvaluearrive in the request body. protectClient.encryptModel()encrypts both fields. For thekeyfield, it also generates an HMAC search term (.hm).- The HMAC is stored in the
keycolumn of the database. The encryptedvalueobject is JSON-serialized and stored in thevaluecolumn. - The database never sees plaintext.
- The plaintext key arrives in the
X-Keyheader. protectClient.createSearchTerms()generates the same HMAC for the key.- The HMAC is used in a
SELECT ... WHERE key = ?query. - The encrypted value returned from the database is parsed and passed to
protectClient.decrypt(). - The decrypted plaintext is returned to the client.
| Column | Contents |
|---|---|
key |
HMAC string (not reversible, deterministic) |
value |
JSON-encoded ciphertext blob |
An attacker with full database access sees only HMACs and ciphertext — no plaintext, no encryption keys.
All endpoints require authentication via the X-API-Key header.
Retrieve a decrypted value by its key.
Headers:
| Header | Required | Description |
|---|---|---|
X-API-Key |
Yes | API key for authentication |
X-Key |
Yes | Plaintext key to look up |
Response codes:
| Status | Meaning |
|---|---|
200 |
Decrypted value returned in response body |
400 |
Missing X-Key header |
401 |
Invalid or missing API key |
404 |
No entry found for the given key |
500 |
Encryption or database failure |
Example:
curl -H "X-API-Key: $API_KEY" \
-H "X-Key: my-secret-key" \
http://localhost:3000/sensitive-dataStore an encrypted key-value pair. If the key already exists, the value is updated.
Headers:
| Header | Required | Description |
|---|---|---|
X-API-Key |
Yes | API key for authentication |
Body (JSON):
{
"key": "my-secret-key",
"value": "the sensitive data to encrypt and store"
}Response codes:
| Status | Meaning |
|---|---|
200 |
Data created or updated |
400 |
Missing key or value in body |
401 |
Invalid or missing API key |
500 |
Encryption or database failure |
Example:
curl -X POST \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "db-password", "value": "hunter2"}' \
http://localhost:3000/sensitive-dataCreate a .env.local file in the project root:
| Variable | Description |
|---|---|
API_KEY |
Shared secret for authenticating API requests |
BUNNY_DATABASE_URL |
LibSQL connection URL for Bunny Database |
BUNNY_DATABASE_AUTH_TOKEN |
JWT auth token for Bunny Database (read-write) |
CS_WORKSPACE_CRN |
CipherStash workspace CRN |
CS_CLIENT_ID |
CipherStash client UUID |
CS_CLIENT_KEY |
CipherStash client key (hex-encoded) |
CS_CLIENT_ACCESS_KEY |
CipherStash API access key |
You will need:
- A CipherStash account and workspace with a configured dataset/keyset.
- A Bunny Database instance. Create a
sensitive_datatable withkey(TEXT) andvalue(TEXT) columns.
- Bun v1.x
- A configured
.env.localfile
bun install
bun --watch index.tsThe server starts on http://localhost:3000.
./run.shOr manually:
docker build -t cipherstash-bunny-vault:local .
docker run --env-file .env.local -p 3000:3000 cipherstash-bunny-vault:localThe Dockerfile uses a multi-stage build on the official oven/bun:1 image. It installs production dependencies in an isolated stage and copies only the necessary files (index.ts, tsconfig.json, package.json) into the final image.
Push the container image to a registry accessible by Bunny, then deploy it via Bunny's Magic Container platform. The container listens on port 3000 and requires all environment variables listed above to be set in the container configuration.
Because the application connects to Bunny Database over HTTPS (LibSQL over HTTP), there is no requirement for the container to be co-located with the database — it works from any Bunny edge location.
├── index.ts # Entire application: encryption setup, DB client, API routes
├── package.json # Dependencies and scripts
├── tsconfig.json # TypeScript configuration
├── Dockerfile # Multi-stage container build
├── run.sh # Local Docker build + run script
├── .env.local # Environment variables (not committed)
└── .dockerignore # Excludes .env and node_modules from build context
This is a proof-of-concept. The following should be addressed before production use:
- Authentication: The current API key check is a simple string comparison via header. Replace with a proper auth mechanism (JWT, mTLS, OAuth2, etc.).
- Rate limiting: No rate limiting is implemented. Add rate limiting to prevent brute-force attacks on the API key or key enumeration.
- Input validation: Beyond basic presence checks, there is no input sanitization or length limiting.
- Transport security: Ensure TLS termination is handled (Bunny's edge handles this for Magic Containers).
- Key management: CipherStash credentials and the API key are passed as environment variables. Use a secrets manager in production, like Stash.
| Layer | Technology |
|---|---|
| Runtime | Bun |
| Web Framework | Elysia |
| Database | Bunny Database (LibSQL/SQLite) |
| Encryption | CipherStash Protect |
| Container | Docker (oven/bun:1) |
| Language | TypeScript |