ssh-workbench/docs/FUTURE_BACKEND.md
jima c4ead07fa4 Version bump, AppSwitch, cloud backend docs, audit files to docs/, gitignore cleanup
- 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>
2026-04-12 11:47:17 +02:00

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:

  1. Cross-device sync of curated subsets of the user's vault, called packs.
  2. Team sharing, where an org admin composes packs and assigns them to members.
  3. A web dashboard with a visual keyboard editor, pack composer, billing, and an in-browser xterm.js terminal.
  4. A swb CLI 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-crypto today (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 .swb export/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 (SavedConnection in 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:

  1. Decrypts pack_data_key for itself using its own wrapped copy.
  2. Fetches the new member's X25519 public key from the server.
  3. Wraps pack_data_key to the new member.
  4. 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

  1. User picks an email and a master password.
  2. Client generates a random 16-byte password salt.
  3. Client derives master_key = Argon2id(password, salt, …).
  4. Client generates an X25519 keypair. The private half is encrypted with master_key and packaged as a vault blob.
  5. Client uploads to server: { email, password_salt, x25519_public_key, encrypted_x25519_private } plus an authentication verifier (see 6.2).
  6. 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:

  1. Server returns the user's password_salt.
  2. Client re-derives master_key locally.
  3. Server issues a short-lived session JWT (15 min) and a long-lived refresh token (rotating, stored in EncryptedSharedPreferences on mobile).
  4. 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:

  1. Local .swb vault 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.
  2. 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
email 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-match semantics on version).
  • 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-crypto module 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 0 columns on SavedConnection, 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.js for 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:

  1. Accepts a WebSocket from the authenticated browser.
  2. Receives { host_id } from the client.
  3. Pulls the encrypted host blob and the encrypted private key for the user from the server (still ciphertext).
  4. 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 wssh style). 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:

  1. Decrypts the host entry and key.
  2. Writes the private key to a temporary file in $XDG_RUNTIME_DIR with mode 0600.
  3. Execs ssh -i <tmpkey> user@host.
  4. Removes the temp file when ssh exits.

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.

  1. 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.
  2. 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.
  3. Phase 2 — Backend launch + Subscription Pro. Web dashboard, sync, packs, visual KB editor, web terminal, swb CLI. Lifetime purchasers keep their local features.
  4. 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 (.swb files) — 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.
  • .swb file format — defined in docs/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, Snippet Room entities — the data model for vault entries on the device. Add remote_id, version, dirty columns when sync ships.
  • SubscriptionRepository and the existing Google Play Billing scaffolding — extend to handle Stripe-side subscriptions on the dashboard.