Source: protocol/codec.js

'use strict';
/**
 * @module lib/protocol/codec
 * @summary Whiteflag message encoding and decoding module
 * @description Module for Whiteflag message encoding and decoding
 * @tutorial modules
 * @tutorial protocol
 */
module.exports = {
    // Codec functions
    encode: encodeMessage,
    decode: decodeMessage,
    // Verification functions
    verifyFormat
};

// Node.js core and external modules //
const fs = require('fs');
const jsonValidate = require('jsonschema').validate;

// Whiteflag common functions and classes //
const array = require('../common/arrays');
const object = require('../common/objects');
const { type } = require('../common/protocol');
const { ProcessingError, ProtocolError } = require('../common/errors');

// Whiteflag modules //
const wfCrypto = require('./crypto');

// Module constants //
const BYTELENGTH = 8;
const BINENCODING = 'hex';
const wfMessageSchema = JSON.parse(fs.readFileSync('./static/protocol/message.schema.json'));

// MAIN MODULE FUNCTIONS //
/**
 * Checks if message formate validates against schema and updates metaheader accordingly
 * @function verifyFormat
 * @alias module:lib/protocol/codec.verifyFormat
 * @param {wfMessage} wfMessage a Whiteflag message
 * @param {function(Error, wfMessage)} callback function to be called upon completion
 */
function verifyFormat(wfMessage, callback) {
    // Check for metaheader
    if (!wfMessage.MetaHeader) {
        return callback(new ProtocolError('Missing metaheader', null, 'WF_METAHEADER_ERROR'), wfMessage);
    }
    // Validate message format
    let formatErrors = [];
    try {
        // Check against schema
        formatErrors = formatErrors.concat(
            array.pluck(jsonValidate(wfMessage, wfMessageSchema).errors, 'stack')
        );
        // Check field vaues
        formatErrors = array.addArray(formatErrors, verifyValues(wfMessage));
    } catch(err) {
        return callback(err, wfMessage);
    }
    // Check for format errors, update metaheader and return result
    if (formatErrors.length > 0) {
        wfMessage.MetaHeader.formatValid = false;
        return callback(new ProtocolError('Invalid message format', formatErrors, 'WF_FORMAT_ERROR'), wfMessage);
    }
    wfMessage.MetaHeader.formatValid = true;
    return callback(null, wfMessage);
}

/**
 * Encodes a json formatted message to binary format and updates metaheader accordingly
 * @function encodeMessage
 * @alias lib/protocol/codec.encode
 * @param {wfMessage} wfMessage a Whiteflag message
 * @param {function(Error, wfMessage)} callback function to be called upon completion
 */
