Adds the foundations Prism's web client (and any future browser-based Shade app) needs: at-rest-encrypted IndexedDB storage that mirrors the SQLite backend byte-for-byte at the AAD/nonce level, browser-safe subpath imports so Vite/webpack/esbuild stop hitting bun:sqlite, and KeyManager support for argon2id and N-factor composite unlock. @shade/storage-encrypted - EncryptedIndexedDBStorage (subpath: /idb) — full StorageProvider using one object store per _enc table; reuses aeadSeal/aeadOpen + row-codec sealers so a row sealed under the SQLite or Postgres backend decrypts under IDB given the same KeyManager. bumpPeerIdentityVersion is atomic under one IDB transaction. - KeyManager argon2id source — memory-hard KDF for low-entropy secrets (PINs). Backed by @noble/hashes/argon2 (already a transitive dep). DEFAULT_ARGON2ID exported (m=64 MiB, t=3, p=1). - KeyManager composite source — HKDF-combine N sub-sources into one master. Every source mandatory; order significant by design; composite-of-composite rejected; optional info string for app-level domain separation. - Subpath exports (/crypto, /sqlite, /postgres, /idb) plus a `browser` condition on the default import that resolves to a barrel excluding the Bun- and Postgres-specific entries. Browser bundles no longer pull bun:sqlite transitively. Tests - 73 tests in shade-storage-encrypted (was 31). New coverage: argon2id determinism + reject paths, composite same-factors → same master, wrong-PIN/passphrase/order-swap → different master, info domain separation, all 28 StorageProvider methods on EncryptedIndexedDBStorage, fingerprint-mismatch rejection, and cross-impl roundtrip with EncryptedSQLiteStorage proving the AAD/ nonce derivation is implementation-agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shade/server — Shade Prekey Server (standalone container)
A self-contained Docker image that provides the prekey server, OpenAPI contract, observer dashboard, and stale cleanup — everything a project needs to adopt Shade, with zero coupling to the consumer's stack.
Deploy in 2 minutes
docker run -d \
--name my-project-shade \
-v my-project-shade:/data \
-p 3900:3900 \
-e SHADE_OBSERVER_TOKEN=change-me-to-at-least-16-chars \
gt.zyon.no/stian/shade-prekey:latest
Done. Your prekey server is live:
http://localhost:3900/health— health checkhttp://localhost:3900/openapi.yaml— API contract for any languagehttp://localhost:3900/docs— interactive API reference (Redoc)http://localhost:3900/shade-observer/dashboard/— live debugger (token required)http://localhost:3900/v1/keys/*— prekey REST API
Your consumer projects (Nova, Orchestrator, Python apps, anything) then point at http://localhost:3900 as their prekeyServer URL.
One container per project
The recommended architecture is one Shade container per project:
nova-shade (Docker container, SQLite volume) ← Nova backend + Android app
orchestrator-shade (Docker container, SQLite volume) ← Orchestrator hub + workstations
future-project (Docker container, SQLite volume) ← Any future app
Each project owns its own container, its own volume, its own observer token. Zero cross-project coupling. If one project's Shade is down, the others keep running.
Keys vs. payloads — what this server is, and isn't
The prekey server is a public-key directory. It exists so a brand- new client can find the right Ed25519 + X25519 bundle to start an X3DH handshake with a peer it has never talked to. After that, the peers ratchet directly.
What lives on this server:
- Identity public keys
- Signed prekey + one-time prekey bundles
- Activity timestamps (used by stale cleanup)
- Operator metadata:
/health,/metrics,/openapi.yaml,/shade-observer/*
What never lives on this server:
- Message plaintext. Ratchet envelopes flow peer-to-peer.
- Transfer chunks.
@shade/transferPOSTs ciphertext directly to the receiver's/v1/transfer/:streamId/chunkroute — not here. - Identity private keys or session state. Both are device- local.
- Resume secrets for in-flight transfers. Encrypted under a device-key derived from the identity signing key, never uploaded.
This is the bright line that lets you deploy one shared prekey
container per project even when consumer apps don't trust each other:
the worst a compromised prekey server can do is hand out a fake
bundle (MITM at first contact). Out-of-band fingerprint comparison
detects this — see THREAT-MODEL.md § 2 and the getIdentityFingerprint()
API.
For deployment-time gates (TLS, backup, observer-token rotation, log
level, secret rotation) see
docs/PRODUCTION-CHECKLIST.md.
For the wire contract — including the peer-served
/v1/transfer/* and ShadeTransferAuthenticator security scheme —
see openapi.yaml.
Environment variables
| Var | Default | Description |
|---|---|---|
PORT |
3900 |
HTTP port |
SHADE_PREKEY_DB_PATH |
/data/shade-prekeys.db |
SQLite file path |
SHADE_PREKEY_PG_URL |
unset | Postgres connection string. If set, overrides SQLite. |
SHADE_OBSERVER_TOKEN |
unset | Bearer token for the dashboard. Min 16 chars. Unset = observer disabled. |
SHADE_STALE_DAYS |
30 |
Purge identities with no activity in N days |
SHADE_CLEANUP_INTERVAL_HOURS |
24 |
How often the cleanup task runs |
SHADE_LOG_LEVEL |
info |
debug / info / warn / error |
Persistence
The /data volume holds the SQLite database. Back it up by copying the .db file (use SQLite's online backup API or just stop the container briefly).
To switch to Postgres, set SHADE_PREKEY_PG_URL=postgres://user:pass@host/db. Tables will be created automatically with the shade_server_* prefix.
Stale cleanup
Identities that have no activity (no bundle fetches, no replenishments, no registration updates) for more than SHADE_STALE_DAYS days are automatically purged. This keeps the database bounded even if users never unregister cleanly.
Using from your project
Any language can speak to a Shade container — it's just HTTP. See openapi.yaml for the full contract.
TypeScript / Bun:
import { createShade } from '@shade/sdk';
const shade = await createShade({ prekeyServer: 'http://my-project-shade:3900' });
Python / Go / Rust: generate a client from the OpenAPI spec with openapi-generator, or implement the wire protocol directly (8 endpoints, Ed25519 signatures documented in the spec).
Android: use the shade-android Kotlin module. Same wire protocol, verified by cross-platform test vectors.
Building locally
bun run build:docker # build shade-prekey:dev
bun run build:docker -- --tag v1.0.0 # custom tag
GITEA_TOKEN=... bun run publish:docker # build + push to registry
CI publishing
Tag a release and CI publishes automatically:
git tag v1.0.0
git push --tags
.gitea/workflows/docker.yml runs tests, builds the image, and pushes both v1.0.0 and latest tags to gt.zyon.no/stian/shade-prekey.