credentialVaultAdapters.test.ts 8.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. // @vitest-environment node
  2. import {afterEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,rmSync,writeFileSync,chmodSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {randomUUID,createHash} from 'node:crypto'
  7. import {websiteFixture,sshFixture} from '../fixtures/credentialProtocols'
  8. import {ProtectedCredentialExecutor} from '../../src/server/credentialVault/protectedExecutor'
  9. import {OperatorIsolationGate} from '../../src/server/credentialVault/isolation'
  10. import {parse,entryInput,type VaultEntry,type LeaseInput} from '../../src/server/credentialVault/schema'
  11. const homes:string[]=[],cleanup:(()=>Promise<void>)[]=[]
  12. const gate={assert(){}} // Dummy wiring validates protocols, never OS isolation.
  13. function home(){const h=mkdtempSync(join(tmpdir(),'yaoyao-vault-adapters-'));homes.push(h);return h}
  14. function entry(value:unknown):VaultEntry{return {...parse(entryInput,value),id:randomUUID(),revision:1,updatedAt:Date.now()}}
  15. function input(e:VaultEntry,operation:LeaseInput['operation']):LeaseInput{return {credentialRef:e.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy',target:e.target,operation,seconds:60}}
  16. afterEach(async()=>{for(const close of cleanup.splice(0))await close();for(const h of homes.splice(0))rmSync(h,{recursive:true,force:true})})
  17. it('a protected standard form authenticates, verifies the marker, logs out and returns no secret/session/browser output',async()=>{
  18. const h=home(),site=await websiteFixture(h);cleanup.push(site.close)
  19. const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage})
  20. const executor=new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert)
  21. expect(await executor.execute(e,input(e,'website.login'),new AbortController().signal)).toEqual({status:'complete',operation:'website.login'})
  22. expect(site.state).toMatchObject({logins:1,proofs:1,logouts:1})
  23. // Fresh context cannot reuse the previous session.
  24. expect(await executor.execute(e,input(e,'website.login'),new AbortController().signal)).toEqual({status:'complete',operation:'website.login'})
  25. expect(site.state).toMatchObject({logins:2,proofs:2,logouts:2})
  26. },20000)
  27. it.each(['action','iframe','redirect'] as const)('rejects unapproved website %s and never exports a session',async cause=>{
  28. const h=home(),site=await websiteFixture(h);cleanup.push(site.close)
  29. if(cause==='action')site.state.formAction='https://127.0.0.1:1/steal'
  30. if(cause==='iframe')site.state.iframe=true
  31. if(cause==='redirect')site.state.redirect='https://127.0.0.1:1/steal'
  32. const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage})
  33. const result=await new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert).execute(e,input(e,'website.login'),new AbortController().signal)
  34. expect(result).toMatchObject({status:'manual_takeover_required',reason:'unsupported_form_flow'})
  35. expect(site.state.logins).toBe(cause==='redirect'?1:0);expect(site.state.proofs).toBe(0)
  36. expect(JSON.stringify(result)).not.toContain(site.secret)
  37. },15000)
  38. it('cancelling a submitted website login closes its private context and leaves an unknown result without retry/logout',async()=>{
  39. const h=home(),site=await websiteFixture(h);cleanup.push(site.close);site.state.delay=true
  40. const e=entry({name:'dummy',username:site.username,secret:site.secret,target:{kind:'website',origin:site.origin},usage:site.usage}),controller=new AbortController()
  41. const run=new ProtectedCredentialExecutor(join(h,'browser'),gate,site.cert).execute(e,input(e,'website.login'),controller.signal),result=expect(run).rejects.toThrow()
  42. await vi.waitFor(()=>expect(site.state.logins).toBe(1));controller.abort();await result
  43. expect(site.state.logins).toBe(1);expect(site.state.logouts).toBe(0)
  44. },15000)
  45. it('uses pinned public-key SSH, a fixed executable and bounded exact SFTP paths; receipts omit stdout and file contents',async()=>{
  46. const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
  47. const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target}
  48. const command=entry({...base,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
  49. expect(await executor.execute(command,input(command,'ssh.exec'),new AbortController().signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
  50. expect(ssh.state.commands).toEqual(['/usr/bin/uptime'])
  51. const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:64}}),original=ssh.files.get('/approved/input.txt')!
  52. expect(await executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).toEqual({status:'complete',operation:'sftp.read',bytes:original.length,sha256:createHash('sha256').update(original).digest('hex')})
  53. const write=entry({...base,usage:{kind:'sftp.write',remotePath:'/approved/output.txt',contents:'DUMMY-fixed-encrypted-transfer-body'}})
  54. const receipt=await executor.execute(write,input(write,'sftp.write'),new AbortController().signal)
  55. expect(receipt).toMatchObject({status:'complete',operation:'sftp.write',bytes:Buffer.byteLength('DUMMY-fixed-encrypted-transfer-body')});expect(JSON.stringify(receipt)).not.toContain('DUMMY')
  56. expect(ssh.files.get('/approved/output.txt')?.toString()).toBe('DUMMY-fixed-encrypted-transfer-body')
  57. expect(ssh.state.opens.at(-1)).toMatchObject({flags:58,mode:0o600})
  58. await expect(executor.execute(write,input(write,'sftp.write'),new AbortController().signal)).rejects.toThrow() // Never overwrite.
  59. },15000)
  60. it('rejects an unknown SSH host key before authentication, symlink targets and oversized files',async()=>{
  61. const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);const executor=new ProtectedCredentialExecutor(join(h,'executor'),gate)
  62. const base={name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target},e=entry({...base,target:{...ssh.target,hostKey:'SHA256:'+'A'.repeat(43)},usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
  63. await expect(executor.execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_key_rejected'});expect(ssh.state.auths).toBe(0)
  64. const read=entry({...base,usage:{kind:'sftp.read',remotePath:'/approved/input.txt',maxBytes:1}})
  65. await expect(executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).rejects.toMatchObject({code:'vault_usage_forbidden'})
  66. ssh.state.readRedirect=true
  67. await expect(executor.execute(read,input(read,'sftp.read'),new AbortController().signal)).rejects.toMatchObject({code:'vault_usage_forbidden'})
  68. expect(ssh.state.opens).toHaveLength(1)
  69. },15000)
  70. it('a malformed private key produces a rejected operation without unhandled connection errors',async()=>{
  71. const h=home(),ssh=await sshFixture();cleanup.push(ssh.close)
  72. const e=entry({name:'dummy',username:ssh.username,secret:'DUMMY-not-a-private-key',target:ssh.target,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
  73. await expect(new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'ssh.exec'),new AbortController().signal)).rejects.toThrow()
  74. expect(ssh.state.auths).toBe(0)
  75. })
  76. it('cancelling SFTP during a lost write acknowledgement terminates promptly and leaves the partial result for manual inspection',async()=>{
  77. const h=home(),ssh=await sshFixture();cleanup.push(ssh.close);ssh.state.delayWrite=true
  78. const e=entry({name:'dummy',username:ssh.username,secret:ssh.secret,target:ssh.target,usage:{kind:'sftp.write',remotePath:'/approved/partial.txt',contents:'DUMMY-only'}}),controller=new AbortController()
  79. const run=new ProtectedCredentialExecutor(join(h,'executor'),gate).execute(e,input(e,'sftp.write'),controller.signal),result=expect(run).rejects.toThrow()
  80. await vi.waitFor(()=>expect(ssh.state.writes).toBe(1));controller.abort();await result
  81. expect(ssh.files.get('/approved/partial.txt')?.toString()).toBe('DUMMY-only');expect(ssh.state.writes).toBe(1)
  82. },10000)
  83. it('requires private, unexpired operator approval and the actual distinct Hermes process identity; revocation cannot downgrade',()=>{
  84. const h=home(),path=join(h,'approval.json'),uid=process.getuid!(),now=Date.now()
  85. const approval={version:1,approved:true,brokerUid:uid,hermesUid:uid+1,hermesPid:123,approvedAt:now,expiresAt:now+60000,controls:['separate-identity','hermes-no-control-access','browser-private-no-cdp','restricted-egress']}
  86. const save=(v:unknown)=>writeFileSync(path,JSON.stringify(v),{mode:0o600}),check=()=>new OperatorIsolationGate(path,uid+1,123,()=>uid+1,()=>now).assert()
  87. expect(check).toThrowError(expect.objectContaining({code:'vault_isolation_required'}));save(approval);expect(check).not.toThrow()
  88. expect(()=>new OperatorIsolationGate(path,uid+1,123,()=>uid,()=>now).assert()).toThrow()
  89. save({...approval,expiresAt:now});expect(check).toThrow();save(approval);chmodSync(path,0o644);expect(check).toThrow()
  90. chmodSync(path,0o600);save({...approval,approved:false});expect(check).toThrow()
  91. })