function encodeMessage(wfMessage, callback) {
    // Validate message format
    verifyFormat(wfMessage, function encodeVerifyFormatCb(err, wfMessage) {
        if (err) return callback(err, wfMessage);

        // Split into message header and body
        const { MessageHeader: header, MessageBody: body } = wfMessage;

        // Encode message header (generic for all message types):
        // Create character string representing the binary encoding of the header
        let headerBinStr = (
            encodeUTF2BinStr(header.Prefix, 16) +
            encodeUTF2BinStr(header.Version, 8) +
            encodeUTF2BinStr(header.EncryptionIndicator, 8) +
            encodeBDX2BinStr(header.DuressIndicator, 1) +
            encodeUTF2BinStr(header.MessageCode, 8) +
            encodeBDX2BinStr(header.ReferenceIndicator, 4) +
            encodeBDX2BinStr(header.ReferencedMessage, 256)
        );
        // Encode message body (message type specific):
        // Create character string representing the binary encoding of the body
        let messageType = header.MessageCode;
        let encodingBody = body;
        let testBinStr = '';
        let bodyBinStr = '';
        let requestObjectsBinStr = '';

        // Check if test message
        if (header.MessageCode === 'T') {
            messageType = body.PseudoMessageCode;
            encodingBody = body.PseudoMessageBody;
            testBinStr = encodeUTF2BinStr(body.PseudoMessageCode, 8);
        }
        // Encode message body for each (pseudo) message type
        switch (messageType) {
            case 'A': {
                bodyBinStr = (
                    encodeBDX2BinStr(encodingBody.VerificationMethod, 4) +
                    encodeUTF2BinStr(encodingBody.VerificationData, 0)
                );
                break;
            }
            case 'K': {
                bodyBinStr = (
                    encodeBDX2BinStr(encodingBody.CryptoDataType, 8) +
                    encodeBDX2BinStr(encodingBody.CryptoData, (encodingBody.CryptoData.length * 4))
                );
                break;
            }
            case 'Q': {
                if (encodingBody.requestObjects) {
                    encodingBody.requestObjects.forEach(requestObject => {
                        requestObjectsBinStr = (
                            requestObjectsBinStr +
                            encodeBDX2BinStr(requestObject.ObjectType, 8) +
                            encodeBDX2BinStr(requestObject.ObjectTypeQuant, 8)
                        );
                    });
                }
            }
            // fallthrough //
            case 'P':
            case 'D':
            case 'S':
            case 'E':
            case 'I':
            case 'M': {
                bodyBinStr = (
                    encodeBDX2BinStr(encodingBody.SubjectCode, 8) +
                    encodeDatum2BinStr(encodingBody.DateTime) +
                    encodeDatum2BinStr(encodingBody.Duration) +
                    encodeBDX2BinStr(encodingBody.ObjectType, 8) +
                    encodeDatum2BinStr(encodingBody.ObjectLatitude) +
                    encodeDatum2BinStr(encodingBody.ObjectLongitude) +
                    encodeBDX2BinStr(encodingBody.ObjectSizeDim1, 16) +
                    encodeBDX2BinStr(encodingBody.ObjectSizeDim2, 16) +
                    encodeBDX2BinStr(encodingBody.ObjectOrientation, 12) +
                    requestObjectsBinStr
                );
                break;
            }
            case 'R': {
                bodyBinStr = (
                    encodeBDX2BinStr(encodingBody.ResourceMethod, 4) +
                    encodeUTF2BinStr(encodingBody.ResourceData, 0)
                );
                break;
            }
            case 'F': {
                bodyBinStr = encodeUTF2BinStr(encodingBody.Text, 0);
                break;
            }
            case 'T': {
                return callback(new ProcessingError(`Embedding of pseudo message type ${messageType} not implemented`, null, 'WF_API_NOT_IMPLEMENTED'), wfMessage);
            }
            default: {
                return callback(new ProtocolError(`Invalid message type: ${messageType}`), wfMessage);
            }
        }
        // Add encoded pseudo message code for test messages
        bodyBinStr = testBinStr + bodyBinStr;
        // Concatinate binary representation of header and body
        const encodedMessage = encodeBinStr2Buffer(headerBinStr + bodyBinStr);

        // Encrypt binary encoded message
        wfCrypto.encrypt(wfMessage, encodedMessage, function encodeEncryptCb(err, wfMessage) {
            if (err) return callback(err, wfMessage);
            return callback(null, wfMessage);
        });
    });
}

/**
 * Decodes binary message to validated json object and updates metaheader accordingly
 * @function decodeMessage
 * @alias lib/protocol/codec.decode
 * @param {wfMessage} wfMessage Whiteflag message with encoded message string in MetaHeader
 * @param {function(Error, wfMessage, ivMissing)} callback
 * @typedef {boolean} ivMissing indicates whether initialisation vector for decryption is missing
 */
