credentialVaultLocalClient.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { randomUUID } from 'node:crypto'
  7. import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
  8. import { sshFixture } from '../fixtures/credentialProtocols'
  9. const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
  10. const session = 'a'.repeat(64), password = 'DUMMY-local-master-only', secret = 'DUMMY-local-website-secret'
  11. function client(home = mkdtempSync(join(tmpdir(), 'yaoyao-local-vault-'))) {
  12. if (!homes.includes(home)) homes.push(home)
  13. const value = new LocalCredentialVaultClient(home); clients.push(value)
  14. return { home, value, call: (command: string, body?: unknown, owner = 'dummy-owner', binding = session) => value.call(owner, binding, command, body) }
  15. }
  16. afterEach(async () => { clients.splice(0).forEach(c => c.close()); await Promise.resolve(); homes.splice(0).forEach(h => rmSync(h, { recursive: true, force: true })) })
  17. it('provides encrypted local CRUD and bounded task execution by default without an external service', async () => {
  18. const f = client()
  19. expect(await f.call('status')).toMatchObject({ online: true, initialized: false, unlocked: false, execution: 'local-adapters', reason: 'local_controlled', storageMode: 'local' })
  20. await f.call('initialize', { password })
  21. await expect(f.call('add', {})).rejects.toMatchObject({ code: 'vault_locked' })
  22. await f.call('unlock', { password })
  23. const entry = await f.call('add', { name: 'Dummy website', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
  24. expect(entry).not.toHaveProperty('secret')
  25. const stored = readdirSync(join(f.home, 'credential-vault')).filter(name => name.endsWith('.vault')).map(name => readFileSync(join(f.home, 'credential-vault', name), 'utf8')).join('')
  26. expect(stored).not.toContain(secret); expect(stored).not.toContain(password); expect(stored).not.toContain('Dummy website')
  27. expect((await f.call('status')).entries).toHaveLength(1)
  28. await expect(f.call('read-secret', {id:entry.id})).rejects.toMatchObject({ code: 'vault_command_unknown' })
  29. await f.call('update', { id: entry.id, entry: { name: 'Updated', username: 'dummy', target: entry.target, revision: entry.revision } })
  30. expect((await f.call('status')).entries[0].name).toBe('Updated')
  31. await f.call('remove', { id: entry.id }); expect((await f.call('status')).entries).toEqual([])
  32. })
  33. it('binds one-use local execution to the owner and session and requires a configured usage policy',async()=>{
  34. const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
  35. const entry=await f.call('add',{name:'Dummy',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret})
  36. const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'website.login',target:entry.target,seconds:60}
  37. const lease=await f.call('grant',input)
  38. await expect(f.call('execute',{id:lease.id,input},'another-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
  39. await expect(f.call('execute',{id:lease.id,input},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_scope_forbidden'})
  40. expect(await f.call('execute',{id:lease.id,input})).toEqual({status:'manual_takeover_required',reason:'usage_policy_required',submitted:false})
  41. await expect(f.call('execute',{id:lease.id,input})).rejects.toMatchObject({code:'vault_lease_revoked'})
  42. })
  43. it('saves SSH offline, remembers the first host without authenticating, persists its pin and rejects a changed host before sending a password',async()=>{
  44. const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
  45. let ssh=await sshFixture('password')
  46. try{
  47. const {hostKey:_initialKey,...target}=ssh.target
  48. await ssh.close() // The server is actually offline while the password is saved.
  49. const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
  50. ssh=await sshFixture('password',target.port);const hostKey=ssh.target.hostKey
  51. const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target,seconds:60}
  52. expect(ssh.state.authMethods).toEqual([])
  53. await expect(f.call('grant',input)).rejects.toMatchObject({code:'vault_host_key_required'})
  54. await expect(f.call('prepare-host',{id:entry.id,revision:entry.revision},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_locked'})
  55. const prepared=await f.call('prepare-host',{id:entry.id,revision:entry.revision})
  56. expect(prepared.target.hostKey).toBe(hostKey);expect(ssh.state.authMethods).toEqual([])
  57. await f.call('lock');await f.call('unlock',{password});expect((await f.call('status')).entries[0].target.hostKey).toBe(hostKey)
  58. const port=target.port;await ssh.close();ssh=await sshFixture('password',port)
  59. expect(ssh.target.hostKey).not.toBe(hostKey)
  60. expect((await f.call('prepare-host',{id:entry.id,revision:prepared.revision})).target.hostKey).toBe(hostKey)
  61. const bound={...input,target:prepared.target},lease=await f.call('grant',bound)
  62. await expect(f.call('execute',{id:lease.id,input:bound})).rejects.toMatchObject({code:'vault_host_key_rejected'})
  63. expect(ssh.state.authMethods).toEqual([]);expect(ssh.state.commands).toEqual([])
  64. expect((await f.call('status')).entries[0].target.hostKey).toBe(hostKey)
  65. }finally{await ssh.close()}
  66. },15000)
  67. it('isolates accounts and login sessions and starts locked after a server restart', async () => {
  68. const f = client(); await f.call('initialize', { password }); await f.call('unlock', { password })
  69. await f.call('add', { name: 'Dummy', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
  70. expect(await f.call('status', undefined, 'another-owner')).toMatchObject({ initialized: false, entries: [] })
  71. expect(await f.call('status', undefined, 'dummy-owner', 'b'.repeat(64))).toMatchObject({ unlocked: false, entries: [] })
  72. await expect(f.call('add', {}, 'dummy-owner', 'b'.repeat(64))).rejects.toMatchObject({ code: 'vault_locked' })
  73. f.value.close(); await Promise.resolve()
  74. const restarted = client(f.home)
  75. expect(await restarted.call('status')).toMatchObject({ initialized: true, unlocked: false, entries: [] })
  76. await restarted.call('unlock', { password }); expect((await restarted.call('status')).entries).toHaveLength(1)
  77. })
  78. it('keeps an explicitly configured but unavailable private broker offline', async () => {
  79. const c = createCredentialVaultClient({ socket: '/missing/socket', tokenFile: '/missing/token', hermesUid: process.getuid!() + 1 }, '/unused')
  80. expect(c).not.toBeInstanceOf(LocalCredentialVaultClient)
  81. await expect(c.call('owner', session, 'status')).rejects.toMatchObject({ code: 'vault_offline' })
  82. c.close()
  83. })
  84. it('prevents concurrent brokers from writing the same local vault', async () => {
  85. const first = client(); await first.call('initialize', { password })
  86. const second = client(first.home)
  87. await expect(second.call('status')).rejects.toMatchObject({ code: 'vault_offline' })
  88. first.value.close(); await Promise.resolve()
  89. expect(await second.call('status')).toMatchObject({ initialized: true, unlocked: false })
  90. })
  91. it('keeps specified Bot approval across management lock and restart, without exposing secrets, and revokes it explicitly',async()=>{
  92. const ssh=await sshFixture('password'),f=client(),agentId=randomUUID(),owner='dummy-owner'
  93. const controller=new AbortController()
  94. try{
  95. await f.call('initialize',{password});await f.call('unlock',{password})
  96. const {hostKey:_hostKey,...target}=ssh.target
  97. const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
  98. await expect(f.call('bot-grant',{credentialRef:entry.id,agentId},owner,'b'.repeat(64))).rejects.toMatchObject({code:'vault_locked'})
  99. await f.call('bot-grant',{credentialRef:entry.id,agentId})
  100. expect(ssh.state.authMethods).toEqual([])
  101. await f.call('lock')
  102. await expect(f.call('bot-grant',{credentialRef:entry.id,agentId})).rejects.toMatchObject({code:'vault_locked'})
  103. const refs=await f.call('bot-refs',{agentId})
  104. expect(refs).toHaveLength(1);expect(refs[0]).toMatchObject({credentialRef:entry.id,agentId,authorized:true,allowedTarget:ssh.target})
  105. expect((await f.call('status')).unlocked).toBe(false)
  106. expect(await f.call('bot-refs',{agentId:randomUUID()})).toEqual([])
  107. expect(await f.call('bot-refs',{agentId},'other-owner')).toEqual([])
  108. const input={credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
  109. await expect(f.value.executeBot('other-owner',input,controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
  110. await expect(f.value.executeBot(owner,{...input,agentId:randomUUID()},controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
  111. f.value.close();await Promise.resolve();const restarted=client(f.home)
  112. expect(await restarted.call('status')).toMatchObject({unlocked:false,botGrants:[{credentialRef:entry.id,agentId}]})
  113. expect(await restarted.value.executeBot(owner,input,controller.signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
  114. expect(ssh.state.commands).toEqual(['/usr/bin/uptime'])
  115. const directory=join(f.home,'credential-vault','bot-access')
  116. const disk=readdirSync(directory).map(n=>readFileSync(join(directory,n),'utf8')).join('')
  117. for(const value of [ssh.secret,ssh.username,password,'Dummy SSH'])expect(disk).not.toContain(value)
  118. const publicData=JSON.stringify([refs,(await restarted.call('status')).botGrants])
  119. for(const value of [ssh.secret,ssh.username,password])expect(publicData).not.toContain(value)
  120. await restarted.call('unlock',{password});await restarted.call('bot-revoke',{credentialRef:entry.id,agentId});await restarted.call('lock')
  121. expect(await restarted.call('bot-refs',{agentId})).toEqual([])
  122. await expect(restarted.value.executeBot(owner,input,controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
  123. }finally{await ssh.close()}
  124. },15000)
  125. it('management expiry preserves Bot approval while editing or deleting a credential revokes approval and in-flight use',async()=>{
  126. const ssh=await sshFixture('password'),f=client(),agentId=randomUUID(),owner='dummy-owner'
  127. try{
  128. await f.call('initialize',{password});await f.call('unlock',{password})
  129. const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target:ssh.target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
  130. const grant={credentialRef:entry.id,agentId};await f.call('bot-grant',grant)
  131. const broker=await (f.value as any).broker
  132. const future=vi.spyOn(broker.store,'now').mockReturnValue(Date.now()+301000)
  133. expect(await f.call('status')).toMatchObject({unlocked:false});expect(await f.call('bot-refs',{agentId})).toHaveLength(1);future.mockRestore()
  134. await f.call('unlock',{password})
  135. await f.call('update',{id:entry.id,entry:{name:'Updated',username:ssh.username,target:entry.target,revision:entry.revision}})
  136. expect(await f.call('bot-refs',{agentId})).toEqual([])
  137. await f.call('bot-grant',grant);await f.call('lock')
  138. ssh.state.delayExec=true
  139. const input={...grant,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
  140. const pending=f.value.executeBot(owner,input,new AbortController().signal),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
  141. await ssh.executed;await f.call('unlock',{password});await f.call('remove',{id:entry.id});await assertion
  142. expect(await f.call('bot-refs',{agentId})).toEqual([])
  143. }finally{vi.restoreAllMocks();await ssh.close()}
  144. },15000)