miniLockLib/EncryptOperation.coffee

module.exports = class EncryptOperation
  NaCl = require "tweetnacl"
  NaCl.stream = require("nacl-stream").stream
  BLAKE2s = require "./BLAKE2s"
  ID = require "./ID"
  {numberToByteArray} = require "./util"
  Blob = window?.Blob or require "./Blob"

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

  constructor: (params={})->
    {@data, @keys, @name, @type, @time, @miniLockIDs, @version, @callback} = params
    @version = 1 if @version is undefined
    @ephemeral = NaCl.box.keyPair()
    @fileKey = NaCl.randomBytes(32)
    @fileNonce = NaCl.randomBytes(24).subarray(0, 16)
    @hash = new BLAKE2s(length: 32)
    @ciphertextBytes = []
    @start() if params.start?

  start: (callback) =>
    @callback = callback if callback?
    if @callback?.constructor isnt Function
      throw "Can’t start encrypt operation without callback function."
    switch
      when (@data instanceof Blob) is no
        @callback "Can’t encrypt without a Blob of data."
      when (@keys?.publicKey is undefined) or (@keys?.secretKey is undefined)
        @callback "Can’t encrypt without a set of keys."
      when (@miniLockIDs instanceof Array) is no
        @callback "Can’t encrypt without an Array of miniLock IDs."
      when @name and @name.length > 256
        @callback "Can’t encrypt because file name is too long. 256-characters max please."
      when @type and @type.length > 128
        @callback "Can’t encrypt because media type is too long. 128-characters max please."
      when (@version in [1, 2]) is no
        @callback "Can’t encrypt because version #{@version} is not supported. Version 1 or 2 please."
      else
        @startedAt = Date.now()
        @time = @startedAt if @time is undefined
        @run()
    return this

  run: ->
    @encryptAttributes(@version)
    @encryptData 0, (error, dataWasEncrypted) =>
      if dataWasEncrypted?
        @constructHeader()
        fileFormat = [
          "miniLock"
          @sizeOfHeaderIn4Bytes
          @headerJSONBytes
          @ciphertextBytes...
        ]
        @end(error, new Blob fileFormat, type: "application/minilock")
      else
        @end(error)

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

  oncomplete: (blob) ->
    @callback(undefined, {
      data: blob
      name: @name + ".minilock"
      type: @type
      time: @time
      senderID: ID.encode(@keys.publicKey)
      duration: @duration
      startedAt: @startedAt
      endedAt: @endedAt
    })

  onerror: (error) ->
    @callback(error)

  encryptAttributes: (version) ->
    @constructStreamEncryptor()
    bytes = switch version
      when 1 then new Uint8Array 256
      when 2 then new Uint8Array 256+128+24
      else throw "EncryptOperation does not support version #{version}. Version 1 or 2 please."
    bytes.set @fixedSizeDecodedName(), 0
    bytes.set @fixedSizeDecodedType(), 256 if version is 2
    bytes.set @fixedSizeDecodedTime(), 256+128 if version is 2
    if encryptedBytes = @streamEncryptor.encryptChunk(bytes, no)
      @hash.update(encryptedBytes)
      @ciphertextBytes.push(encryptedBytes)
    else
      throw "EncryptOperation failed to record file attributes."

  encryptData: (position, callback) ->
    @constructStreamEncryptor()
    @readSliceOfData position, position+@chunkSize, (error, sliceOfBytes) =>
      if error then return callback(error)
      isLastSlice = position+sliceOfBytes.length is @data.size
      if encryptedBytes = @streamEncryptor.encryptChunk(sliceOfBytes, isLastSlice)
        @hash.update(encryptedBytes)
        @ciphertextBytes.push(encryptedBytes)
        if isLastSlice
          @hash.digest()
          callback(undefined, @hash.isFinished)
        else
          @encryptData(position+@chunkSize, callback)
      else
        callback "Failed to encrypt slice of data at [#{position}..#{position+@chunkSize}]"

  constructHeader: ->
    @header =
      version: @version
      ephemeral: NaCl.util.encodeBase64(@ephemeral.publicKey)
      decryptInfo: @encodedEncryptedPermits()
    headerJSON = JSON.stringify(@header)
    @sizeOfHeaderIn4Bytes = numberToByteArray(headerJSON.length)
    @headerJSONBytes = NaCl.util.decodeUTF8(headerJSON)
    return @header

  constructStreamEncryptor: ->
    @streamEncryptor ?= NaCl.stream.createEncryptor(@fileKey, @fileNonce, @chunkSize)

  fixedSizeDecodedName: ->
    fixedSize = new Uint8Array(256)
    if @name
      decodedName = NaCl.util.decodeUTF8(@name)
      if decodedName.length > fixedSize.length
        throw "Can’t set fixed size decoded name because input is too long."
      fixedSize.set(decodedName)
    return fixedSize

  fixedSizeDecodedType: ->
    fixedSize = new Uint8Array(128)
    if @type
      decodedType = NaCl.util.decodeUTF8(@type)
      if decodedType.length > fixedSize.length
        throw "Can’t set fixed size decoded type because input is too long."
      fixedSize.set(decodedType)
    return fixedSize

  fixedSizeDecodedTime: ->
    fixedSize = new Uint8Array(24)
    if @time
      timestamp = (new Date(@time)).toJSON()
      fixedSize.set(NaCl.util.decodeUTF8(timestamp))
    return fixedSize

  encodedEncryptedPermits: ->
    permits = {}
    for miniLockID in @miniLockIDs
      [uniqueNonce, encryptedPermit] = @encryptedPermit(miniLockID)
      encodedUniqueNonce = NaCl.util.encodeBase64(uniqueNonce)
      encodedEncryptedPermit = NaCl.util.encodeBase64(encryptedPermit)
      permits[encodedUniqueNonce] = encodedEncryptedPermit
    return permits

  encryptedPermit: (miniLockID) ->
    [uniqueNonce, permit] = @permit(miniLockID)
    decodedPermitJSON = NaCl.util.decodeUTF8(JSON.stringify(permit))
    recipientPublicKey = ID.decode(miniLockID)
    encryptedPermit = NaCl.box(decodedPermitJSON, uniqueNonce, recipientPublicKey, @ephemeral.secretKey)
    [uniqueNonce, encryptedPermit]

  permit: (miniLockID) ->
    uniqueNonce = NaCl.randomBytes(24)
    [uniqueNonce, {
      senderID: ID.encode(@keys.publicKey)
      recipientID: miniLockID
      fileInfo: NaCl.util.encodeBase64(@encryptedFileInfo(miniLockID, uniqueNonce))
    }]

  encryptedFileInfo: (miniLockID, uniqueNonce) ->
    decodedFileInfoJSON = NaCl.util.decodeUTF8(JSON.stringify(@permitFileInfo()))
    recipientPublicKey = ID.decode(miniLockID)
    NaCl.box(decodedFileInfoJSON, uniqueNonce, recipientPublicKey, @keys.secretKey)

  permitFileInfo: ->
    fileKey:   NaCl.util.encodeBase64(@fileKey)
    fileNonce: NaCl.util.encodeBase64(@fileNonce)
    fileHash:  NaCl.util.encodeBase64(@hash.digest())