function decodeMessage(wfMessage, callback) {
    // Check metaheader and encoded message
    if (!wfMessage.MetaHeader) {
        return callback(new ProtocolError('Missing metaheader', null, 'WF_METAHEADER_ERROR'), wfMessage);
    }
    if (!wfMessage.MetaHeader.encodedMessage) {
        return callback(new ProtocolError('No encoded message in metaheader', null, 'WF_METAHEADER_ERROR'), wfMessage);
    }
    // Create character string of the binary representation
    const encryptedMessage = Buffer.from(wfMessage.MetaHeader.encodedMessage, BINENCODING);
    let messageBinStr = decodeBin2BinStr(encryptedMessage);

    // Create empty message header and body objects
    wfMessage.MessageHeader = {};
    wfMessage.MessageBody = {};

    // Decoding of first part of message header
    let { MessageHeader: header } = wfMessage;
    header.Prefix = decodeBinStr(messageBinStr, 0, 16, 'utf');
    if (header.Prefix !== 'WF') {
        return callback(new ProtocolError('Encoded message does not have WF prefix', null, 'WF_FORMAT_ERROR'), wfMessage);
    }
    header.Version = decodeBinStr(messageBinStr, 16, 24, 'utf');
    header.EncryptionIndicator = decodeBinStr(messageBinStr, 24, 32, 'utf');

    // Check decryption data
    switch (header.EncryptionIndicator) {
        case '1':
        case '2': {
            // Check initialisation Vector
            if (!wfMessage.MetaHeader.encryptionInitVector) {
                return callback(null, wfMessage, true);
            }
            break;
        }
        default: break;
    }
    // Decrypt message
    wfCrypto.decrypt(wfMessage, encryptedMessage, function decodeDecryptCb(err, decryptedMessage) {
        if (err) return callback(err, wfMessage);

        // Replace binary string with decrypted one
        messageBinStr = decodeBin2BinStr(decryptedMessage);

        // Determine message type
        header.MessageCode = decodeBinStr(messageBinStr, 33, 41, 'utf');
        let messageType = header.MessageCode;

        // Message body
        let { MessageBody: body } = wfMessage;
        let decodingBody = {};

        // Check if test message
        let p = 0;
        if (header.MessageCode === 'T') {
            body.PseudoMessageCode = decodeBinStr(messageBinStr, 301, 309, 'utf').toUpperCase();
            messageType = body.PseudoMessageCode;
            p = 8;
        }
        // Decoding of message body
        const messageLength = messageBinStr.length - BYTELENGTH;
        switch (messageType) {
            case 'A': {
                decodingBody.VerificationMethod = decodeBinStr(messageBinStr, 301 + p, 305 + p, 'hex').toUpperCase();
                decodingBody.VerificationData = decodeBinStr(messageBinStr, 305 + p, messageLength, 'utf');
                break;
            }
            case 'K': {
                decodingBody.CryptoDataType = decodeBinStr(messageBinStr, 301 + p, 309 + p, 'hex').toUpperCase();
                decodingBody.CryptoData = decodeBinStr(messageBinStr, 309 + p, (messageLength + 4), 'hex');
                break;
            }
            case 'P':
            case 'D':
            case 'S':
            case 'E':
            case 'I':
            case 'M':
            case 'Q': {
                decodingBody.SubjectCode = decodeBinStr(messageBinStr, 301 + p, 309 + p, 'hex').toUpperCase();
                decodingBody.DateTime = decodeBinStr(messageBinStr, 309 + p, 365 + p, 'datetime');
                decodingBody.Duration = decodeBinStr(messageBinStr, 365 + p, 389 + p, 'duration');
                decodingBody.ObjectType = decodeBinStr(messageBinStr, 389 + p, 397 + p, 'hex').toUpperCase();
                decodingBody.ObjectLatitude = decodeBinStr(messageBinStr, 397 + p, 426 + p, 'lat');
                decodingBody.ObjectLongitude = decodeBinStr(messageBinStr, 426 + p, 459 + p, 'long');
                decodingBody.ObjectSizeDim1 = decodeBinStr(messageBinStr, 459 + p, 475 + p, 'dec');
                decodingBody.ObjectSizeDim2 = decodeBinStr(messageBinStr, 475 + p, 491 + p, 'dec');
                decodingBody.ObjectOrientation = decodeBinStr(messageBinStr, 491 + p, 503 + p, 'dec');
                if (header.MessageCode === 'Q') {
                    decodingBody.requestObjects = [];
                    for (let b = 503 + p; b <= messageLength; b += (2 * BYTELENGTH)) {
                        let requestObject = {};
                        requestObject.ObjectType = decodeBinStr(messageBinStr, b, (b + 8), 'hex').toUpperCase();
                        requestObject.ObjectTypeQuant = decodeBinStr(messageBinStr, (b + 8), (b + 16), 'dec');
                        decodingBody.requestObjects.push(requestObject);
                    }
                }
                break;
            }
            case 'R': {
                decodingBody.ResourceMethod = decodeBinStr(messageBinStr, 301 + p, 305 + p, 'hex').toUpperCase();
                decodingBody.ResourceData = decodeBinStr(messageBinStr, 305 + p, messageLength, 'utf');
                break;
            }
            case 'F': {
                decodingBody.Text = decodeBinStr(messageBinStr, 301 + p, messageLength, 'utf');
                break;
            }
            case 'T': {
                return callback(new ProcessingError(`Embedding of pseudo message type ${messageType} not implemented`, null, 'WF_API_NOT_IMPLEMENTED'), wfMessage, false);
            }
            default: {
                if (header.EncryptionIndicator !== '0') {
                    header.MessageCode = null;
                    return callback(new ProtocolError('Decryption did not result in valid data; maybe wrong key or message intended for a different recipient', null, 'WF_ENCRYPTION_ERROR'), wfMessage);
                }
                return callback(new ProtocolError(`Invalid message type: ${messageType}`), wfMessage);
            }
        }
        // Further decoding of message header
        header.DuressIndicator = decodeBinStr(messageBinStr, 32, 33, 'bin');
        header.ReferenceIndicator = decodeBinStr(messageBinStr, 41, 45, 'hex').toUpperCase();
        header.ReferencedMessage = decodeBinStr(messageBinStr, 45, 301, 'hex');

        // Message Nody
        if (header.MessageCode === 'T') {
            body.PseudoMessageBody = decodingBody;
        } else {
            object.update(decodingBody, body);
        }
        // Validate message format and return message
        verifyFormat(wfMessage, function decodeVerifyFormatCb(err, wfMessage) {
            if (err) return callback(err, wfMessage);
            return callback(null, wfMessage, false);
        });
    });
}

