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 } }) })