All files / src sealedResults.ts

100% Statements 32/32
100% Branches 13/13
100% Functions 6/6
100% Lines 31/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99                8x   8x 8x               8x     4x             4x   4x 2x     2x                     9x   3x             9x 2x     7x   7x 10x         9x 9x   6x 6x       1x       3x       9x 9x   9x 9x   9x   9x 9x   9x   3x    
import { createDecipheriv } from 'crypto'
import { inflateRaw } from 'zlib'
import { promisify } from 'util'
import { Event } from './types'
import { UnsealAggregateError, UnsealError } from './errors/unsealError'
import { toError } from './errors/toError'
import { Buffer } from 'buffer'
 
const asyncInflateRaw = promisify(inflateRaw)
 
export enum DecryptionAlgorithm {
  Aes256Gcm = 'aes-256-gcm',
}
 
export interface DecryptionKey {
  key: Buffer
  algorithm: DecryptionAlgorithm | `${DecryptionAlgorithm}`
}
 
const SEALED_HEADER = Buffer.from([0x9e, 0x85, 0xdc, 0xed])
 
function isEventResponse(data: unknown): data is Event {
  return typeof data === 'object' && data !== null && 'event_id' in data && 'timestamp' in data
}
 
/**
 * @private
 * */
export function parseEventsResponse(unsealed: string): Event {
  const json: unknown = JSON.parse(unsealed)
 
  if (!isEventResponse(json)) {
    throw new Error('Sealed data is not valid events response')
  }
 
  return json
}
 
/**
 * Decrypts the sealed response with the provided keys.
 * The SDK will try to decrypt the result with each key until it succeeds.
 * To learn more about sealed results visit: https://dev.fingerprint.com/docs/sealed-client-results
 * @throws UnsealAggregateError
 * @throws Error
 */
export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: DecryptionKey[]): Promise<Event> {
  const unsealed = await unseal(sealedData, decryptionKeys)
 
  return parseEventsResponse(unsealed)
}
 
/**
 * @private
 * */
export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[]) {
  if (sealedData.subarray(0, SEALED_HEADER.length).toString('hex') !== SEALED_HEADER.toString('hex')) {
    throw new Error('Invalid sealed data header')
  }
 
  const errors = new UnsealAggregateError([])
 
  for (const decryptionKey of decryptionKeys) {
    switch (decryptionKey.algorithm) {
      // Both cases are the same runtime value, but `algorithm` accepts the enum member and the
      // string literal as distinct types, so switch-exhaustiveness-check requires matching both.
      case DecryptionAlgorithm.Aes256Gcm:
      case 'aes-256-gcm':
        try {
          return await unsealAes256Gcm(sealedData, decryptionKey.key)
        } catch (e) {
          errors.addError(new UnsealError(decryptionKey, toError(e)))
          continue
        }
 
      default:
        throw new Error(`Unsupported decryption algorithm: ${String(decryptionKey.algorithm)}`)
    }
  }
 
  throw errors
}
 
async function unsealAes256Gcm(sealedData: Buffer, decryptionKey: Buffer) {
  const nonceLength = 12
  const nonce = sealedData.subarray(SEALED_HEADER.length, SEALED_HEADER.length + nonceLength)
 
  const authTagLength = 16
  const authTag = sealedData.subarray(-authTagLength)
 
  const ciphertext = sealedData.subarray(SEALED_HEADER.length + nonceLength, -authTagLength)
 
  const decipher = createDecipheriv('aes-256-gcm', decryptionKey, nonce).setAuthTag(authTag)
  const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()])
 
  const payload = await asyncInflateRaw(compressed)
 
  return payload.toString()
}