// PRIVATE MODULE FUNCTIONS //
/**
 * Checks if message content is compliant with the WF specification
 * @private
 * @param {wfMessage} wfMessage a Whiteflag message
 * @returns {Array} content errors
 */
function verifyValues(wfMessage) {
    // Split message, get specification and prepare error array
    const { MessageHeader: header, MessageBody: body } = wfMessage;
    const wfSpec = wfMessageSchema.specifications;
    let contentErrors = [];

    // Find correct message type
    const mesageTypeIndex = wfSpec.MessageCode.findIndex(
        mesageCode => mesageCode.const === header.MessageCode
    );
    if (mesageTypeIndex < 0) {
        return contentErrors.push(`Cannot validate values for non-existing message type: ${header.MessageCode}`);
    }
    const messageType = wfSpec.MessageCode[mesageTypeIndex];

    // CHECK 1A: is the object code valid?
    let objectTypeIndex;
    if (body.ObjectType) {
        objectTypeIndex = wfSpec.ObjectType.findIndex(
            ObjectType => ObjectType.const === body.ObjectType
        );
        if (objectTypeIndex < 0) {
            contentErrors.push(`Object code is not valid: ${body.ObjectType}`);
        }
    }
    // CHECK 1B: is the subject code valid?
    if (body.SubjectCode) {
        const subjectCodeIndex = messageType.SubjectCode.findIndex(
            SubjectCode => SubjectCode.const === body.SubjectCode
        );
        if (subjectCodeIndex < 0) {
            contentErrors.push(`Subject code is not defined for ${header.MessageCode} messages: ${body.SubjectCode}`);
        } else {
            // CHECK 1C: may subject code be used for this object type?
            const allowedObjectIndex = messageType.SubjectCode[subjectCodeIndex].allowedObjectTypes.findIndex(
                objectCat => objectCat === body.ObjectType.slice(0, -1) + '*'
            );
            if (allowedObjectIndex < 0) {
                contentErrors.push(`Object type ${body.ObjectType} cannot be used in ${type(wfMessage)} messages`);
            }
        }
    }
    // Evaluate and return result
    return contentErrors;
}

