meyfa/giantdb-crypto

View on GitHub
src/index.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import crypto, { CipherKey } from 'node:crypto'
import { Readable, Writable } from 'node:stream'
import { Middleware, MiddlewareNextFn } from 'giantdb'

/**
 * Subset of options as required by this middleware.
 */
export interface EncryptionOptions {
  encryption: {
    key: CipherKey
  }
}

/**
 * Determine if the given thing can be indexed like a string-keyed record.
 *
 * @param thing The thing to test.
 * @returns Whether the given thing is a record.
 */
function isRecord (thing: unknown): thing is Record<string, unknown> {
  return thing != null && typeof thing === 'object'
}

/**
 * Determine if the given thing looks like valid encryption options.
 *
 * @param options The potential options.
 * @returns Whether the parameter contains expected encryption options.
 */
function isEncryptionOptions (options: unknown): options is EncryptionOptions {
  return isRecord(options) && isRecord(options.encryption) && options.encryption.key != null
}

/**
 * Given a previous metadata object, store an IV value on it (or create a new object if needed).
 *
 * @param metadata The previous metadata.
 * @param iv The value to store.
 * @returns The resulting metadata object (may be a new instance, or may be the modified parameter).
 */
function storeIV (metadata: unknown, iv: Buffer): object {
  const str = iv.toString('base64')
  const meta = isRecord(metadata) ? metadata : {}
  if (isRecord(meta.encryption)) {
    meta.encryption.iv = str
  } else {
    meta.encryption = { iv: str }
  }
  return meta
}

/**
 * Try to retrieve an IV value from the given item metadata.
 * If IV not found, this will return undefined.
 *
 * @param metadata The metadata.
 * @returns Undefined if IV invalid, otherwise the IV buffer.
 */
function retrieveIV (metadata: unknown): Buffer | undefined {
  if (!isRecord(metadata) || !isRecord(metadata.encryption) || typeof metadata.encryption.iv !== 'string') {
    return undefined
  }
  return Buffer.from(metadata.encryption.iv, 'base64')
}

/**
 * The crypto middleware class. Instantiate this class and use it on a GiantDB store.
 */
export class GiantDBCrypto implements Middleware {
  /**
   * Transforms the given readable stream, putting a decryption layer in between.
   * next is called with the modified state.
   *
   * @param stream The base Readable Stream.
   * @param meta The item metadata.
   * @param options The user-provided options object.
   * @param next The continuation callback.
   */
  transformReadable (stream: Readable, meta: object, options: object | undefined | null, next: MiddlewareNextFn<Readable>): void {
    // can only decrypt if options are specified correctly, and metadata is complete
    if (!isEncryptionOptions(options)) {
      next(new Error('missing or invalid encryption options, key not found during item read'))
      return
    }

    // get key from options and initialization vector from item's metadata
    const key = options.encryption.key
    const iv = retrieveIV(meta)
    if (iv == null) {
      next(new Error('unable to retrieve encryption metadata for item'))
      return
    }

    const decipher = crypto.createDecipheriv('aes192', key, iv)
    stream.pipe(decipher)

    next(undefined, {
      stream: decipher
    })
  }

  /**
   * Transforms the given writable stream, putting an encryption layer in between.
   * next is called with the modified state.
   *
   * @param stream The base Writable Stream.
   * @param meta The item metadata.
   * @param options The user-provided options object.
   * @param next The continuation callback.
   */
  transformWritable (stream: Writable, meta: object, options: object | undefined | null, next: MiddlewareNextFn<Writable>): void {
    if (!isEncryptionOptions(options)) {
      next(new Error('missing or invalid encryption options, key not found during item write'))
      return
    }

    // get key from options and generate new initialization vector for this item
    const key = options.encryption.key
    const iv = crypto.randomBytes(16)

    const newMeta = storeIV(meta, iv)
    const cipher = crypto.createCipheriv('aes192', key, iv)
    cipher.pipe(stream)

    next(undefined, {
      metadata: newMeta,
      stream: cipher
    })
  }
}