credentialVaultStore.test.ts 6.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. // @vitest-environment node
  2. import { afterEach, expect, it } from 'vitest'
  3. import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { CredentialVaultStore } from '../../src/server/credentialVault/store'
  7. const homes: string[] = [], stores: CredentialVaultStore[] = []
  8. const owner = 'fixture-owner', password = 'dummy-master-password-only'
  9. const secret = 'DUMMY-DO-NOT-USE-website-password'
  10. const input = { name: 'fixture site', username: 'fixture-user', target: { kind: 'website' as const, origin: 'https://example.test' }, secret }
  11. async function fixture() {
  12. const home = mkdtempSync(join(tmpdir(), 'yaoyao-vault-store-')); homes.push(home)
  13. const store = await CredentialVaultStore.create(home); stores.push(store); return { store, home }
  14. }
  15. afterEach(() => { stores.splice(0).forEach(s => s.lockAll()); homes.splice(0).forEach(p => rmSync(p, { recursive: true, force: true })) })
  16. it('starts locked, encrypts entries and exposes metadata only; restart requires manual unlock', async () => {
  17. const { store, home } = await fixture()
  18. expect(store.initialize(owner, password)).toMatchObject({ initialized: true, unlocked: false })
  19. expect(() => store.add(owner, input)).toThrowError(expect.objectContaining({ code: 'vault_locked' }))
  20. store.unlock(owner, password); const entry = store.add(owner, input)
  21. expect(entry).not.toHaveProperty('secret')
  22. const file = readdirSync(home)[0]!, bytes = readFileSync(join(home, file), 'utf8')
  23. expect(bytes).not.toContain(secret); expect(bytes).not.toContain(input.username); expect(bytes).not.toContain(password)
  24. expect(readdirSync(home)).toHaveLength(1) // No co-located key file.
  25. const restarted = await CredentialVaultStore.create(home); stores.push(restarted)
  26. expect(restarted.state(owner).unlocked).toBe(false)
  27. expect(() => restarted.unlock(owner, 'wrong-dummy-password')).toThrowError(expect.objectContaining({ code: 'vault_unlock_failed' }))
  28. expect(() => restarted.unlock(owner, password)).toThrowError(expect.objectContaining({ code: 'vault_unlock_throttled' }))
  29. expect(store.withSecret(owner, entry.id, e => e.secret)).toBe(secret)
  30. })
  31. it('binds encrypted payload to owner, vault ID, revision and KDF metadata', async () => {
  32. const { store, home } = await fixture(); store.initialize(owner, password)
  33. const path = join(home, readdirSync(home)[0]!), original = JSON.parse(readFileSync(path, 'utf8'))
  34. for (const mutate of [(e: any) => e.id = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', (e: any) => e.revision++, (e: any) => e.kdf.salt = Buffer.alloc(16).toString('base64')]) {
  35. const e = structuredClone(original); mutate(e); writeFileSync(path, JSON.stringify(e), { mode: 0o600 })
  36. const probe = await CredentialVaultStore.create(home); stores.push(probe)
  37. expect(() => probe.unlock(owner, password)).toThrowError(expect.objectContaining({ code: 'vault_unlock_failed' }))
  38. }
  39. })
  40. it('uses fresh nonces, enforces entry revisions and atomically rotates the DEK and wrapping password', async () => {
  41. const { store, home } = await fixture(); store.initialize(owner, password); store.unlock(owner, password)
  42. const first = store.add(owner, input), path = join(home, readdirSync(home)[0]!), before = JSON.parse(readFileSync(path, 'utf8'))
  43. const next = store.update(owner, first.id, { ...input, secret: undefined, revision: 1 })
  44. expect(next.revision).toBe(2)
  45. expect(() => store.update(owner, first.id, { ...input, revision: 1 })).toThrowError(expect.objectContaining({ code: 'vault_revision_conflict' }))
  46. expect(JSON.parse(readFileSync(path, 'utf8')).payload.nonce).not.toBe(before.payload.nonce)
  47. store.rotate(owner, 'dummy-replacement-password')
  48. expect(store.state(owner).unlocked).toBe(false)
  49. store.unlock(owner, 'dummy-replacement-password')
  50. expect(store.withSecret(owner, first.id, e => e.secret)).toBe(secret)
  51. store.remove(owner, first.id); expect(store.list(owner)).toEqual([])
  52. })
  53. it('verifies encrypted backups, rejects owner changes and restores without retaining unlock', async () => {
  54. const { store } = await fixture(); store.initialize(owner, password); store.unlock(owner, password)
  55. const entry = store.add(owner, input), archive = store.backup(owner), { store: destination } = await fixture()
  56. expect(archive).not.toContain(secret)
  57. expect(() => destination.restore('another-owner', archive, password)).toThrowError(expect.objectContaining({ code: 'vault_backup_owner' }))
  58. destination.restore(owner, archive, password); expect(destination.state(owner).unlocked).toBe(false)
  59. destination.unlock(owner, password); expect(destination.withSecret(owner, entry.id, e => e.secret)).toBe(secret)
  60. })
  61. it('locks on a fixed deadline and preserves the old decryptable file if rotation is interrupted before rename', async () => {
  62. const { store, home } = await fixture(); store.initialize(owner, password)
  63. let now = 1000, fail = false
  64. const guarded = await CredentialVaultStore.create(home, () => now, () => { if (fail) throw new Error('dummy write interruption') }); stores.push(guarded)
  65. guarded.unlock(owner, password, 30); guarded.add(owner, input); const original = guarded.backup(owner)
  66. fail = true; expect(() => guarded.rotate(owner, 'dummy-new-master-password')).toThrow('dummy write interruption')
  67. expect(guarded.state(owner).unlocked).toBe(false);guarded.unlock(owner,password,30)
  68. expect(guarded.backup(owner)).toBe(original); expect(readdirSync(home)).toHaveLength(1)
  69. now += 30000; expect(guarded.state(owner).unlocked).toBe(false)
  70. fail = false; guarded.unlock(owner, password); expect(guarded.list(owner)).toHaveLength(1)
  71. })
  72. it('encrypts fixed SFTP contents, never returns them, retains a blank edit and revokes policy changes',async()=>{
  73. const {store,home}=await fixture();store.initialize(owner,password);store.unlock(owner,password)
  74. const ssh={name:'dummy',username:'dummy',secret:'DUMMY-key',target:{kind:'ssh',host:'127.0.0.1',port:22,hostKey:'SHA256:'+'A'.repeat(43)}}
  75. const body='DUMMY-fixed-private-file-body',e=store.add(owner,{...ssh,usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:body}})
  76. expect(e.usage).toEqual({kind:'sftp.write',remotePath:'/approved/new.txt',bytes:Buffer.byteLength(body)})
  77. expect(JSON.stringify(store.list(owner))).not.toContain(body);expect(store.backup(owner)).not.toContain(body)
  78. let revoked=0;store.onLock=()=>revoked++
  79. const edit=store.update(owner,e.id,{...ssh,secret:undefined,revision:1,usage:{kind:'sftp.write',remotePath:'/approved/other.txt'}})
  80. expect(edit.usage).toMatchObject({bytes:Buffer.byteLength(body)});expect(store.withSecret(owner,e.id,x=>x.usage)).toMatchObject({contents:body});expect(revoked).toBe(1)
  81. for(const value of [{kind:'ssh.exec',command:'/bin/sh -c id'},{kind:'sftp.read',remotePath:'/approved/../outside',maxBytes:10}])expect(()=>store.update(owner,e.id,{...ssh,revision:2,usage:value})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
  82. expect(readFileSync(join(home,readdirSync(home).find(v=>v.endsWith('.vault'))!),'utf8')).not.toContain(body)
  83. })