/**
 * Converts a string with characters representing Binary, Decimal and Hexadecimal values
 * to a character string representing their the binary encoding
 * @private
 * @param {string} fieldStr unencoded/uncompressed field value
 * @param {number} nBits size of field in compressed binary encoding in bits
 * @returns {string} representation of the binary encoding of the field
 */
function encodeBDX2BinStr(fieldStr, nBits) {
    const padding = Math.ceil(nBits / fieldStr.length);
    const padbits = new Array(padding).join('0');
    let binStr = '';

    // Run through characters of the string and convert to binary one by one
    // Treating all integers as hexadecimals always results in correct binary encoding
    for (let i = 0; i < fieldStr.length; i++) {
        let binCNum = parseInt(fieldStr[i], 16).toString(2);
        binStr += (padbits + binCNum).slice(-padding);
    }
    return binStr;
}

/**
 * Converts a string of 1-byte UTF-8 characters to a character string
 * representing the binary encoding of 8-bit bytes
 * @private
 * @param {*} fieldStr unencoded/uncompressed field value
 * @param {*} nBits size of field in compressed binary encoding in bits
 * @returns {string} representation of the binary encoding of the field
 */
function encodeUTF2BinStr(fieldStr, nBits) {
    const padbits = new Array(BYTELENGTH).join('0');
    let binStr = '';

    // Run through characters of the string and convert to binary one by one
    for (let i = 0; i < fieldStr.length; i++) {
        let binChar = fieldStr[i].charCodeAt(0).toString(2);
        binStr += (padbits + binChar).slice(-BYTELENGTH);
    }
    // Add 0s to fill all nBits of the field with UTF-8 NULL character, unless nBits = 0
    if (nBits === 0) return binStr;

    let nullstr = new Array(nBits - binStr.length).join('0');
    return (binStr + nullstr);
}

/**
 * Converts a string with datetime, time periode and lat long coordinates
 * to a character string representing the binary encoding
 * @private
 * @param {string} fieldStr unencoded/uncompressed field value
 * @returns {string} representation of the binary encoding of the field
 */
function encodeDatum2BinStr(fieldStr) {
    let sign = '';

    // Sign of lat long coordinates
    if (fieldStr.charAt(0) === '-') sign = '0';
    if (fieldStr.charAt(0) === '+') sign = '1';

    // Prepare string by removing fixed characters
    const fieldStrCl = fieldStr.replace(/[-+:.A-Z]/g, '');

    // Run through characters of the string and convert to binary one by one
    const padding = 4;
    const padbits = new Array(padding).join('0');
    let binStr = '';
    for (let i = 0; i < fieldStrCl.length; i++) {
        let binCNum = parseInt(fieldStrCl[i], 10).toString(2);
        binStr += (padbits + binCNum).slice(-padding);
    }
    return (sign + binStr);
}

/**
 * Converts a character string representing the binary encoding to a buffer
 * @private
 * @param {string} binStr representation of the binary encoding
 * @returns {buffer} binary ncoding
 */
function encodeBinStr2Buffer(binStr) {
    // Split the string in an array of strings representing 8-bit bytes
    const regexBytes = new RegExp('.{1,' + BYTELENGTH + '}', 'g');
    let binStrArr = binStr.match(regexBytes);

    // Add trailing 0s to fill last byte
    const lastbyte = binStrArr.length - 1;
    const trailzero = new Array(BYTELENGTH - binStrArr[lastbyte].length + 1).join('0');
    binStrArr[lastbyte] += trailzero;

    // Convert the character string with binary representation to a binary buffer of 8-bit words
    const buf = Buffer.alloc(binStrArr.length);
    for (let i = 0; i < binStrArr.length; i++) {
        buf[i] = parseInt(binStrArr[i], 2);
    }
    return buf;
}

