credentialVaultBroker.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import { CredentialVaultStore } from '../../src/server/credentialVault/store'
  8. import { CredentialVaultBroker, type CredentialExecutorFixture } from '../../src/server/credentialVault/broker'
  9. import type { LeaseInput } from '../../src/server/credentialVault/schema'
  10. const homes:string[]=[], brokers:CredentialVaultBroker[]=[]
  11. const owner='dummy-owner', session='a'.repeat(64), otherSession='b'.repeat(64), password='dummy-master-password', secret='DUMMY-CREDENTIAL-NEVER-REAL'
  12. async function fixture(executor?:CredentialExecutorFixture){
  13. const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-broker-'));homes.push(home)
  14. let now=Date.now();const store=await CredentialVaultStore.create(home,()=>now),broker=new CredentialVaultBroker(store,()=>now,executor);brokers.push(broker)
  15. let epoch=randomUUID();broker.hello(epoch)
  16. const dispatch=(command:string,value?:unknown,s=session,o=owner)=>broker.dispatch(epoch,o,s,command,value) as any
  17. dispatch('initialize',{password});dispatch('unlock',{password})
  18. const entry=dispatch('add',{name:'dummy-site',username:'dummy-username',target:{kind:'website',origin:'https://example.test'},secret})
  19. const input:LeaseInput={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-runner-epoch',operation:'website.login',target:entry.target,seconds:60}
  20. return {home,store,broker,dispatch,entry,input,grant:()=>dispatch('grant',input),prepare:(body:unknown,s=session,o=owner,signal?:AbortSignal)=>broker.prepareHost(epoch,o,s,body,signal),execute:(id:string,b=input,o=owner,s=session)=>broker.execute(epoch,o,s,id,b),advance:(ms:number)=>{now+=ms},reconnect:()=>{epoch=randomUUID();broker.hello(epoch)}}
  21. }
  22. afterEach(()=>{brokers.splice(0).forEach(b=>b.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}))})
  23. it('production has no execution adapter or read-secret RPC, even after user unlock and grant',async()=>{
  24. const f=await fixture(),lease=f.grant()
  25. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_executor_not_enabled'})
  26. expect(()=>f.dispatch('read-secret',{id:f.entry.id})).toThrowError(expect.objectContaining({code:'vault_command_unknown'}))
  27. const state=f.dispatch('status');expect(JSON.stringify(state)).not.toContain(secret)
  28. expect(state.execution).toBe('disabled');expect(f.dispatch('status',{},otherSession).entries).toEqual([])
  29. })
  30. it('binds a lease to owner, Bot, task, exact target, operation and Runner incarnation',async()=>{
  31. const execute=vi.fn(async()=>{}),f=await fixture({execute}),lease=f.grant()
  32. for(const patch of [{agentId:randomUUID()},{workId:randomUUID()},{runnerId:randomUUID()},{runnerInstance:randomUUID()},{runnerEpoch:'new-epoch'},{operation:'ssh.exec' as const},{target:{kind:'website' as const,origin:'https://other.test'}}])
  33. await expect(f.execute(lease.id,{...f.input,...patch})).rejects.toMatchObject({code:'vault_scope_forbidden'})
  34. await expect(f.execute(lease.id,f.input,'other-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
  35. await expect(f.execute(lease.id,f.input,owner,otherSession)).rejects.toMatchObject({code:'vault_scope_forbidden'})
  36. expect(()=>f.dispatch('grant',{...f.input,target:{kind:'website',origin:'http://example.test'}})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
  37. expect(()=>f.dispatch('grant',{...f.input,target:{kind:'website',origin:'https://other.test'}})).toThrowError(expect.objectContaining({code:'vault_target_forbidden'}))
  38. expect(execute).not.toHaveBeenCalled()
  39. expect(await f.execute(lease.id)).toEqual({status:'complete',operation:'website.login'})
  40. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_lease_revoked'})
  41. expect(execute).toHaveBeenCalledOnce()
  42. })
  43. it('binds SSH grants to a pinned host key and port and rejects plaintext FTP',async()=>{
  44. const f=await fixture({execute:async()=>{}}),target={kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43)}
  45. const entry=f.dispatch('add',{name:'dummy-ssh',username:'dummy',target,secret:'DUMMY-PRIVATE-KEY'})
  46. const input={...f.input,credentialRef:entry.id,target,operation:'sftp.read',seconds:60} as LeaseInput
  47. const l=f.dispatch('grant',input)
  48. await expect(f.execute(l.id,{...input,target:{...input.target,port:2222} as any})).rejects.toMatchObject({code:'vault_scope_forbidden'})
  49. expect(()=>f.dispatch('grant',{...input,operation:'ftp.login'})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
  50. expect(()=>f.dispatch('grant',{...input,target:{...target,hostKey:'SHA256:'+'B'.repeat(43)}})).toThrowError(expect.objectContaining({code:'vault_target_forbidden'}))
  51. })
  52. it.each(['lock','logout','revoke','cancel','reconnect','update','remove','expiry'] as const)('aborts an in-flight dummy executor on %s, hides errors and prevents retry',async cause=>{
  53. let release!:()=>void,signal:AbortSignal|undefined
  54. const started=Promise.withResolvers<void>(),f=await fixture({execute:async(entry,_input,s)=>{expect(entry.secret).toBe(secret);signal=s;started.resolve();await new Promise<void>(r=>{release=r})}}),lease=f.grant()
  55. const run=f.execute(lease.id),result=expect(run).rejects.toMatchObject({code:'vault_operation_uncertain'})
  56. await started.promise
  57. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_operation_uncertain'})
  58. if(cause==='lock'||cause==='logout')f.dispatch(cause)
  59. if(cause==='revoke')f.dispatch('revoke',{id:lease.id})
  60. if(cause==='cancel')f.dispatch('revoke-work',{workId:f.input.workId})
  61. if(cause==='reconnect')f.reconnect()
  62. if(cause==='update')f.dispatch('update',{id:f.entry.id,entry:{name:'new-name',username:'dummy',target:f.entry.target,revision:1}})
  63. if(cause==='remove')f.dispatch('remove',{id:f.entry.id})
  64. if(cause==='expiry'){f.advance(300000);f.dispatch('status')}
  65. expect(signal?.aborted).toBe(true);release();await result
  66. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_lease_revoked'})
  67. const audit=readFileSync(join(f.home,'audit.jsonl'),'utf8')
  68. for(const privateValue of [secret,password,'dummy-username','https://example.test'])expect(audit).not.toContain(privateValue)
  69. expect(audit).toContain('not-confirmed')
  70. })
  71. it('logout from another login cannot lock the session that unlocked the vault',async()=>{
  72. const f=await fixture();f.grant();f.dispatch('logout',{},otherSession)
  73. expect(f.dispatch('status').unlocked).toBe(true);f.dispatch('logout');expect(f.dispatch('status').unlocked).toBe(false)
  74. })
  75. it.each(['lock','logout','reconnect','update','remove','expiry','close','connection'] as const)('never saves a late first-use host pin after %s',async cause=>{
  76. const started=Promise.withResolvers<void>(),release=Promise.withResolvers<string>();let signal:AbortSignal|undefined
  77. const f=await fixture({mode:'local-adapters',execute:async()=>{},prepareSshHost:async(_target,s)=>{signal=s;started.resolve();return release.promise}})
  78. const target={kind:'ssh',host:'ssh.example.test',port:22,auth:'password'},entry=f.dispatch('add',{name:'dummy ssh',username:'dummy',target,secret})
  79. const connection=new AbortController(),run=f.prepare({id:entry.id,revision:entry.revision},session,owner,connection.signal),result=expect(run).rejects.toMatchObject({code:'vault_host_probe_failed'})
  80. await started.promise
  81. if(cause==='lock'||cause==='logout')f.dispatch(cause)
  82. if(cause==='reconnect')f.reconnect()
  83. if(cause==='update')f.dispatch('update',{id:entry.id,entry:{name:'changed',username:'dummy',target,revision:1}})
  84. if(cause==='remove')f.dispatch('remove',{id:entry.id})
  85. if(cause==='expiry'){f.advance(300000);f.dispatch('status')}
  86. if(cause==='close')f.broker.close()
  87. if(cause==='connection')connection.abort()
  88. expect(signal?.aborted).toBe(true);release.resolve('SHA256:'+'A'.repeat(43));await result
  89. f.dispatch('unlock',{password});expect(f.dispatch('status').entries.some((e:any)=>e.id===entry.id&&e.target.hostKey)).toBe(false)
  90. })
  91. it('refuses symlink audit sinks and leaves their destination unchanged',async()=>{
  92. const f=await fixture(),path=join(f.home,'audit.jsonl'),destination=join(f.home,'dummy-destination')
  93. writeFileSync(destination,'unchanged');rmSync(path);symlinkSync(destination,path)
  94. expect(()=>f.dispatch('backup')).toThrow();expect(readFileSync(destination,'utf8')).toBe('unchanged')
  95. })
  96. const invalidReceipts=[undefined,{status:'complete',operation:'website.login',secret},{status:'complete',operation:'ssh.exec'}]
  97. it.each((['protected-adapters','local-adapters'] as const).flatMap(mode=>invalidReceipts.map(receipt=>({mode,receipt}))))('never treats missing, raw or scope-mismatched $mode receipts as success',async({mode,receipt})=>{
  98. const f=await fixture({mode,execute:async()=>receipt as any}),lease=f.grant()
  99. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_operation_uncertain'})
  100. expect(f.dispatch('status').leases).toHaveLength(0)
  101. expect(readFileSync(join(f.home,'audit.jsonl'),'utf8')).not.toContain(secret)
  102. })
  103. it('revocation of deployment approval locks and cancels the current operation instead of downgrading its executor',async()=>{
  104. let approved=true,signal:AbortSignal|undefined
  105. const started=Promise.withResolvers<void>(),finish=Promise.withResolvers<void>()
  106. const f=await fixture({mode:'protected-adapters',assertAvailable(){if(!approved)throw new Error('revoked')},execute:async(_e,_i,s)=>{signal=s;started.resolve();await finish.promise;return {status:'complete',operation:'website.login'}}}),lease=f.grant()
  107. const pending=f.execute(lease.id),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'});await started.promise
  108. approved=false;expect(f.dispatch('status')).toMatchObject({unlocked:false,execution:'disabled',reason:'isolation_required',leases:[]})
  109. expect(signal?.aborted).toBe(true);finish.resolve();await assertion
  110. })