credentialProtocols.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. // Local, ephemeral dummy protocols only; never contact an external account.
  2. import {createServer} from 'node:https'
  3. import {once} from 'node:events'
  4. import {execFileSync} from 'node:child_process'
  5. import {readFileSync,writeFileSync} from 'node:fs'
  6. import {join} from 'node:path'
  7. import {createHash,randomBytes} from 'node:crypto'
  8. import {Server,utils,type Connection} from 'ssh2'
  9. export async function websiteFixture(home:string){
  10. const key=join(home,'dummy-key.pem'),cert=join(home,'dummy-cert.pem'),cnf=join(home,'dummy-openssl.cnf')
  11. writeFileSync(cnf,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=127.0.0.1\n[ext]\nsubjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:TRUE\n')
  12. execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',cnf],{stdio:'ignore'})
  13. const username='dummy-site-user',secret='DUMMY-local-only-password',cookie=randomBytes(32).toString('hex')
  14. const submitted=Promise.withResolvers<void>()
  15. const state={logins:0,proofs:0,logouts:0,loginViews:0,formAction:'/login',iframe:false,redirect:'/account',delay:false,requests:[] as string[]}
  16. const server=createServer({key:readFileSync(key),cert:readFileSync(cert)},async(req,res)=>{
  17. state.requests.push(req.method+' '+req.url)
  18. if(req.url==='/login'&&req.method==='GET'){state.loginViews++;res.setHeader('Content-Type','text/html');res.end(`<form id="login" method="post" action="${state.formAction}"><input name="username"><input type="password" name="password"><button type="submit">Login</button></form>${state.iframe?'<iframe src="/frame"></iframe>':''}`);return}
  19. if(req.url==='/login'&&req.method==='POST'){
  20. let body='';for await(const c of req)body+=c
  21. const form=new URLSearchParams(body)
  22. if(form.get('username')!==username||form.get('password')!==secret){res.writeHead(401);res.end();return}
  23. state.logins++;submitted.resolve();if(state.delay)return
  24. res.setHeader('Set-Cookie',`fixture_auth=${cookie}; HttpOnly; Secure; SameSite=Strict`);res.writeHead(303,{Location:state.redirect});res.end();return
  25. }
  26. if(req.url==='/account'&&req.headers.cookie===`fixture_auth=${cookie}`){state.proofs++;res.setHeader('Content-Type','text/html');res.end('<div id="login-success">Dummy authenticated</div>');return}
  27. if(req.url==='/logout'&&req.headers.cookie===`fixture_auth=${cookie}`){state.logouts++;res.setHeader('Set-Cookie','fixture_auth=; Max-Age=0; HttpOnly; Secure; SameSite=Strict');res.end('Logged out');return}
  28. res.writeHead(401);res.end()
  29. })
  30. server.listen(0,'127.0.0.1');await once(server,'listening')
  31. return {state,submitted:submitted.promise,username,secret,cookie,cert:readFileSync(cert),origin:'https://127.0.0.1:'+(server.address() as any).port,
  32. usage:{kind:'website.form' as const,loginPath:'/login',submitPath:'/login',successPath:'/account',logoutPath:'/logout',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#login-success'},
  33. close:async()=>{server.closeAllConnections();await new Promise<void>(r=>server.close(()=>r()))}}
  34. }
  35. export async function sshFixture(){
  36. const host=utils.generateKeyPairSync('ed25519'),key=utils.generateKeyPairSync('ed25519'),parsed=utils.parseKey(key.public) as utils.ParsedKey
  37. const fingerprint='SHA256:'+createHash('sha256').update((utils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
  38. const clients=new Set<Connection>(),files=new Map<string,Buffer>([['/approved/input.txt',Buffer.from('DUMMY-private-file-contents')]])
  39. const executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
  40. const state={auths:0,commands:[] as string[],opens:[] as {path:string,flags:number,mode:number}[],writes:0,delayWrite:false,delayExec:false,readRedirect:false}
  41. const server=new Server({hostKeys:[host.private]},client=>{
  42. clients.add(client);client.on('error',()=>{});client.on('close',()=>clients.delete(client))
  43. client.on('authentication',ctx=>{
  44. if(ctx.method!=='publickey'||ctx.username!=='dummy-ssh-user'||!ctx.key.data.equals(parsed.getPublicSSH())||ctx.signature&&parsed.verify(ctx.blob!,ctx.signature)!==true){ctx.reject();return}
  45. state.auths++;ctx.accept()
  46. }).on('ready',()=>client.on('session',accept=>{
  47. const session=accept()
  48. session.on('exec',(accept,_reject,info)=>{state.commands.push(info.command);const stream=accept();executed.resolve();if(state.delayExec)return;stream.write('DUMMY-private-command-output');stream.stderr.write('DUMMY-private-stderr');stream.exit(0);stream.end()})
  49. session.on('sftp',accept=>{
  50. const s=accept(),handles=new Map<string,string>();let id=0
  51. s.on('error',()=>{})
  52. s.on('REALPATH',(req,path)=>s.name(req,[{filename:state.readRedirect&&path==='/approved/input.txt'?'/outside/private.txt':path,longname:path,attrs:{}}]))
  53. s.on('OPEN',(req,path,flags,attrs)=>{
  54. state.opens.push({path,flags,mode:attrs.mode})
  55. if(!path.startsWith('/approved/')||(flags&32)&&files.has(path)){s.status(req,4);return}
  56. if(flags&8)files.set(path,Buffer.alloc(0))
  57. if(!files.has(path)){s.status(req,2);return}
  58. const handle=Buffer.from(String(++id));handles.set(handle.toString(),path);s.handle(req,handle)
  59. })
  60. s.on('FSTAT',(req,handle)=>{const data=files.get(handles.get(handle.toString())??'');if(!data){s.status(req,4);return}s.attrs(req,{mode:0o100600,size:data.length,uid:1,gid:1,atime:1,mtime:1})})
  61. s.on('READ',(req,handle,offset,len)=>{const data=files.get(handles.get(handle.toString())??'');if(!data){s.status(req,4);return}if(offset>=data.length){s.status(req,1);return}s.data(req,data.subarray(offset,offset+len))})
  62. s.on('WRITE',(req,handle,offset,data)=>{
  63. const path=handles.get(handle.toString());if(!path){s.status(req,4);return}
  64. state.writes++;const old=files.get(path)!,next=Buffer.alloc(Math.max(old.length,offset+data.length));old.copy(next);data.copy(next,offset);files.set(path,next)
  65. written.resolve()
  66. if(!state.delayWrite)s.status(req,0)
  67. })
  68. s.on('CLOSE',(req,handle)=>{handles.delete(handle.toString());s.status(req,0)})
  69. })
  70. }))
  71. })
  72. server.listen(0,'127.0.0.1');await once(server,'listening')
  73. return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint},
  74. close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
  75. }