/**
 * Converts a binary buffer/array to a character string representation
 * of the binary encoding of a message
 * @private
 * @param {buffer} BufArr binary encoding of a message
 * @returns {string} representation of the binary encoding
 */
function decodeBin2BinStr(BufArr) {
    const padbits = new Array(BYTELENGTH).join('0');
    let binStr = '';

    // Run through characters of the string and convert to binary one by one
    for (let i = 0; i < BufArr.length; i++) {
        let binChars = BufArr[i].toString(2);
        binStr += (padbits + binChars).slice(-BYTELENGTH);
    }
    return binStr;
}

/**
 * Converts a substring of the character string representation
 * of the binary encoding of a message to the uncompressed field value
 * @private
 * @param {*} binStr representation of the binary encoding
 * @param {*} beginBit position in string from where to convert
 * @param {*} endBit position in string before which conversion stops
 * @param {*} type 'utf', 'bin', 'dec', 'hex', 'datetime', 'duration', 'lat', 'long'
 * @returns {string} decoded/uncompressed field value
 */
function decodeBinStr(binStr, beginBit, endBit, type) {
    let i = 0;
    let fieldStr = '';

    // Perform conversion, depending on used binary encoding
    switch (type) {
        case 'utf': {
            // Run through bytes of the substring and convert to UTF-8 one by one
            for (i = beginBit; i < endBit; i += BYTELENGTH) {
                fieldStr += String.fromCharCode(parseInt(binStr.substring(i, i + BYTELENGTH), 2));
            }
            break;
        }
        case 'bin': {
            // Run through 1-bit binary values and convert to characters one by one
            for (i = beginBit; i < endBit; i++) {
                fieldStr += parseInt(binStr.charAt(i), 2).toString(2);
            }
            break;
        }
        case 'lat':
        case 'long': {
            // Convert the first bit of lat long coordinates into sign
            if (parseInt(binStr.charAt(beginBit), 2) === 0) fieldStr = '-';
            if (parseInt(binStr.charAt(beginBit), 2) === 1) fieldStr = '+';
            // Make sure BCD decoding below skips the sign bit; no break needed here!
            beginBit++;
        }
        // fallthrough  //
        case 'dec':
        case 'datetime':
        case 'duration': {
            // Run through 4-bit BCDs in the substring and convert to characters one by one
            for (i = beginBit; i < endBit; i += 4) {
                fieldStr += parseInt(binStr.substring(i, i + 4), 2).toString();
            }
            break;
        }
        case 'hex': {
            // Run through 4-bit HCDs in the substring and convert to characters one by one
            for (i = beginBit; i < endBit; i += 4) {
                fieldStr += parseInt(binStr.substring(i, i + 4), 2).toString(16);
            }
            break;
        }
        default: {
            throw new Error(`Internal Coding Error: wrong decoding type provided to decodeBinStr: ${type}`);
        }
    }
    // Re-insert fixed characters for certain field types i.a.w. specification
    switch (type) {
        case 'datetime':
            fieldStr = [
                fieldStr.slice(0, 4), '-',
                fieldStr.slice(4, 6), '-',
                fieldStr.slice(6, 8), 'T',
                fieldStr.slice(8, 10), ':',
                fieldStr.slice(10, 12), ':',
                fieldStr.slice(12), 'Z'
            ].join('');
            break;
        case 'duration':
            fieldStr = [
                'P',
                fieldStr.slice(0, 2), 'D',
                fieldStr.slice(2, 4), 'H',
                fieldStr.slice(4), 'M'
            ].join('');
            break;
        case 'lat':
            fieldStr = [fieldStr.slice(0, 3), '.', fieldStr.slice(3)].join('');
            break;
        case 'long':
            fieldStr = [fieldStr.slice(0, 4), '.', fieldStr.slice(4)].join('');
            break;
    }
    return fieldStr;
}