-- Initial schema. -- -- Timestamps are unix milliseconds (INTEGER): sortable, timezone-free, and -- directly comparable against Date.now() without a parse on every read. -- -- Encryption boundary — only three columns in this database hold ciphertext: -- credentials.ciphertext, private_keys.ciphertext, master_key_meta.ciphertext -- Everything else (hostnames, ports, usernames, folder names) is plaintext on -- purpose. The connection inventory has to be queryable, sortable and -- searchable; encrypting it wholesale would degrade all three, and a leaked -- hostname is a different order of problem from a leaked password. -- ---------------------------------------------------------------- the tree -- -- -- Folders and hosts form one adjacency list, each with a nullable parent. -- Chosen over a materialized path or nested set because real depth is 3-5 -- levels, so a recursive CTE read is cheap, while a move is a single-row -- UPDATE — which is exactly what drag-and-drop does constantly. CREATE TABLE folders ( id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, name TEXT NOT NULL, -- REAL, not INTEGER. Reordering writes the midpoint between two neighbours -- instead of renumbering every sibling. The cost is precision exhaustion -- after ~50 repeated midpoint inserts in the same gap, at which point the -- layer is renumbered in one pass. sort_order REAL NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX idx_folders_parent ON folders (parent_id, sort_order); CREATE TABLE hosts ( id INTEGER PRIMARY KEY, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, label TEXT NOT NULL, hostname TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22 CHECK (port BETWEEN 1 AND 65535), username TEXT NOT NULL, auth_type TEXT NOT NULL CHECK (auth_type IN ('password', 'key', 'agent')), -- Only meaningful when auth_type = 'key'. key_path TEXT, color TEXT, sort_order REAL NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX idx_hosts_folder ON hosts (folder_id, sort_order); -- ----------------------------------------------------------- the secrets -- CREATE TABLE credentials ( id INTEGER PRIMARY KEY, host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('password', 'passphrase')), ciphertext BLOB NOT NULL, nonce BLOB NOT NULL, -- Per-record HKDF salt. The data key is derived as HKDF(master, salt, info) -- rather than using the master key directly, so a nonce collision in one -- record cannot compromise another. salt BLOB NOT NULL, created_at INTEGER NOT NULL, -- One password (and one passphrase) per host. UNIQUE (host_id, kind) ); -- Imported private key material, for keys the user pastes in rather than -- referencing by path. Same encryption treatment as credentials. CREATE TABLE private_keys ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, ciphertext BLOB NOT NULL, nonce BLOB NOT NULL, salt BLOB NOT NULL, created_at INTEGER NOT NULL ); -- The data master key (DMK), sealed by the OS keyring via Electron's -- safeStorage. Exactly one row, enforced by the CHECK. CREATE TABLE master_key_meta ( id INTEGER PRIMARY KEY CHECK (id = 1), ciphertext BLOB NOT NULL, -- Which safeStorage backend sealed it. Recorded so a later downgrade is -- detectable: a record sealed by gnome_libsecret on a machine that now -- reports basic_text means the keyring changed underneath us, and an -- opaque "decrypt failed" would be the wrong thing to show the user. backend TEXT NOT NULL, created_at INTEGER NOT NULL ); -- ------------------------------------------------------- trust and tunnels -- -- Trust-on-first-use host key store. CREATE TABLE known_hosts ( host TEXT NOT NULL, port INTEGER NOT NULL, key_type TEXT NOT NULL, -- SHA256 base64 fingerprint, OpenSSH format. fingerprint TEXT NOT NULL, added_at INTEGER NOT NULL, PRIMARY KEY (host, port, key_type) ); CREATE TABLE port_forwards ( id INTEGER PRIMARY KEY, host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('local', 'remote', 'dynamic')), bind_host TEXT NOT NULL DEFAULT '127.0.0.1', bind_port INTEGER NOT NULL, -- Null for 'dynamic' (-D), which is a SOCKS proxy and has no fixed target. dest_host TEXT, dest_port INTEGER, auto_start INTEGER NOT NULL DEFAULT 0 CHECK (auto_start IN (0, 1)), created_at INTEGER NOT NULL, CHECK ( (type = 'dynamic' AND dest_host IS NULL AND dest_port IS NULL) OR (type IN ('local', 'remote') AND dest_host IS NOT NULL AND dest_port IS NOT NULL) ) ); CREATE INDEX idx_port_forwards_host ON port_forwards (host_id); CREATE TABLE history ( id INTEGER PRIMARY KEY, host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL, connected_at INTEGER NOT NULL, duration_ms INTEGER, exit_status TEXT ); CREATE INDEX idx_history_host ON history (host_id, connected_at DESC); CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL );