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.
149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { closeDatabase, openDatabase } from '../src/main/db/database'
|
|
import { Vault } from '../src/main/security/vault'
|
|
import { SECRET_CONTEXT } from '../src/main/security/secretBox'
|
|
import { createTempDb, fakeKeyring, insecureKeyring, type TempDb } from './helpers'
|
|
|
|
let temp: TempDb | null = null
|
|
|
|
afterEach(() => {
|
|
temp?.cleanup()
|
|
temp = null
|
|
})
|
|
|
|
describe('vault', () => {
|
|
it('generates a master key on first run', () => {
|
|
temp = createTempDb()
|
|
const vault = new Vault(temp.db, fakeKeyring())
|
|
|
|
const status = vault.initialize()
|
|
|
|
expect(status).toMatchObject({ state: 'unlocked', createdNow: true })
|
|
expect(vault.isUnlocked).toBe(true)
|
|
})
|
|
|
|
it('reloads the existing master key on later runs', () => {
|
|
temp = createTempDb()
|
|
const first = new Vault(temp.db, fakeKeyring())
|
|
first.initialize()
|
|
const sealed = first.sealSecret('persisted-secret', SECRET_CONTEXT.credential)
|
|
|
|
// A second vault over the same database must recover the same key, or every
|
|
// stored credential becomes undecryptable on restart.
|
|
const second = new Vault(temp.db, fakeKeyring())
|
|
const status = second.initialize()
|
|
|
|
expect(status).toMatchObject({ state: 'unlocked', createdNow: false })
|
|
expect(second.unsealSecret(sealed, SECRET_CONTEXT.credential)).toBe('persisted-secret')
|
|
})
|
|
|
|
it('stores the master key wrapped, never in the clear', () => {
|
|
temp = createTempDb()
|
|
const vault = new Vault(temp.db, fakeKeyring())
|
|
vault.initialize()
|
|
|
|
const row = temp.db
|
|
.prepare('SELECT ciphertext FROM master_key_meta WHERE id = 1')
|
|
.get() as { ciphertext: Buffer }
|
|
|
|
// The keyring prefix proves wrap() was actually called rather than the raw
|
|
// key being written straight to the row.
|
|
expect(row.ciphertext.toString('utf8').startsWith('WRAPPED:')).toBe(true)
|
|
})
|
|
|
|
it('refuses to operate when the keyring is not trustworthy', () => {
|
|
temp = createTempDb()
|
|
const vault = new Vault(temp.db, insecureKeyring())
|
|
|
|
const status = vault.initialize()
|
|
|
|
expect(status).toMatchObject({ state: 'unavailable', reason: 'insecure-keyring' })
|
|
expect(vault.isUnlocked).toBe(false)
|
|
|
|
// The hard rule: no silent degradation to weak storage. Storing a password
|
|
// under a hardcoded key is worse than refusing, because the user believes
|
|
// it is protected.
|
|
expect(() => vault.sealSecret('hunter2', SECRET_CONTEXT.credential)).toThrow()
|
|
})
|
|
|
|
it('writes no master key row at all when the keyring is insecure', () => {
|
|
temp = createTempDb()
|
|
new Vault(temp.db, insecureKeyring()).initialize()
|
|
|
|
const count = temp.db
|
|
.prepare('SELECT COUNT(*) AS n FROM master_key_meta')
|
|
.get() as { n: number }
|
|
|
|
expect(count.n).toBe(0)
|
|
})
|
|
|
|
it('reports a usable failure when the stored key cannot be unwrapped', () => {
|
|
temp = createTempDb()
|
|
const first = new Vault(temp.db, fakeKeyring())
|
|
first.initialize()
|
|
|
|
// Simulates the keyring changing underneath us — a different daemon, a
|
|
// restored backup, a reimaged machine.
|
|
const brokenKeyring = { ...fakeKeyring(), unwrap: (): string => {
|
|
throw new Error('no keyring entry found')
|
|
} }
|
|
|
|
const second = new Vault(temp.db, brokenKeyring)
|
|
const status = second.initialize()
|
|
|
|
expect(status).toMatchObject({ state: 'unavailable', reason: 'unseal-failed' })
|
|
// The message must name the backend that sealed it, so the condition is
|
|
// diagnosable rather than an opaque decrypt failure.
|
|
if (status.state === 'unavailable') {
|
|
expect(status.detail).toContain('test-keyring')
|
|
}
|
|
})
|
|
|
|
it('locks, after which secrets can no longer be sealed', () => {
|
|
temp = createTempDb()
|
|
const vault = new Vault(temp.db, fakeKeyring())
|
|
vault.initialize()
|
|
|
|
vault.lock()
|
|
|
|
expect(vault.isUnlocked).toBe(false)
|
|
expect(() => vault.sealSecret('hunter2', SECRET_CONTEXT.credential)).toThrow(/locked/)
|
|
})
|
|
|
|
it('rejects a master key of the wrong length', () => {
|
|
temp = createTempDb()
|
|
new Vault(temp.db, fakeKeyring()).initialize()
|
|
|
|
// A short key would silently weaken AES-256 or make ciphertext
|
|
// unrecoverable depending on how the crypto layer reacted.
|
|
const shortKeyring = {
|
|
...fakeKeyring(),
|
|
unwrap: (): string => Buffer.from('too-short').toString('base64'),
|
|
}
|
|
|
|
const vault = new Vault(temp.db, shortKeyring)
|
|
const status = vault.initialize()
|
|
|
|
expect(status).toMatchObject({ state: 'unavailable', reason: 'unseal-failed' })
|
|
})
|
|
|
|
it('survives a restart with a fresh Vault over the same file', () => {
|
|
const db = createTempDb()
|
|
const path = db.path
|
|
|
|
const vault = new Vault(db.db, fakeKeyring())
|
|
vault.initialize()
|
|
const sealed = vault.sealSecret('across-restart', SECRET_CONTEXT.credential)
|
|
closeDatabase(db.db)
|
|
|
|
const reopened = openDatabase(path)
|
|
const afterRestart = new Vault(reopened, fakeKeyring())
|
|
afterRestart.initialize()
|
|
|
|
expect(afterRestart.unsealSecret(sealed, SECRET_CONTEXT.credential)).toBe('across-restart')
|
|
|
|
closeDatabase(reopened)
|
|
temp = { ...db, db: reopened }
|
|
})
|
|
})
|