credentialProtocols.ts 7.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  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 ssh2 from 'ssh2'
  9. import type {Connection,utils} from 'ssh2'
  10. const {Server,utils:keyUtils}=ssh2
  11. export async function websiteFixture(home:string){
  12. const key=join(home,'dummy-key.pem'),cert=join(home,'dummy-cert.pem'),cnf=join(home,'dummy-openssl.cnf')
  13. 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')
  14. execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',cnf],{stdio:'ignore'})
  15. const username='dummy-site-user',secret='DUMMY-local-only-password',cookie=randomBytes(32).toString('hex')
  16. const submitted=Promise.withResolvers<void>()
  17. const state={logins:0,proofs:0,logouts:0,loginViews:0,formAction:'/login',iframe:false,redirect:'/account',delay:false,requests:[] as string[]}
  18. const server=createServer({key:readFileSync(key),cert:readFileSync(cert)},async(req,res)=>{
  19. state.requests.push(req.method+' '+req.url)
  20. 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}
  21. if(req.url==='/login'&&req.method==='POST'){
  22. let body='';for await(const c of req)body+=c
  23. const form=new URLSearchParams(body)
  24. if(form.get('username')!==username||form.get('password')!==secret){res.writeHead(401);res.end();return}
  25. state.logins++;submitted.resolve();if(state.delay)return
  26. res.setHeader('Set-Cookie',`fixture_auth=${cookie}; HttpOnly; Secure; SameSite=Strict`);res.writeHead(303,{Location:state.redirect});res.end();return
  27. }
  28. 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}
  29. 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}
  30. res.writeHead(401);res.end()
  31. })
  32. server.listen(0,'127.0.0.1');await once(server,'listening')
  33. return {state,submitted:submitted.promise,username,secret,cookie,cert:readFileSync(cert),origin:'https://127.0.0.1:'+(server.address() as any).port,
  34. usage:{kind:'website.form' as const,loginPath:'/login',submitPath:'/login',successPath:'/account',logoutPath:'/logout',formId:'login',usernameName:'username',passwordName:'password',successSelector:'#login-success'},
  35. close:async()=>{server.closeAllConnections();await new Promise<void>(r=>server.close(()=>r()))}}
  36. }
  37. function dummySshKey(){
  38. // ssh2 1.17.0 strips leading zero bytes when serializing Ed25519 public
  39. // material. Validate generated dummy input, never retry an actual operation.
  40. for(let attempt=0;attempt<8;attempt++){
  41. const pair=keyUtils.generateKeyPairSync('ed25519')
  42. if(!(keyUtils.parseKey(pair.private) instanceof Error)&&!(keyUtils.parseKey(pair.public) instanceof Error))return pair
  43. }
  44. throw new Error('Unable to generate a valid dummy SSH key')
  45. }
  46. export async function sshFixture(auth: 'privateKey' | 'password' = 'privateKey',port=0){
  47. const host=dummySshKey(),key=dummySshKey(),parsed=keyUtils.parseKey(key.public) as utils.ParsedKey
  48. const fingerprint='SHA256:'+createHash('sha256').update((keyUtils.parseKey(host.private) as utils.ParsedKey).getPublicSSH()).digest('base64').replace(/=+$/,'')
  49. const clients=new Set<Connection>(),files=new Map<string,Buffer>([['/approved/input.txt',Buffer.from('DUMMY-private-file-contents')]])
  50. const executed=Promise.withResolvers<void>(),written=Promise.withResolvers<void>()
  51. const password='DUMMY-ssh-login-password-only'
  52. const state={auths:0,authMethods:[] as string[],commands:[] as string[],opens:[] as {path:string,flags:number,mode:number}[],writes:0,delayWrite:false,delayExec:false,readRedirect:false,
  53. execReplies:new Map<string,{stdout:string;stderr?:string;exitCode?:number}>()}
  54. const server=new Server({hostKeys:[host.private]},client=>{
  55. clients.add(client);client.on('error',()=>{});client.on('close',()=>clients.delete(client))
  56. client.on('authentication',ctx=>{
  57. state.authMethods.push(ctx.method)
  58. const valid=ctx.username==='dummy-ssh-user'&&(auth==='password'
  59. ? ctx.method==='password'&&ctx.password===password
  60. : ctx.method==='publickey'&&ctx.key.data.equals(parsed.getPublicSSH())&&(!ctx.signature||parsed.verify(ctx.blob!,ctx.signature)===true))
  61. if(!valid){ctx.reject([auth==='password'?'password':'publickey']);return}
  62. state.auths++;ctx.accept()
  63. }).on('ready',()=>client.on('session',accept=>{
  64. const session=accept()
  65. session.on('exec',(accept,_reject,info)=>{state.commands.push(info.command);const stream=accept();executed.resolve();if(state.delayExec)return;const reply=state.execReplies.get(info.command);stream.write(reply?.stdout??'DUMMY-private-command-output');stream.stderr.write(reply?.stderr??'DUMMY-private-stderr');stream.exit(reply?.exitCode??0);stream.end()})
  66. session.on('sftp',accept=>{
  67. const s=accept(),handles=new Map<string,string>();let id=0
  68. s.on('error',()=>{})
  69. s.on('REALPATH',(req,path)=>s.name(req,[{filename:state.readRedirect&&path==='/approved/input.txt'?'/outside/private.txt':path,longname:path,attrs:{}}]))
  70. s.on('OPEN',(req,path,flags,attrs)=>{
  71. state.opens.push({path,flags,mode:attrs.mode})
  72. if(!path.startsWith('/approved/')||(flags&32)&&files.has(path)){s.status(req,4);return}
  73. if(flags&8)files.set(path,Buffer.alloc(0))
  74. if(!files.has(path)){s.status(req,2);return}
  75. const handle=Buffer.from(String(++id));handles.set(handle.toString(),path);s.handle(req,handle)
  76. })
  77. 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})})
  78. 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))})
  79. s.on('WRITE',(req,handle,offset,data)=>{
  80. const path=handles.get(handle.toString());if(!path){s.status(req,4);return}
  81. 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)
  82. written.resolve()
  83. if(!state.delayWrite)s.status(req,0)
  84. })
  85. s.on('CLOSE',(req,handle)=>{handles.delete(handle.toString());s.status(req,0)})
  86. })
  87. }))
  88. })
  89. server.listen(port,'127.0.0.1');await once(server,'listening')
  90. return {state,files,executed:executed.promise,written:written.promise,username:'dummy-ssh-user',secret:auth==='password'?password:key.private,target:{kind:'ssh' as const,host:'127.0.0.1',port:(server.address() as any).port,hostKey:fingerprint,...(auth==='password'?{auth}:{})},
  91. close:async()=>{for(const client of clients)client.end();await new Promise<void>(r=>server.close(()=>r()))}}
  92. }