- Bump version 0.0.38 → 0.0.39 - AppSwitch: scaled-down (0.75x) Material3 Switch with full touch target, replaces default Switch in KeyboardSettingsDialog for consistent narrow style - Cloud backend spec: FUTURE.md summary + FUTURE_BACKEND.md full architecture (zero-knowledge sync, packs, team sharing, web dashboard, swb CLI) + FUTURE_BACKEND_TECH.md implementation details - Move Audit.md and SecurityAudit.md into docs/ folder - Add scripts/ to .gitignore (test results, deploy scripts — local only) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
30 KiB
SSH Workbench — Cloud Backend Specification
Status: Planning / Vision document. Nothing here is implemented. Audience: backend engineers picking up the project. Self-contained — does not assume familiarity with the mobile codebase. Last updated: 2026-04-11
1. What we are building
SSH Workbench is currently a local-first Android SSH client. Everything lives on the device: connections, SSH keys, keyboard layouts, QuickBar customizations, snippets. The local .swb vault export is the only way to move data between devices.
This document describes the future cloud backend that will sit alongside the local app — never replacing it — to add four capabilities:
- Cross-device sync of curated subsets of the user's vault, called packs.
- Team sharing, where an org admin composes packs and assigns them to members.
- A web dashboard with a visual keyboard editor, pack composer, billing, and an in-browser xterm.js terminal.
- A
swbCLI for Linux/macOS/Windows that talks to the same backend.
The mobile app remains the core product. The backend is an optional, additive layer. A user who never logs in keeps every feature they have today.
2. Non-negotiables
These are not requirements that can be relaxed under schedule pressure. They are the product.
2.1 Zero-knowledge encryption
- The server never sees plaintext private keys, passwords, or any other secret credential material.
- The client derives a master key from the user password using Argon2id with the same parameters as
lib-vault-cryptotoday (memory=64 MiB, iterations=3, parallelism=1, output=32 bytes). The user password is never transmitted. - Vault entries are encrypted with AES-256-GCM, the algorithm already implemented and audited in
lib-vault-crypto. We do not introduce a second crypto stack. - A full database dump must be useless to an attacker. The acceptance test for this design is: if our database is leaked tomorrow, can the attacker decrypt anything? The answer must be no.
- We — the developers, the operators, the company — must not be able to read customer credentials. Not by accident, not on purpose, not under subpoena. This is a marketing claim and a technical claim simultaneously, and the latter has to back up the former.
2.2 Local-first
- Every existing feature continues to work without an account. The user can install the app, never sign in, and have a fully functional SSH client.
- The local
.swbexport/import path stays. It is the user's escape hatch and their offline backup. - The backend is offline-tolerant: the client caches the synced vault locally, and read operations work without network. Writes queue and replay on reconnection.
2.3 Open standards, no lock-in
- The vault format is documented. A user can extract their data without our help if we go out of business.
- The CLI is open source under a permissive license (Apache 2.0 or MIT).
- The wire protocol is documented. Third-party reimplementations are allowed.
3. Concept model
3.1 Vault
A user's vault is the union of:
- Hosts — saved SSH/Telnet/Local connection profiles (
SavedConnectionin the mobile app's Room DB). - Keys — SSH keypairs. Public side is metadata; private side is the secret.
- Snippets — terminal command shortcuts.
- Keyboard layouts — JSON layouts produced by the visual KB editor.
- QuickBar configs — the user's customized QB key list and app shortcuts.
- Settings — non-secret user preferences worth syncing (theme, font, scrollback size).
Today, all of this lives in Room + EncryptedSharedPreferences on the device. In the cloud model, the vault becomes the single source of truth, and devices hold cached projections.
3.2 Pack
A pack is a named, curated subset of the vault that gets synced as a unit:
Pack "Work servers"
├── Hosts: prod-web-01, prod-db-01, staging-jump
├── Keys: work-ed25519
├── Layout: us-qwerty-compact
├── QB config: dev-shortcuts
└── Settings (optional override)
A pack is the atomic unit of sharing and subscription:
- A solo user creates packs for their own organizational convenience ("Home lab", "Clients/AcmeCorp", "Personal VPS").
- A team admin creates packs per role ("On-call SREs", "Frontend devs", "Read-only auditors") and assigns them to members.
A device subscribes to one or more packs. Subscribing pulls everything in those packs into the local cache. Unsubscribing removes them. Removing a host from a pack on the dashboard syncs the removal to every device that has that pack — instantly, on next sync.
A host can live in multiple packs. The vault deduplicates; the pack memberships are independent.
3.3 Org / Team
- An org is created by a user. The creator becomes the org admin.
- The admin invites members by email. Members get their own login.
- Members of the same org share a vault namespace, but per-pack ACLs control what each member actually sees.
- Removing a member revokes their access; their devices remove the affected packs on next sync.
- Solo users without an org are functionally an "org of one" — same data model, no member management UI.
4. Threat model
4.1 In scope
| Threat | Mitigation |
|---|---|
| Database leak / dump | Zero-knowledge: ciphertext only, master key never on server |
| Compromised server / rogue admin | Same — the server cannot decrypt vault content |
| MITM on sync traffic | TLS 1.3, certificate pinning in mobile + CLI clients |
| Stolen device | Local vault is encrypted with the master key; biometric + Argon2id KDF on every cold start |
| Phishing for the user password | High-entropy password requirement; future: WebAuthn second factor for dashboard login |
| Account takeover via email | Email is not a password reset path for the master key — there is no password reset, only recovery via the user's local vault export. See §6.4. |
| Malicious team member | Per-pack ACLs; admin audit log; ability to revoke + rotate |
| Subpoena / legal compulsion | We cannot decrypt what we do not have keys for. We can be compelled to hand over ciphertext, which is useless. |
4.2 Out of scope (explicitly)
- A user who forgets their master password. There is no recovery. We say this loudly. Their local vault export is their only backup. This is a feature, not a bug — the moment we have a recovery path, we have a backdoor.
- Endpoint compromise. If the user's device is rooted and a keylogger captures the master password, no backend design can save them. We assume the endpoint is honest.
- Side-channel attacks against Argon2id. We trust the reference implementation already shipping in
lib-vault-crypto. - Traffic analysis revealing pack subscriptions. A network observer might learn that a user has 3 packs of certain ciphertext sizes. We accept this leak.
5. Cryptography
5.1 Master key derivation
Same parameters as the existing VaultCrypto.deriveKey:
master_key = Argon2id(
password = user_password (CharArray),
salt = per-user random 16 bytes (stored on server, sent on login),
memory = 65536 KiB,
iterations = 3,
parallelism= 1,
output = 32 bytes
)
The salt is stored server-side and returned during the login handshake. It is per-user, generated at signup, and never changes for the life of the account. Rotating the salt would invalidate every existing ciphertext.
The master key never leaves the client. The server never sees it. Login authentication is a separate mechanism (see §6.2).
5.2 Vault entry encryption
Each vault entry (host, key, snippet, layout, QB config) is serialized to JSON, then encrypted as a single AES-256-GCM blob:
ciphertext = nonce(12) || AES-256-GCM(master_key, plaintext_json) || tag(16)
This is byte-for-byte the same format lib-vault-crypto produces today. No new code path.
5.3 Team key sharing (X25519 wrapping)
For team scenarios, multiple members need to decrypt the same vault entries. We do not share the master key directly. Instead, we use per-pack data keys wrapped to each member's X25519 public key — extending the design already sketched in FUTURE.md under "Vault Export — Option C".
For each pack:
pack_data_key = random 32 bytes (used for AES-256-GCM of all entries in the pack)
For each member with access:
ephemeral_priv, ephemeral_pub = X25519 keypair (one per wrap)
shared = X25519(ephemeral_priv, member_long_term_pub)
wrap_key = HKDF-SHA256(shared, salt=pack_id, info="swb-pack-wrap")
wrapped_data_key = AES-256-GCM(wrap_key, pack_data_key)
store: { member_id, pack_id, ephemeral_pub, wrapped_data_key }
Each member has a long-term X25519 keypair generated client-side at signup. The public key is uploaded to the server. The private key is encrypted with the member's master key and uploaded as a single blob — the same way the rest of the vault is encrypted. On a new device, after login, the client downloads the encrypted X25519 private key and decrypts it locally with the master key derived from the user password.
When the admin grants pack access to a new member, the admin's client:
- Decrypts
pack_data_keyfor itself using its own wrapped copy. - Fetches the new member's X25519 public key from the server.
- Wraps
pack_data_keyto the new member. - Uploads the new wrap.
When access is revoked, the wrap is deleted server-side and the pack_data_key is rotated. Re-wraps go out to all remaining members. Already-decrypted-and-cached content on the revoked member's device is out of our control — they had it, we cannot un-have it. New entries added to the pack after revocation are unreachable to them.
5.4 Why not just one key per user?
Per-pack data keys make rotation cheap (rotate one pack, not the whole vault) and make team membership auditable (each member's wraps are listed per pack). Solo users still get the same model — they are simply the only member of their packs.
5.5 Algorithm choices, locked
| Use | Algorithm | Rationale |
|---|---|---|
| Password → master key | Argon2id (m=64MiB, t=3, p=1) | Already shipping in lib-vault-crypto |
| Symmetric encryption | AES-256-GCM | Already shipping; AEAD |
| Asymmetric wrapping | X25519 + HKDF-SHA256 + AES-256-GCM | Modern, fast, standard |
| Transport | TLS 1.3 with cert pinning | Standard |
| Login auth | SRP-6a or OPAQUE (TBD) | See §6.2 |
No new algorithms are introduced. No custom crypto. Everything has a reference implementation we can audit.
6. Authentication
6.1 Account creation
- User picks an email and a master password.
- Client generates a random 16-byte password salt.
- Client derives
master_key = Argon2id(password, salt, …). - Client generates an X25519 keypair. The private half is encrypted with
master_keyand packaged as a vault blob. - Client uploads to server:
{ email, password_salt, x25519_public_key, encrypted_x25519_private }plus an authentication verifier (see 6.2). - The plaintext password is never transmitted.
6.2 Login
We need to authenticate the user without sending the password. Two viable options:
- SRP-6a — well understood, mobile-friendly, available in many languages.
- OPAQUE — newer (RFC 9807), arguably simpler to reason about, fewer mature libraries.
Pick one and stick with it. The decision can wait until implementation time. Either gives us the property: server learns nothing it could use to log in as the user.
After authentication succeeds:
- Server returns the user's
password_salt. - Client re-derives
master_keylocally. - Server issues a short-lived session JWT (15 min) and a long-lived refresh token (rotating, stored in EncryptedSharedPreferences on mobile).
- Client downloads the encrypted X25519 private key and decrypts it locally.
6.3 Multi-device login
Same flow on each device. The master key is derived from the password the same way each time. The X25519 private key is downloaded once and cached locally (encrypted at rest with the master key).
6.4 Recovery
There is no password reset. We say this in big letters at signup, behind a checkbox the user must tick.
The only recovery paths are:
- Local
.swbvault export. The user is encouraged to make one immediately after signup and store it offline. If they lose their password, they can import the local export into a fresh account. - Future: recovery codes. At signup we may generate 12 random words encoded as a recovery key that can decrypt the vault independently of the password. Stored encrypted under both the master key and a one-time-use AES key derived from the recovery code. User prints / writes down the words. Never stored server-side in a usable form.
We deliberately do not offer email-based reset. Email reset would require server-side ability to re-encrypt the vault, which would mean server-side access to the master key, which would defeat the entire premise.
6.5 Two-factor
- TOTP (RFC 6238) for login on the dashboard and CLI.
- WebAuthn (FIDO2) on the dashboard once we are confident in the library story.
- Mobile uses the device's biometric prompt as a local lock; that is independent of the server-side 2FA.
7. Data model (server)
Tables, columns abbreviated. Foreign keys implied.
users
| col | type | notes |
|---|---|---|
| id | uuid | PK |
| text | unique | |
| password_salt | bytea(16) | for client-side Argon2id |
| auth_verifier | bytea | SRP/OPAQUE verifier |
| x25519_public | bytea(32) | for team wrapping |
| encrypted_x25519_private | bytea | wrapped under master_key |
| created_at | timestamptz | |
| org_id | uuid? | nullable for solo users |
orgs
| col | type | notes |
|---|---|---|
| id | uuid | PK |
| name | text | display only |
| owner_id | uuid → users.id | initial admin |
| billing_subscription_id | text | Stripe etc. |
| created_at | timestamptz |
org_members
| col | type | notes |
|---|---|---|
| org_id | uuid | |
| user_id | uuid | |
| role | text | "admin" / "member" |
| invited_at | timestamptz | |
| accepted_at | timestamptz? | null until accepted |
vault_entries
The single ciphertext table. One row per host / key / snippet / layout / QB config.
| col | type | notes |
|---|---|---|
| id | uuid | PK |
| owner_id | uuid → users.id | for solo content |
| org_id | uuid? | for org-shared content |
| kind | text | "host" / "key" / "snippet" / "layout" / "qb_config" |
| ciphertext | bytea | nonce ‖ ct ‖ tag (AES-256-GCM under master_key OR pack_data_key) |
| version | int | optimistic concurrency / sync |
| updated_at | timestamptz | |
| created_at | timestamptz |
The server cannot tell what kind of host this is, where it connects, or who its key is — it only sees kind and a ciphertext blob. kind is unencrypted because the server needs to enumerate by type when serving sync responses.
packs
| col | type | notes |
|---|---|---|
| id | uuid | PK |
| owner_id | uuid → users.id | creator |
| org_id | uuid? | for org-owned packs |
| name_ciphertext | bytea | pack name encrypted under pack_data_key (or master_key for solo) |
| created_at | timestamptz |
pack_entries
| col | type | notes |
|---|---|---|
| pack_id | uuid | |
| entry_id | uuid → vault_entries.id | |
| added_at | timestamptz |
A junction table. Removing a row removes the entry from the pack on next sync without deleting the underlying entry.
pack_keys
The wrapping table.
| col | type | notes |
|---|---|---|
| pack_id | uuid | |
| user_id | uuid | recipient |
| ephemeral_pub | bytea(32) | per-wrap ephemeral X25519 |
| wrapped_data_key | bytea | AES-256-GCM(HKDF(X25519(eph, recipient_pub)), pack_data_key) |
| created_at | timestamptz |
One row per (pack, member). To add a member to a pack: insert a row. To remove: delete the row (and rotate the pack data key).
pack_subscriptions
| col | type | notes |
|---|---|---|
| user_id | uuid | |
| pack_id | uuid | |
| device_id | uuid | |
| last_synced_version | int | for incremental sync |
Per-device per-pack sync cursor.
audit_log
Append-only. Visible to admins of the org. Records: pack created/deleted, member invited/removed, pack assigned/revoked, login from new device, password changed. Logs metadata only — no ciphertext content.
8. Sync protocol
8.1 Goals
- Incremental: client sends "I am at version N for pack P", server returns the delta.
- Conflict-tolerant: last-write-wins on individual entries by
(version, updated_at). Conflicts are rare in practice — most edits happen on one device at a time. We do not need OT or CRDTs. - Offline-tolerant: client batches local edits and replays them on reconnect. Server rejects stale writes (
if-matchsemantics onversion). - End-to-end: server only ever sees ciphertext.
8.2 Endpoints (REST + JSON, TLS 1.3, cert-pinned)
POST /v1/auth/signup { email, salt, verifier, x25519_pub, enc_x25519_priv }
POST /v1/auth/login SRP/OPAQUE handshake
POST /v1/auth/refresh refresh token rotation
POST /v1/auth/logout
GET /v1/me current user, salt, x25519 keys
PATCH /v1/me update encrypted profile
GET /v1/packs list packs visible to me
POST /v1/packs create
DELETE /v1/packs/:id
GET /v1/packs/:id metadata + member list (for admins)
GET /v1/packs/:id/sync?since=<version> delta of entries; returns ciphertext blobs
POST /v1/packs/:id/entries add an entry to a pack
DELETE /v1/packs/:id/entries/:eid remove an entry from a pack
POST /v1/packs/:id/members grant access (admin only) — body includes wrapped_data_key
DELETE /v1/packs/:id/members/:uid revoke access (admin only)
POST /v1/entries create a new vault entry (ciphertext blob)
PATCH /v1/entries/:id update (with version check)
DELETE /v1/entries/:id
GET /v1/orgs/:id org metadata
POST /v1/orgs/:id/invites invite by email
DELETE /v1/orgs/:id/members/:uid remove member
GET /v1/audit?since=<ts> paginated audit log (admin only)
gRPC is a fine alternative if we want streaming sync; the data shapes are the same. Start with REST for simplicity.
8.3 Sync flow on the device
1. On app start, if logged in:
a. Refresh access token if expired.
b. For each subscribed pack:
GET /v1/packs/<id>/sync?since=<local_last_version>
Apply: insert new entries, update changed, delete removed, into local cache.
c. Replay any queued local writes.
2. On user edit:
a. Encrypt locally with master_key (solo) or pack_data_key (team).
b. Optimistically apply to local cache.
c. POST/PATCH to server.
d. On 409 conflict: pull, merge, retry.
3. On network loss:
a. Continue working from local cache.
b. Queue writes in a local outbox.
4. On reconnect:
a. Drain the outbox.
The mobile app's existing Room DB becomes the local cache. The DB schema mostly stays the same; we add a remote_id, version, and dirty column to syncable entities. A background worker (WorkManager) handles the sync loop.
9. Mobile app changes
In broad strokes:
- A new Account screen: sign up, log in, log out, manage subscription.
- A new Packs screen showing subscribed packs and the entries within. Think of it as a parallel root to the existing connection list, with a clear visual marker for "synced" content.
- The existing connection list shows the union of local + synced hosts, with a small badge on synced rows.
- Offline mode is the default. Sync runs in the background. The user never has to wait on the network for a screen to load.
- The
lib-vault-cryptomodule is reused as-is for AES-GCM operations. We add an X25519 helper, possibly via libsodium-jni or a small Kotlin wrapper around BouncyCastle. - Room schema gets
remote_id: TEXT?,version: INT DEFAULT 0,dirty: INT DEFAULT 0columns onSavedConnection,SshKey,Snippet, plus new tables for packs and pack memberships. - The existing local-only flow keeps working unchanged. A user without an account sees the app exactly as it is today.
10. Web dashboard
10.1 Stack
- Frontend: SvelteKit or Next.js — both fine, pick whichever the team prefers.
- Crypto in the browser: WebCrypto for AES-256-GCM and HKDF; a vetted Argon2id WASM build (e.g.
argon2-browser). - Terminal:
xterm.jsfor the in-browser SSH session. - WebSocket transport for the terminal session. The SSH transport itself runs on a backend "terminal gateway" service that opens the actual SSH connection on behalf of the browser (see §10.3).
10.2 Pages
- Login / Signup (with the no-recovery warning loud and clear)
- Dashboard — pack overview, recent activity, billing status
- Vault — list of all hosts + keys + snippets, search, edit
- Packs — pack composer; drag entries between "all vault items" and "pack contents"; per-pack member list (team only)
- Keyboard editor — visual layout designer with key drag-and-drop, live preview, JSON export. This is the headline pro feature. The current in-app QB Customizer covers basic needs; the web editor is what justifies the subscription for power users.
- Web terminal — connect-from-anywhere xterm.js session
- Team — member list, invite, remove, role assignment
- Billing — subscription status, plan switch, invoices
- Audit log — admin-only
10.3 Web terminal architecture
The browser cannot open raw TCP sockets. We need a backend gateway:
Browser (xterm.js) ⇄ WebSocket ⇄ Terminal Gateway ⇄ SSH ⇄ Target host
The gateway is a stateless service that:
- Accepts a WebSocket from the authenticated browser.
- Receives
{ host_id }from the client. - Pulls the encrypted host blob and the encrypted private key for the user from the server (still ciphertext).
- Forwards them to the browser, which decrypts them client-side and sends the SSH protocol bytes back over the WebSocket.
Wait — that puts SSH parsing in the browser. There are two reasonable models:
- Model A — browser does SSH. We bundle a WASM SSH client (or use
wsshstyle). The gateway is dumb — just a WebSocket-to-TCP relay. Zero-knowledge preserved; the gateway never sees plaintext credentials. Heavier browser. Preferred. - Model B — gateway does SSH on behalf of the user. Lighter browser, but the user must hand the gateway their decrypted credentials for the duration of the session. Breaks zero-knowledge. Rejected unless we accept the compromise and disclose it loudly.
Go with Model A. The CPU and bandwidth cost in the browser is fine for interactive sessions. The audit story is much cleaner.
11. swb CLI
11.1 Goals
- One static binary per platform (Linux, macOS, Windows). No installer.
- Open source under Apache 2.0.
- Same wire protocol as the mobile app and web dashboard.
- Strong differentiator vs Termius CLI, which is closed and limited.
11.2 Language
Go or Rust. Both produce static binaries cleanly. Rust gives us shared crypto code with future native modules; Go gives us faster development and easier contributors. Default to Go unless the team has a strong Rust preference.
11.3 Commands
swb login interactive login, stores refresh token in OS keychain
swb logout
swb sync pull all subscribed packs into ~/.swb/cache
swb list show all hosts in the local cache
swb list --pack work
swb connect <host-name> open SSH connection (uses local ssh, swb handles credentials)
swb connect work-prod-01
swb export <file.swb> export local vault as .swb file (compatible with mobile)
swb import <file.swb>
swb pack create <name>
swb pack add <pack> <host>
swb pack rm <pack> <host>
swb keys list
swb keys generate ed25519 --name mykey
swb terminal <host> open xterm in current TTY (alternative to swb connect)
11.4 Credential handover to local ssh
For swb connect, the CLI:
- Decrypts the host entry and key.
- Writes the private key to a temporary file in
$XDG_RUNTIME_DIRwith mode 0600. - Execs
ssh -i <tmpkey> user@host. - Removes the temp file when
sshexits.
This is the same trade-off gh auth git-credential makes. The temp file lives only for the duration of the session and is in tmpfs.
A more sophisticated mode: the CLI implements an SSH agent (SSH_AUTH_SOCK) and never writes the key to disk at all. Add this in v2.
12. Billing and subscriptions
12.1 Tiers
| Tier | Target price | What it gives |
|---|---|---|
| Free | €0 | The app as it exists today. No backend access. |
| Pro | ~€2.99/month | Web dashboard, visual KB editor, cloud sync, solo packs, web terminal, swb CLI |
| Teams | per-seat (€8/seat/month?) | Pro + org management, multi-member packs, access control, audit log |
A one-time Pro Lifetime purchase (~€6.99) exists before the backend launches and unlocks local power-user features only (advanced QB customization, additional themes/fonts, larger snippet limit, etc.). After the backend launches, lifetime purchasers are grandfathered: their local Pro features stay; the cloud features remain a subscription. We make this explicit at purchase time.
12.2 Payment processor
Stripe for the web dashboard. Google Play Billing for any in-app subscription on Android — though most Pro signups will happen on the dashboard, where Stripe gives us better margins than Play's 15-30% cut.
12.3 Free tier sustainability
The free tier has zero backend cost (no account, no data on our servers). It is genuinely free forever. We do not need to subsidize it.
13. Release sequence
This is the order in which features become visible to users. Each step is independently shippable.
- Now (already shipped): Free app on Play Store. Subscription billing scaffolding exists in the code but no Pro features are visible and the app says nothing about future cloud plans. The app is positioned as a complete, free SSH client.
- Phase 1 — Local Pro Lifetime (~€6.99 one-time). Unlock advanced local features (visual layouts, more themes/fonts, unlimited snippets, etc.) for users who want to support the project. No backend needed.
- Phase 2 — Backend launch + Subscription Pro. Web dashboard, sync, packs, visual KB editor, web terminal,
swbCLI. Lifetime purchasers keep their local features. - Phase 3 — Teams. Org management, multi-member pack sharing, audit log. Higher per-seat tier. Launch when there is real organic demand from companies asking for it — not before.
The point of phase 1 is to validate willingness to pay before investing in backend infrastructure. If nobody buys Pro Lifetime, building a backend is throwing money at a problem we do not have.
14. What stays local forever
These never require the backend, no matter what tier the user is on:
- All core SSH functionality: connections, terminal rendering, parser, key auth, host key TOFU
- Local vault export/import (
.swbfiles) — this is the offline backup path and the recovery path - The in-app QuickBar customizer (the web editor is an additional tool, not a replacement)
- Per-connection settings, themes, font sizes
- Hardware key actions, HW keyboard support, all keyboard packs
- SFTP browser
- Port forwarding
- Local shell sessions
If the backend is offline for a week, the app keeps working. If the company shuts down, the app keeps working. The backend is a convenience, never a dependency.
15. Open questions
Things to decide before implementation, not now:
- SRP-6a vs OPAQUE for login. Both are fine; pick whichever has the better library story in our chosen backend language.
- Backend language. Go (fast iteration, simple ops) or Rust (shared crypto code with mobile JNI) or Kotlin (shared types with mobile). No strong reason to pick one over the others yet.
- Database. Postgres is the obvious default. If we ever need horizontal sharding, Postgres + Citus, or skip ahead to FoundationDB. Not a near-term concern.
- Hosting. Hetzner / OVH / Fly.io for cost; AWS / GCP for "enterprise customers expect it". Probably start on Hetzner and migrate if a big customer demands it.
- Recovery codes at signup — do we ship them in v1 or punt to v2?
- Pack data key rotation cadence — manual on revocation only, or scheduled?
- Web terminal on mobile browsers — works in theory, ergonomically painful. Probably "desktop browsers only" for the v1 dashboard.
- Self-hosted backend — should we offer a Docker compose that organizations can run on-prem? Strong differentiator. Not v1.
16. Glossary
| Term | Meaning |
|---|---|
| Vault | The full set of a user's hosts, keys, snippets, layouts, QB configs |
| Entry | A single item in the vault (one host, one key, etc.) — encrypted as a single ciphertext blob |
| Pack | A named, curated subset of the vault that syncs as a unit |
| Master key | 32-byte AES key derived from the user password via Argon2id; never leaves the client |
| Pack data key | 32-byte AES key per pack, used to encrypt entries shared in that pack; wrapped per member with X25519 |
| Wrap | An X25519-encrypted copy of a pack data key, addressed to one specific member |
| Org | A team / organization with shared vault namespace and per-pack ACLs |
swb |
The open-source CLI client |
| Zero-knowledge | Server only sees ciphertext; cannot decrypt vault content under any circumstances |
17. References to existing code
The backend reuses, not replaces, what exists:
lib-vault-crypto— AES-256-GCM + Argon2id JNI. Already audited. Used as-is..swbfile format — defined indocs/TECHNICAL.md§"Vault Export / Import". The cloud sync uses the same envelope structure for individual entries.FUTURE.md"Vault Export — Option C" — the X25519 recipient-public-key design described there is the foundation for team key sharing in §5.3 above. Implement it for vault export first, then promote it to the cloud team-sharing model.SavedConnection,SshKey,SnippetRoom entities — the data model for vault entries on the device. Addremote_id,version,dirtycolumns when sync ships.SubscriptionRepositoryand the existing Google Play Billing scaffolding — extend to handle Stripe-side subscriptions on the dashboard.