qwgit / lib / local-certs.js
http{/,s} git server
git clone http://git.nthia.dev/qwgit

const fs = require('fs')
const path = require('path')
const EventEmitter = require('events')
const { nextTick } = process
const { X509Certificate } = require('crypto')
const mkcert = require('./openssl-mkcert.js')
module.exports = Certs

function Certs(opts) {
  if (!(this instanceof Certs)) return new Certs(opts)
  EventEmitter.call(this)
  this._datadir = opts.dir
}
Certs.prototype = Object.create(EventEmitter.prototype)

Certs.prototype.get = function (name, file, cb) {
  if (!validName(name)) return nextTick(cb, new Error('invalid cert name'))
  if (/\.\.|[\/:]/.test(file)) return nextTick(cb, new Error('invalid cert file'))
  fs.readFile(path.join(this._datadir, name, file), (err,data) => {
    if (err && err.code === 'ENOENT') cb(null, null)
    else if (err) cb(err)
    else cb(null, data)
  })
}

Certs.prototype.getAllFiles = function (name, cb) {
  if (!validName(name)) return nextTick(cb, new Error('invalid cert name'))
  let dir = path.join(this._datadir, name)
  let out = {}
  fs.readdir(dir, (err,files) => {
    if (err && err.code === 'ENOENT') return cb(null, null)
    if (err) return cb(err)
    let pending = 1
    files.forEach(file => {
      if (!/\.pem$/.test(file)) return
      if (/^\./.test(file)) return
      pending++
      let key = path.basename(file).replace(/\.pem$/,'')
      fs.readFile(path.join(dir,file), (err,src) => {
        if (err) {
          let f = cb
          cb = noop
          return f(err)
        }
        out[key] = src
        if (--pending === 0) cb(null, out)
      })
    })
    if (--pending === 0) cb(null, out)
  })
}

Certs.prototype.getFingerprint = function (name, file, cb) {
  this.get(name, file, (err,src) => {
    if (err) return cb(err)
    let fp = new X509Certificate(src)?.fingerprint512
    if (!fp) return cb(null, null)
    else cb(null, fp.toLowerCase().replace(/:/g,''))
  })
}

Certs.prototype.create = function (name, cb) {
  if (!validName(name)) return nextTick(cb, new Error('cert name contains invalid characters'))
  let dir = path.join(this._datadir, name)
  fs.mkdir(dir, { recursive: true }, err => {
    if (err) cb(err)
    else mkcert(dir, cb)
  })
}

Certs.prototype.rename = function (prevName, newName, cb) {
  if (!validName(prevName)) return nextTick(cb, new Error('previous cert name contains invalid characters'))
  if (!validName(newName)) return nextTick(cb, new Error('new cert name contains invalid characters'))
  let src = path.join(this._datadir, prevName)
  let dst = path.join(this._datadir, newName)
  fs.rename(src, dst, cb)
}

Certs.prototype.remove = function (name, cb) {
  if (!validName(name)) return nextTick(cb, new Error('cert name contains invalid characters'))
  fs.rm(path.join(this._datadir,name), { recursive: true }, cb)
}

Certs.prototype.list = function (cb) {
  let self = this
  fs.readdir(this._datadir, (err,files) => {
    if (err && err.code !== 'ENOENT') return cb(err)
    files ??= []
    let list = []
    ;(function next(i) {
      if (i >= files.length) return cb(null, list)
      fs.stat(path.join(self._datadir, files[i], 'cert.pem'), (err,s) => {
        if (s) list.push(files[i])
        next(i+1)
      })
    })(0)
  })
}

Certs.prototype.import = function (opts, cb) {
  let self = this
  let name = opts.name
  let files = opts.files
  let link = opts.link ?? false
  let dir = path.join(this._datadir, name)
  let certSources = []
  let next = function next(i) {
    if (i >= files.length) return importFiles()
    fs.stat(files[i], (err,s) => {
      if (err) return cb(err)
      if (s.isDirectory()) {
        fs.readdir(files[i], (err,list) => {
          if (err) return cb(err)
          ;(function nextFile(j) {
            if (j >= list.length) return next(i+1)
            if (/^\./.test(list[j])) return nextFile(j+1)
            let file = path.join(files[i],list[j])
            fs.readFile(file, 'utf8', (err,src) => {
              if (err) return cb(err)
              pushCert(certSources, file, src)
              nextFile(j+1)
            })
          })(0)
        })
      } else {
        fs.readFile(file, 'utf8', (err,src) => {
          if (err) return cb(err)
          pushCert(certSources, file, src)
          next(i+1)
        })
      }
    })
  }
  fs.mkdir(dir, { recursive: true }, err => {
    if (err) cb(err)
    else next(0)
  })
  function importFiles() {
    let files = new Map
    let bestChain = -1
    for (let i = 0; i < certSources.length; i++) {
      let c = certSources[i]
      if (c.type === 'private key' && files.has('key.pem')) {
        return cb(new Error('multiple private keys provided'))
      } else if (c.type === 'private key') {
        files.set('key.pem', c)
      } else if (c.type === 'certificate' && c.certs.length === 1) {
        files.set('cert.pem', c)
      } else if (c.type === 'certificate' && c.certs.length > 1 && bestChain < c.certs.length) {
        files.set('ca.pem', c)
        bestChain = c.certs.length
      }
    }
    let keys = Array.from(files.keys())
    ;(function next(i) {
      if (i >= keys.length) return cb(null, keys)
      let c = files.get(keys[i])
      let dst = path.join(self._datadir, name, keys[i])
      if (link) {
        fs.symlink(c.file, dst, err => {
          if (err) cb(err)
          else next(i+1)
        })
      } else {
        fs.writeFile(dst, c.source, err => {
          if (err) cb(err)
          else next(i+1)
        })
      }
    })(0)
  }
  function pushCert(out, file, src) {
    let type = parseCertType(src)
    let c = {
      file,
      source: src,
      type,
    }
    if (type === 'certificate') c.certs = parseCerts(src)
    out.push(c)
  }
}

function validName(name) {
  return name.length > 0 && !/\.\.|[\/:\s\0]/.test(name) && name !== '.'
}

function parseCerts(src) {
  if (!src) return null
  let r = /(?<=^|\n)(-{5}BEGIN ([^-]+)-{5}\r?\n[^]*?\r?\n-{5}END \2-{5}\r?\n)/g
  if (typeof src !== 'string') src = src.toString()
  return src.match(r).map(c => new X509Certificate(c))
}

function parseCertType(src) {
  let m = /^-{5}\s*BEGIN\s+(.*)\s*-{5}\s*\r?\n/.exec(src)
  return m ? m[1].toLowerCase() : null
}
function noop() {}