NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Db } from '../src/main/db/database'
|
||||
import {
|
||||
deleteCredential,
|
||||
getCredential,
|
||||
hasCredential,
|
||||
setCredential,
|
||||
} from '../src/main/db/repositories/credentials'
|
||||
import { createHost } from '../src/main/db/repositories/connections'
|
||||
import { Vault } from '../src/main/security/vault'
|
||||
import { createTempDb, fakeKeyring, insecureKeyring, type TempDb } from './helpers'
|
||||
|
||||
let temp: TempDb
|
||||
let vault: Vault
|
||||
let hostId: number
|
||||
|
||||
beforeEach(() => {
|
||||
temp = createTempDb()
|
||||
vault = new Vault(temp.db, fakeKeyring())
|
||||
vault.initialize()
|
||||
hostId = createHost(temp.db, {
|
||||
parentId: null,
|
||||
label: 'web-01',
|
||||
hostname: '10.0.1.5',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
temp.cleanup()
|
||||
})
|
||||
|
||||
describe('credentials repository', () => {
|
||||
it('round-trips a password', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('hunter2')
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBeNull()
|
||||
expect(hasCredential(temp.db, hostId, 'password')).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces rather than duplicates on a second write', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'first')
|
||||
setCredential(temp.db, vault, hostId, 'password', 'second')
|
||||
|
||||
const count = temp.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM credentials WHERE host_id = ?')
|
||||
.get(hostId) as { n: number }
|
||||
|
||||
expect(count.n).toBe(1)
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('second')
|
||||
})
|
||||
|
||||
it('keeps password and passphrase independent', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'login-pw')
|
||||
setCredential(temp.db, vault, hostId, 'passphrase', 'key-phrase')
|
||||
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('login-pw')
|
||||
expect(getCredential(temp.db, vault, hostId, 'passphrase')).toBe('key-phrase')
|
||||
})
|
||||
|
||||
it('deletes a credential and leaves no row', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
deleteCredential(temp.db, hostId, 'password')
|
||||
|
||||
expect(hasCredential(temp.db, hostId, 'password')).toBe(false)
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes credentials when the host is deleted', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
temp.db.prepare('DELETE FROM hosts WHERE id = ?').run(hostId)
|
||||
|
||||
const count = temp.db.prepare('SELECT COUNT(*) AS n FROM credentials').get() as { n: number }
|
||||
expect(count.n).toBe(0)
|
||||
})
|
||||
|
||||
it('throws instead of storing plaintext when the vault is locked', () => {
|
||||
vault.lock()
|
||||
|
||||
// A caller that believes it saved a password must not be told otherwise.
|
||||
expect(() => setCredential(temp.db, vault, hostId, 'password', 'hunter2')).toThrow()
|
||||
})
|
||||
|
||||
it('stores no plaintext anywhere in the database file', () => {
|
||||
// The assertion that matters. Checking the column would only prove the
|
||||
// column is bytes; reading the file proves the secret did not land in a WAL
|
||||
// frame, an index, or anywhere else SQLite decided to put it.
|
||||
const secret = 'correct-horse-battery-staple'
|
||||
setCredential(temp.db, vault, hostId, 'password', secret)
|
||||
|
||||
// Fold the WAL back into the main file so a single read is conclusive.
|
||||
temp.db.pragma('wal_checkpoint(TRUNCATE)')
|
||||
|
||||
const bytes = readFileSync(temp.path)
|
||||
expect(bytes.includes(Buffer.from(secret, 'utf8'))).toBe(false)
|
||||
// Guard against the assertion passing for the wrong reason — e.g. the write
|
||||
// silently not happening at all.
|
||||
expect(bytes.includes(Buffer.from('credentials', 'utf8'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stores no plaintext for non-ASCII secrets either', () => {
|
||||
const secret = '密码-🔐-pass'
|
||||
setCredential(temp.db, vault, hostId, 'password', secret)
|
||||
temp.db.pragma('wal_checkpoint(TRUNCATE)')
|
||||
|
||||
const bytes = readFileSync(temp.path)
|
||||
expect(bytes.includes(Buffer.from(secret, 'utf8'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to read a credential written under a different master key', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
|
||||
// Simulates the keyring having changed: a new vault generates a new key,
|
||||
// and the old ciphertext must fail authentication rather than decrypt to
|
||||
// garbage that gets sent to a server as a password.
|
||||
const otherVault = new Vault(temp.db, insecureKeyring())
|
||||
expect(otherVault.isUnlocked).toBe(false)
|
||||
expect(() => otherVault.initialize()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('credentials with an insecure keyring', () => {
|
||||
it('cannot be written at all', () => {
|
||||
const lockedDb: Db = createTempDb().db
|
||||
const insecureVault = new Vault(lockedDb, insecureKeyring())
|
||||
insecureVault.initialize()
|
||||
|
||||
const id = createHost(lockedDb, {
|
||||
parentId: null,
|
||||
label: 'web-02',
|
||||
hostname: '10.0.1.6',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
})
|
||||
|
||||
expect(() => setCredential(lockedDb, insecureVault, id, 'password', 'hunter2')).toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user