miniLockLib/DecryptOperation.coffee

module.exports = class DecryptOperation
  NaCl = require "tweetnacl"
  NaCl.stream = require("nacl-stream").stream
  {encodeUTF8, decodeBase64} = NaCl.util
  ID = require "./ID"
  {byteArrayToNumber} = require "./util"
  Blob = window?.Blob or require "./Blob"

  chunkSize: 1024 * 1024
  readSliceOfData: require "./readSliceOfData"

  constructor: (params={}) ->
    {@data, @keys, @callback} = params
    @decryptedBytes = []
    @start() if params.start?

  start: (callback) =>
    @callback = callback if callback?
    if @callback?.constructor isnt Function
      throw "Can’t start decrypt operation without a callback function."
    switch
      when @data is undefined
        @callback "Can’t decrypt without a Blob of data."
      when @keys?.secretKey is undefined
        @callback "Can’t decrypt without a set of keys."
      else
        @startedAt = Date.now()
        @run()
    return this

  run: ->
    @readHeader (error, header, sizeOfHeader) =>
      @["decryptVersion#{header.version}Attributes"] (error, attributes, startOfEncryptedDataBytes) =>
        if error is undefined
          @decryptData startOfEncryptedDataBytes, (error, blob) =>
            @end(error, blob, attributes, header, sizeOfHeader)
        else
          @end(error, undefined, attributes, header, sizeOfHeader)

  end: (error, blob, attributes, header, sizeOfHeader) ->
    @streamDecryptor.clean() if @streamDecryptor?
    @endedAt = Date.now()
    @duration = @endedAt - @startedAt
    if error
      @onerror(error, header, sizeOfHeader)
    else
      @oncomplete(blob, attributes, header, sizeOfHeader)

  oncomplete: (blob, attributes, header, sizeOfHeader) ->
    @callback(undefined, {
      data: blob
      name: attributes.name
      type: attributes.type
      time: attributes.time
      senderID: @permit.senderID
      recipientID: @permit.recipientID
      fileKey: @permit.fileInfo.fileKey
      fileNonce: @permit.fileInfo.fileNonce
      fileHash: @permit.fileInfo.fileHash
      duration: @duration
      startedAt: @startedAt
      endedAt: @endedAt
    }, header, sizeOfHeader)

  onerror: (error, header, sizeOfHeader) ->
    @callback(error, undefined, header, sizeOfHeader)

  decryptVersion1Attributes: (callback) ->
    @constructMap (error, map) =>
      if error then return callback(error)
      @constructStreamDecryptor (error) =>
        if error then return callback(error)
        {ciphertextBytes} = map
        start = ciphertextBytes.start
        end   = ciphertextBytes.start+256+4+16
        @readSliceOfData start, end, (error, sliceOfBytes) =>
          if error then return callback(error)
          if decryptedBytes = @streamDecryptor.decryptChunk(sliceOfBytes, no)
            nameAsBytes = (byte for byte in decryptedBytes when byte isnt 0)
            attributes =
              name: encodeUTF8(nameAsBytes)
            callback undefined, attributes, end
          else
            callback "Failed to decrypt version 1 file attributes."

  decryptVersion2Attributes: (callback) ->
    @constructMap (error, map) =>
      if error then return callback(error)
      @constructStreamDecryptor (error) =>
        if error then return callback(error)
        {ciphertextBytes} = map
        start = ciphertextBytes.start
        end   = ciphertextBytes.start+256+128+24+4+16
        @readSliceOfData start, end, (error, sliceOfBytes) =>
          if error then return callback(error)
          if decryptedBytes = @streamDecryptor.decryptChunk(sliceOfBytes, no)
            decryptedNameBytes = decryptedBytes.subarray(0, 256)
            nameAsBytes = (byte for byte in decryptedNameBytes when byte isnt 0)
            decryptedTypeBytes = decryptedBytes.subarray(256, 256+128)
            typeAsBytes = (byte for byte in decryptedTypeBytes when byte isnt 0)
            decryptedTimeBytes = decryptedBytes.subarray(256+128, 256+128+24)
            timeAsBytes = (byte for byte in decryptedTimeBytes when byte isnt 0)
            attributes =
              name: encodeUTF8(nameAsBytes)
              type: encodeUTF8(typeAsBytes)
              time: encodeUTF8(timeAsBytes)
            callback undefined, attributes, end
          else
            callback "Failed to decrypt version 2 file attributes."

  decryptData: (position, callback) ->
    @constructStreamDecryptor (error) =>
      if error then return callback(error)
      startPosition = position
      endPosition   = position+@chunkSize+4+16
      @readSliceOfData startPosition, endPosition, (error, sliceOfBytes) =>
        isLast = position+sliceOfBytes.length is @data.size
        decryptedBytes = @streamDecryptor.decryptChunk(sliceOfBytes, isLast)
        if decryptedBytes
          @decryptedBytes.push(decryptedBytes)
          if isLast
            callback(undefined, new Blob @decryptedBytes)
          else
            @decryptData(endPosition, callback)
        else
          callback "Failed to decrypt slice of data at [#{startPosition}..#{endPosition}]"

  constructMap: (callback) ->
    @readHeader (error, header, sizeOfHeader) =>
      if (error is undefined) and sizeOfHeader?
        magicBytes        = {start: 0, end: 8}
        sizeOfHeaderBytes = {start: 8, end: 12}
        headerBytes       = {start: 12, end: 12+sizeOfHeader}
        ciphertextBytes   = {start: headerBytes.end, end: @data.size}
      callback error, {magicBytes, sizeOfHeaderBytes, headerBytes, ciphertextBytes}

  constructStreamDecryptor: (callback) ->
    @decryptUniqueNonceAndPermit (error, uniqueNonce, permit) =>
      if uniqueNonce and permit
        @uniqueNonce = uniqueNonce
        @permit = permit
        @fileKey = permit.fileInfo.fileKey
        @fileNonce = permit.fileInfo.fileNonce
        @streamDecryptor = NaCl.stream.createDecryptor(@fileKey, @fileNonce, @chunkSize)
        @constructStreamDecryptor = (callback) -> callback(undefined)
        @constructStreamDecryptor(callback)
      else
        callback(error)

  decryptUniqueNonceAndPermit: (callback) ->
    @readHeader (error, header) =>
      if error
        callback(error)
      else
        returned = @findUniqueNonceAndPermit(header)
        if returned
          [uniqueNonce, permit] = returned
          callback undefined, uniqueNonce, permit
        else
          callback "Can’t decrypt this file with this set of keys."

  findUniqueNonceAndPermit: (header) ->
    ephemeral = decodeBase64(header.ephemeral)
    for encodedUniqueNonce, encodedEncryptedPermit of header.decryptInfo
      uniqueNonce = decodeBase64(encodedUniqueNonce)
      decodedEncryptedPermit = decodeBase64(encodedEncryptedPermit)
      permit = @decryptPermit(decodedEncryptedPermit, uniqueNonce, ephemeral)
      if permit then return [uniqueNonce, permit]
    return undefined

  decryptPermit: (decodedEncryptedPermit, uniqueNonce, ephemeral) ->
    decryptedPermitAsBytes = NaCl.box.open(decodedEncryptedPermit, uniqueNonce, ephemeral, @keys.secretKey)
    if decryptedPermitAsBytes
      decryptedPermitAsString = encodeUTF8(decryptedPermitAsBytes)
      decryptedPermit = JSON.parse(decryptedPermitAsString)
      decodedEncryptedFileInfo = decodeBase64(decryptedPermit.fileInfo)
      senderPublicKey = ID.decode(decryptedPermit.senderID)
      decryptedPermit.fileInfo = @decryptFileInfo(decodedEncryptedFileInfo, uniqueNonce, senderPublicKey)
      return decryptedPermit
    else
      return undefined

  decryptFileInfo: (decodedEncryptedFileInfo, uniqueNonce, senderPublicKey) ->
    decryptedFileInfoAsBytes = NaCl.box.open(decodedEncryptedFileInfo, uniqueNonce, senderPublicKey, @keys.secretKey)
    if (decryptedFileInfoAsBytes)
      decryptedFileInfoAsString = encodeUTF8(decryptedFileInfoAsBytes)
      decryptedFileInfo = JSON.parse(decryptedFileInfoAsString)
      return {
        fileHash:  decodeBase64(decryptedFileInfo.fileHash)
        fileKey:   decodeBase64(decryptedFileInfo.fileKey)
        fileNonce: decodeBase64(decryptedFileInfo.fileNonce)
      }
    else
      return undefined

  readHeader: (callback) ->
    @readSizeOfHeader (error, sizeOfHeader) =>
      if error then return callback(error)
      @readSliceOfData 12, 12+sizeOfHeader, (error, sliceOfBytes) =>
        if error then return callback(error)
        headerAsString = encodeUTF8(sliceOfBytes)
        header = JSON.parse(headerAsString)
        callback(undefined, header, sizeOfHeader)

  readSizeOfHeader: (callback) ->
    @readSliceOfData 8, 12, (error, sliceOfBytes) =>
      if error then return callback(error)
      sizeOfHeader = byteArrayToNumber(sliceOfBytes)
      callback(error, sizeOfHeader)