diff --git a/src/crypto/cipher/aes.js b/src/crypto/cipher/aes.js index 1c345583..a41c89aa 100644 --- a/src/crypto/cipher/aes.js +++ b/src/crypto/cipher/aes.js @@ -2,13 +2,12 @@ * @requires asmcrypto.js */ -import { _AES_asm_instance, _AES_heap_instance } from 'asmcrypto.js/src/aes/exports'; import { AES_ECB } from 'asmcrypto.js/src/aes/ecb/ecb'; // TODO use webCrypto or nodeCrypto when possible. function aes(length) { const C = function(key) { - const aes_ecb = new AES_ECB(key, _AES_heap_instance, _AES_asm_instance); + const aes_ecb = new AES_ECB(key); this.encrypt = function(block) { return aes_ecb.encrypt(block).result; diff --git a/src/encoding/armor.js b/src/encoding/armor.js index ea748c67..4622af7c 100644 --- a/src/encoding/armor.js +++ b/src/encoding/armor.js @@ -41,7 +41,7 @@ import util from '../util'; * 6 = SIGNATURE */ function getType(text) { - const reHeader = /^-----BEGIN PGP (MESSAGE, PART \d+\/\d+|MESSAGE, PART \d+|SIGNED MESSAGE|MESSAGE|PUBLIC KEY BLOCK|PRIVATE KEY BLOCK|SIGNATURE)-----$\n/m; + const reHeader = /^-----BEGIN PGP (MESSAGE, PART \d+\/\d+|MESSAGE, PART \d+|SIGNED MESSAGE|MESSAGE|PUBLIC KEY BLOCK|PRIVATE KEY BLOCK|SIGNATURE)-----$/m; const header = text.match(reHeader); @@ -261,6 +261,85 @@ function splitChecksum(text) { return { body: body, checksum: checksum }; } +/** + * DeArmor an OpenPGP armored message; verify the checksum and return + * the encoded bytes + * @param {String} text OpenPGP armored message + * @returns {Object} An object with attribute "text" containing the message text, + * an attribute "data" containing the bytes and "type" for the ASCII armor type + * @static + */ +function dearmorStream(text) { + return new Promise(async (resolve, reject) => { + const reSplit = /^-----[^-]+-----$/; + const reEmptyLine = /^[ \f\r\t\u00a0\u2000-\u200a\u202f\u205f\u3000]*$/; + + const reader = text.getReader(); + let lineIndex = 0; + let type; + const headers = {}; + let headersDone; + let controller; + let [data, dataClone] = base64.decode(new ReadableStream({ + async start(_controller) { + controller = _controller; + } + })).tee(); + let checksum; + const checksumVerified = getCheckSum(dataClone); + data = data.transform(async (done, value) => { + if (!done) { + return value; + } + const checksumVerifiedString = util.Uint8Array_to_str(await checksumVerified.readToEnd()); + if (checksum !== checksumVerifiedString && (checksum || config.checksum_required)) { + throw new Error("Ascii armor integrity check on message failed: '" + checksum + "' should be '" + + checksumVerifiedString + "'"); + } + }); + while (true) { + const value = await reader.readLine(); + if (!value) break; + let text = util.Uint8Array_to_str(value); + if (lineIndex++ === 0) { + // trim string + text = text.trim(); + } + // remove trailing whitespace at end of lines + text = text.replace(/[\t\r\n ]+$/g, ''); + if (!type) { + if (reSplit.test(text)) { + type = getType(text); + } + } else if(!headersDone) { + if (reSplit.test(text)) { + reject(new Error('Mandatory blank line missing between armor headers and armor data')); + } + if (!reEmptyLine.test(text)) { + // Parse header + } else { + headersDone = true; + resolve({ + type, + data + }); + } + } else { + if (!reSplit.test(text)) { + if (text[0] !== '=') { + controller.enqueue(util.str_to_Uint8Array(text)); + } else { + checksum = text.substr(1); + } + } else { + controller.close(); + break; + } + } + } + }); +} + /** * DeArmor an OpenPGP armored message; verify the checksum and return * the encoded bytes @@ -298,7 +377,7 @@ function dearmor(text) { const msg_sum = splitChecksum(msg.body); result = { - data: base64.decode(msg_sum.body), + data: base64.decode(util.str_to_Uint8Array(msg_sum.body)), headers: msg.headers, type: type }; @@ -313,7 +392,7 @@ function dearmor(text) { result = { text: msg.body.replace(/\n$/, '').replace(/\n/g, "\r\n"), - data: base64.decode(sig_sum.body), + data: base64.decode(util.str_to_Uint8Array(sig_sum.body)), headers: msg.headers, type: type }; @@ -412,5 +491,6 @@ function armor(messagetype, body, partindex, parttotal, customComment) { export default { encode: armor, - decode: dearmor + decode: dearmor, + decodeStream: dearmorStream }; diff --git a/src/encoding/base64.js b/src/encoding/base64.js index 9031aab6..18f2e634 100644 --- a/src/encoding/base64.js +++ b/src/encoding/base64.js @@ -18,8 +18,8 @@ import util from '../util'; -const b64s = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; // Standard radix-64 -const b64u = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; // URL-safe radix-64 +const b64s = util.str_to_Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'); // Standard radix-64 +const b64u = util.str_to_Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'); // URL-safe radix-64 /** * Convert binary array to radix-64 @@ -45,22 +45,22 @@ function s2r(t, u = false) { for (let n = 0; n < tl; n++) { c = value[n]; if (s === 0) { - r.push(b64.charAt((c >> 2) & 63)); + r.push(b64[(c >> 2) & 63]); a = (c & 3) << 4; } else if (s === 1) { - r.push(b64.charAt(a | ((c >> 4) & 15))); + r.push(b64[a | ((c >> 4) & 15)]); a = (c & 15) << 2; } else if (s === 2) { - r.push(b64.charAt(a | ((c >> 6) & 3))); + r.push(b64[a | ((c >> 6) & 3)]); l += 1; if ((l % 60) === 0 && !u) { - r.push("\n"); + r.push(10); // "\n" } - r.push(b64.charAt(c & 63)); + r.push(b64[c & 63]); } l += 1; if ((l % 60) === 0 && !u) { - r.push("\n"); + r.push(10); // "\n" } s += 1; @@ -70,24 +70,24 @@ function s2r(t, u = false) { } } else { if (s > 0) { - r.push(b64.charAt(a)); + r.push(b64[a]); l += 1; if ((l % 60) === 0 && !u) { - r.push("\n"); + r.push(10); // "\n" } if (!u) { - r.push('='); + r.push(61); // "=" l += 1; } } if (s === 1 && !u) { if ((l % 60) === 0 && !u) { - r.push("\n"); + r.push(10); // "\n" } - r.push('='); + r.push(61); // "=" } } - return util.str_to_Uint8Array(r.join('')); + return new Uint8Array(r); }); } @@ -102,23 +102,27 @@ function r2s(t, u) { // TODO check atob alternative const b64 = u ? b64u : b64s; let c; - let n; - const r = []; + let s = 0; let a = 0; - const tl = t.length; - for (n = 0; n < tl; n++) { - c = b64.indexOf(t.charAt(n)); - if (c >= 0) { - if (s) { - r.push(a | ((c >> (6 - s)) & 255)); + return t.transform((done, value) => { + if (!done) { + const r = []; + const tl = value.length; + for (let n = 0; n < tl; n++) { + c = b64.indexOf(value[n]); + if (c >= 0) { + if (s) { + r.push(a | ((c >> (6 - s)) & 255)); + } + s = (s + 2) & 7; + a = (c << s) & 255; + } } - s = (s + 2) & 7; - a = (c << s) & 255; + return new Uint8Array(r); } - } - return new Uint8Array(r); + }); } export default { diff --git a/src/message.js b/src/message.js index 64b781c6..39d3c3ab 100644 --- a/src/message.js +++ b/src/message.js @@ -122,6 +122,7 @@ Message.prototype.decrypt = async function(privateKeys, passwords, sessionKeys) await symEncryptedPacket.decrypt(keyObjs[i].algorithm, keyObjs[i].data); break; } catch (e) { + util.print_debug_error(e); exception = e; } } @@ -618,6 +619,17 @@ Message.prototype.armor = function() { return armor.encode(enums.armor.message, this.packets.write()); }; +/** + * reads an OpenPGP armored message and returns a message object + * @param {String} armoredText text to be parsed + * @returns {module:message.Message} new message object + * @static + */ +async function readArmoredStream(armoredText) { + const input = await armor.decodeStream(armoredText); + return readStream(input.data); +} + /** * reads an OpenPGP armored message and returns a message object * @param {String} armoredText text to be parsed @@ -625,12 +637,29 @@ Message.prototype.armor = function() { * @static */ export function readArmored(armoredText) { + if (util.isStream(armoredText)) { + return readArmoredStream(armoredText); + } //TODO how do we want to handle bad text? Exception throwing //TODO don't accept non-message armored texts const input = armor.decode(armoredText).data; return read(input); } +/** + * reads an OpenPGP message as byte array and returns a message object + * @param {Uint8Array} input binary message + * @returns {Message} new message object + * @static + */ +async function readStream(input) { + const packetlist = new packet.List(); + await packetlist.readStream(input); + const message = new Message(packetlist); + message.fromStream = true; + return message; +} + /** * reads an OpenPGP message as byte array and returns a message object * @param {Uint8Array} input binary message diff --git a/src/openpgp.js b/src/openpgp.js index 45dd79b8..cd9f3cd8 100644 --- a/src/openpgp.js +++ b/src/openpgp.js @@ -357,9 +357,10 @@ export function decrypt({ message, privateKeys, passwords, sessionKeys, publicKe return asyncProxy.delegate('decrypt', { message, privateKeys, passwords, sessionKeys, publicKeys, format, signature, date }); } + const asStream = message.fromStream; return message.decrypt(privateKeys, passwords, sessionKeys).then(async function(message) { - const result = parseMessage(message, format); + const result = await parseMessage(message, format, asStream); if (!publicKeys) { publicKeys = []; @@ -587,21 +588,26 @@ function createMessage(data, filename, date=new Date(), type) { * Parse the message given a certain format. * @param {Message} message the message object to be parse * @param {String} format the output format e.g. 'utf8' or 'binary' + * @param {Boolean} asStream whether to return a ReadableStream, if available * @returns {Object} the parse data in the respective format */ -function parseMessage(message, format) { +async function parseMessage(message, format, asStream) { + let data; if (format === 'binary') { - return { - data: message.getLiteralData(), - filename: message.getFilename() - }; + data = message.getLiteralData(); + if (!asStream && util.isStream(data)) { + data = await data.readToEnd(); + } } else if (format === 'utf8') { - return { - data: message.getText(), - filename: message.getFilename() - }; + data = message.getText(); + if (!asStream && util.isStream(data)) { + data = await data.readToEnd(chunks => chunks.join('')); + } + } else { + throw new Error('Invalid format'); } - throw new Error('Invalid format'); + const filename = message.getFilename(); + return { data, filename }; } /** diff --git a/src/packet/literal.js b/src/packet/literal.js index 87c5cd1e..ce275952 100644 --- a/src/packet/literal.js +++ b/src/packet/literal.js @@ -54,6 +54,10 @@ Literal.prototype.setText = function(text, format='utf8') { this.data = null; }; +function normalize(text) { + return util.nativeEOL(util.decode_utf8(text)); +} + /** * Returns literal data packets as native JavaScript string * with normalized end of line to \n @@ -63,10 +67,24 @@ Literal.prototype.getText = function() { if (this.text !== null) { return this.text; } - // decode UTF8 - const text = util.decode_utf8(util.Uint8Array_to_str(this.data)); - // normalize EOL to \n - this.text = util.nativeEOL(text); + let lastChar = ''; + this.text = this.data.transform((done, value) => { + if (!done) { + const text = lastChar + util.Uint8Array_to_str(value); + // decode UTF8 and normalize EOL to \n + const normalized = normalize(text); + // if last two bytes are \r\n or an UTF8 sequence, return them immediately + if (text.length >= 2 && normalize(text.slice(-2)).length === 1) { + lastChar = ''; + return normalized; + } + // else, store the last character for the next chunk in case it's \r or half an UTF8 sequence + lastChar = text[text.length - 1]; + return normalized.slice(0, -1); + } else { + return lastChar; + } + }); return this.text; }; @@ -123,16 +141,17 @@ Literal.prototype.getFilename = function() { * @param {Uint8Array} input Payload of a tag 11 packet * @returns {module:packet.Literal} object representation */ -Literal.prototype.read = function(bytes) { +Literal.prototype.read = async function(bytes) { + const reader = bytes.getReader(); // - A one-octet field that describes how the data is formatted. - const format = enums.read(enums.literal, bytes[0]); + const format = enums.read(enums.literal, await reader.readByte()); - const filename_len = bytes[1]; - this.filename = util.decode_utf8(util.Uint8Array_to_str(bytes.subarray(2, 2 + filename_len))); + const filename_len = await reader.readByte(); + this.filename = util.decode_utf8(util.Uint8Array_to_str(await reader.readBytes(filename_len))); - this.date = util.readDate(bytes.subarray(2 + filename_len, 2 + filename_len + 4)); + this.date = util.readDate(await reader.readBytes(4)); - const data = bytes.subarray(6 + filename_len, bytes.length); + const data = reader.substream(); this.setBytes(data, format); }; diff --git a/src/packet/packet.js b/src/packet/packet.js index 5561b360..aa48af68 100644 --- a/src/packet/packet.js +++ b/src/packet/packet.js @@ -110,6 +110,150 @@ export default { return util.concatUint8Array([new Uint8Array([0x80 | (tag_type << 2) | 2]), util.writeNumber(length, 4)]); }, + /** + * Generic static Packet Parser function + * + * @param {String} input Input stream as string + * @param {integer} position Position to start parsing + * @param {integer} len Length of the input from position on + * @returns {Object} Returns a parsed module:packet/packet + */ + readStream: function(reader) { + return new Promise(async (resolve, reject) => { + const peekedBytes = await reader.peekBytes(2); + // some sanity checks + if (!peekedBytes || peekedBytes.length < 2 || (peekedBytes[0] & 0x80) === 0) { + reject(new Error("Error during parsing. This message / key probably does not conform to a valid OpenPGP format.")); + return; + } + const headerByte = await reader.readByte(); + let tag = -1; + let format = -1; + let packet_length; + + format = 0; // 0 = old format; 1 = new format + if ((headerByte & 0x40) !== 0) { + format = 1; + } + + let packet_length_type; + if (format) { + // new format header + tag = headerByte & 0x3F; // bit 5-0 + } else { + // old format header + tag = (headerByte & 0x3F) >> 2; // bit 5-2 + packet_length_type = headerByte & 0x03; // bit 1-0 + } + + let controller; + let bodydata = null; + if (!format) { + // 4.2.1. Old Format Packet Lengths + switch (packet_length_type) { + case 0: + // The packet has a one-octet length. The header is 2 octets + // long. + packet_length = await reader.readByte(); + break; + case 1: + // The packet has a two-octet length. The header is 3 octets + // long. + packet_length = (await reader.readByte() << 8) | await reader.readByte(); + break; + case 2: + // The packet has a four-octet length. The header is 5 + // octets long. + packet_length = (await reader.readByte() << 24) | (await reader.readByte() << 16) | (await reader.readByte() << + 8) | await reader.readByte(); + break; + default: + // 3 - The packet is of indeterminate length. The header is 1 + // octet long, and the implementation must determine how long + // the packet is. If the packet is in a file, this means that + // the packet extends until the end of the file. In general, + // an implementation SHOULD NOT use indeterminate-length + // packets except where the end of the data will be clear + // from the context, and even then it is better to use a + // definite length, or a new format header. The new format + // headers described below have a mechanism for precisely + // encoding data of indeterminate length. + packet_length = Infinity; + break; + } + } else { // 4.2.2. New Format Packet Lengths + // 4.2.2.1. One-Octet Lengths + const lengthByte = await reader.readByte(); + if (lengthByte < 192) { + packet_length = lengthByte; + // 4.2.2.2. Two-Octet Lengths + } else if (lengthByte >= 192 && lengthByte < 224) { + packet_length = ((lengthByte - 192) << 8) + (await reader.readByte()) + 192; + // 4.2.2.4. Partial Body Lengths + } else if (lengthByte > 223 && lengthByte < 255) { + packet_length = 1 << (lengthByte & 0x1F); + bodydata = new ReadableStream({ + async start(_controller) { + controller = _controller; + } + }); + resolve({ + tag: tag, + packet: bodydata, + done: true + }); + controller.enqueue(await reader.readBytes(packet_length)); + let tmplen; + while (true) { + const tmplenByte = await reader.readByte(); + if (tmplenByte < 192) { + tmplen = tmplenByte; + controller.enqueue(await reader.readBytes(tmplen)); + break; + } else if (tmplenByte >= 192 && tmplenByte < 224) { + tmplen = ((tmplenByte - 192) << 8) + (await reader.readByte()) + 192; + controller.enqueue(await reader.readBytes(tmplen)); + break; + } else if (tmplenByte > 223 && tmplenByte < 255) { + tmplen = 1 << (tmplenByte & 0x1F); + controller.enqueue(await reader.readBytes(tmplen)); + } else { + tmplen = (await reader.readByte() << 24) | (await reader.readByte() << 16) | (await reader.readByte() << 8) | await reader.readByte(); + controller.enqueue(await reader.readBytes(tmplen)); + break; + } + } + // 4.2.2.3. Five-Octet Lengths + } else { + packet_length = (await reader.readByte() << 24) | (await reader.readByte() << 16) | (await reader.readByte() << + 8) | await reader.readByte(); + } + } + + // if there wasn't a partial body length + if (bodydata === null) { + bodydata = await reader.readBytes(packet_length); + + resolve({ + tag: tag, + packet: bodydata, + done: !await reader.peekBytes(1) + }); + } else { + try { + const { done } = await reader.read(); + if (!done) { + throw new Error('Packets after a packet with partial lengths are not supported'); + } else { + controller.close(); + } + } catch(e) { + controller.error(e); + } + } + }); + }, + /** * Generic static Packet Parser function * @@ -147,7 +291,6 @@ export default { // header octet parsing done mypos++; - // parsed length from length field let bodydata = null; // used for partial body lengths diff --git a/src/packet/packetlist.js b/src/packet/packetlist.js index 80ce760d..d589d67e 100644 --- a/src/packet/packetlist.js +++ b/src/packet/packetlist.js @@ -29,6 +29,44 @@ function List() { this.length = 0; } +/** + * Reads a stream of binary data and interprents it as a list of packets. + * @param {Uint8Array} A Uint8Array of bytes. + */ +List.prototype.readStream = async function (bytes) { + const reader = bytes.getReader(); + while (true) { + const parsed = await packetParser.readStream(reader); + + let pushed = false; + try { + const tag = enums.read(enums.packet, parsed.tag); + const packet = packets.newPacketFromTag(tag); + this.push(packet); + pushed = true; + if (packet.readStream) { + await packet.readStream(parsed.packet); + } else { + await packet.read(parsed.packet); + } + if (parsed.done) { + break; + } + } catch (e) { + if (!config.tolerant || + parsed.tag === enums.packet.symmetricallyEncrypted || + parsed.tag === enums.packet.literal || + parsed.tag === enums.packet.compressed) { + throw e; + } + util.print_debug_error(e); + if (pushed) { + this.pop(); // drop unsupported packet + } + } + } +}; + /** * Reads a stream of binary data and interprents it as a list of packets. * @param {Uint8Array} A Uint8Array of bytes. diff --git a/src/packet/sym_encrypted_integrity_protected.js b/src/packet/sym_encrypted_integrity_protected.js index 5d7c075e..67e542d0 100644 --- a/src/packet/sym_encrypted_integrity_protected.js +++ b/src/packet/sym_encrypted_integrity_protected.js @@ -22,8 +22,7 @@ * @requires util */ -import { _AES_asm_instance, _AES_heap_instance } from 'asmcrypto.js/src/aes/exports'; -import { AES_CFB, AES_CFB_Decrypt, AES_CFB_Encrypt } from 'asmcrypto.js/src/aes/cfb/exports'; +import { AES_CFB_Decrypt, AES_CFB_Encrypt } from 'asmcrypto.js/src/aes/cfb/exports'; import crypto from '../crypto'; import enums from '../enums'; @@ -61,16 +60,18 @@ function SymEncryptedIntegrityProtected() { this.packets = null; } -SymEncryptedIntegrityProtected.prototype.read = function (bytes) { +SymEncryptedIntegrityProtected.prototype.read = async function (bytes) { + const reader = bytes.getReader(); + // - A one-octet version number. The only currently defined value is 1. - if (bytes[0] !== VERSION) { + if (await reader.readByte() !== VERSION) { throw new Error('Invalid packet version.'); } // - Encrypted data, the output of the selected symmetric-key cipher // operating in Cipher Feedback mode with shift amount equal to the // block size of the cipher (CFB-n where n is the block size). - this.encrypted = bytes.subarray(1, bytes.length); + this.encrypted = reader.substream(); }; SymEncryptedIntegrityProtected.prototype.write = function () { @@ -112,25 +113,29 @@ SymEncryptedIntegrityProtected.prototype.encrypt = async function (sessionKeyAlg * @async */ SymEncryptedIntegrityProtected.prototype.decrypt = async function (sessionKeyAlgorithm, key) { + const [encrypted, encryptedClone] = this.encrypted.tee(); let decrypted; if (sessionKeyAlgorithm.substr(0, 3) === 'aes') { // AES optimizations. Native code for node, asmCrypto for browser. - decrypted = aesDecrypt(sessionKeyAlgorithm, this.encrypted, key); + decrypted = aesDecrypt(sessionKeyAlgorithm, encrypted, key); } else { - decrypted = crypto.cfb.decrypt(sessionKeyAlgorithm, key, this.encrypted, false); + decrypted = crypto.cfb.decrypt(sessionKeyAlgorithm, key, encrypted, false); } + let decryptedClone; + [decrypted, decryptedClone] = decrypted.tee(); // there must be a modification detection code packet as the // last packet and everything gets hashed except the hash itself - const prefix = crypto.cfb.mdc(sessionKeyAlgorithm, key, this.encrypted); - const bytes = decrypted.subarray(0, decrypted.length - 20); + const encryptedPrefix = await encryptedClone.subarray(0, crypto.cipher[sessionKeyAlgorithm].blockSize + 2).readToEnd(); + const prefix = crypto.cfb.mdc(sessionKeyAlgorithm, key, encryptedPrefix); + let [bytes, bytesClone] = decrypted.subarray(0, -20).tee(); const tohash = util.concatUint8Array([prefix, bytes]); - this.hash = util.Uint8Array_to_str(crypto.hash.sha1(tohash)); - const mdc = util.Uint8Array_to_str(decrypted.subarray(decrypted.length - 20, decrypted.length)); + this.hash = util.Uint8Array_to_str(await crypto.hash.sha1(tohash).readToEnd()); + const mdc = util.Uint8Array_to_str(await decryptedClone.subarray(-20).readToEnd()); if (this.hash !== mdc) { throw new Error('Modification detected.'); } else { - this.packets.read(decrypted.subarray(0, decrypted.length - 22)); + await this.packets.readStream(bytesClone.subarray(0, -2)); } return true; @@ -150,7 +155,7 @@ function aesEncrypt(algo, pt, key) { if (nodeCrypto) { // Node crypto library. return nodeEncrypt(algo, pt, key); } // asm.js fallback - const cfb = new AES_CFB_Encrypt(key, undefined, _AES_heap_instance, _AES_asm_instance); + const cfb = new AES_CFB_Encrypt(key); return pt.transform((done, value) => { if (!done) { return cfb.process(value).result; @@ -164,9 +169,15 @@ function aesDecrypt(algo, ct, key) { if (nodeCrypto) { // Node crypto library. pt = nodeDecrypt(algo, ct, key); } else { // asm.js fallback - pt = AES_CFB.decrypt(ct, key); + const cfb = new AES_CFB_Decrypt(key); + pt = ct.transform((done, value) => { + if (!done) { + return cfb.process(value).result; + } + return cfb.finish().result; + }); } - return pt.subarray(crypto.cipher[algo].blockSize + 2, pt.length); // Remove random prefix + return pt.subarray(crypto.cipher[algo].blockSize + 2); // Remove random prefix } function nodeEncrypt(algo, prefix, pt, key) { diff --git a/src/type/stream.js b/src/type/stream.js index fb84e420..467b1ee5 100644 --- a/src/type/stream.js +++ b/src/type/stream.js @@ -37,38 +37,35 @@ ReadableStream.prototype.transform = function(fn) { const reader = this.getReader(); return new ReadableStream({ async pull(controller) { - const { done, value } = await reader.read(); - const result = fn(done, value); - if (result) controller.enqueue(result); - if (done) controller.close(); - if (!done && !result) await this.pull(controller); // ??? Chrome bug? + try { + const { done, value } = await reader.read(); + const result = await fn(done, value); + if (result) controller.enqueue(result); + else if (!done) await this.pull(controller); // ??? Chrome bug? + if (done) controller.close(); + } catch(e) { + controller.error(e); + } } }); }; -ReadableStream.prototype.readToEnd = async function() { - const reader = this.getReader(); - const result = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - result.push(value); - } - return util.concatUint8Array(result); +ReadableStream.prototype.readToEnd = async function(join) { + return this.getReader().readToEnd(join); }; Uint8Array.prototype.getReader = function() { let doneReading = false; - return { - read: async () => { - if (doneReading) { - return { value: undefined, done: true }; - } - doneReading = true; - return { value: this, done: false }; + const reader = Object.create(ReadableStreamDefaultReader.prototype); + reader._read = async () => { + if (doneReading) { + return { value: undefined, done: true }; } + doneReading = true; + return { value: this, done: false }; }; + return reader; }; Uint8Array.prototype.transform = function(fn) { @@ -85,3 +82,129 @@ Uint8Array.prototype.tee = function() { Uint8Array.prototype.readToEnd = async function() { return this; }; + +const ReadableStreamDefaultReader = new ReadableStream().getReader().constructor; + +ReadableStreamDefaultReader.prototype._read = ReadableStreamDefaultReader.prototype.read; +ReadableStreamDefaultReader.prototype.read = async function() { + if (this.externalBuffer && this.externalBuffer.length) { + const value = this.externalBuffer.shift(); + return { done: false, value }; + } + return this._read(); +}; + +ReadableStreamDefaultReader.prototype.readLine = async function() { + let buffer = []; + let returnVal; + while (!returnVal) { + const { done, value } = await this.read(); + if (done) { + if (buffer.length) return util.concatUint8Array(buffer); + return; + } + const lineEndIndex = value.indexOf(10) + 1; // Position after the first "\n" + if (lineEndIndex) { + returnVal = util.concatUint8Array(buffer.concat(value.subarray(0, lineEndIndex))); + buffer = []; + } + if (lineEndIndex !== value.length) { + buffer.push(value.subarray(lineEndIndex)); + } + } + this.unshift(...buffer); + return returnVal; +}; + +ReadableStreamDefaultReader.prototype.readByte = async function() { + const { done, value } = await this.read(); + if (done) return; + const byte = value[0]; + this.unshift(value.subarray(1)); + return byte; +}; + +ReadableStreamDefaultReader.prototype.readBytes = async function(length) { + const buffer = []; + let bufferLength = 0; + while (true) { + const { done, value } = await this.read(); + if (done) { + if (buffer.length) return util.concatUint8Array(buffer); + return; + } + buffer.push(value); + bufferLength += value.length; + if (bufferLength >= length) { + const bufferConcat = util.concatUint8Array(buffer); + this.unshift(bufferConcat.subarray(length)); + return bufferConcat.subarray(0, length); + } + } +}; + +ReadableStreamDefaultReader.prototype.peekBytes = async function(length) { + const bytes = await this.readBytes(length); + this.unshift(bytes); + return bytes; +}; + +ReadableStreamDefaultReader.prototype.unshift = function(...values) { + if (!this.externalBuffer) { + this.externalBuffer = []; + } + this.externalBuffer.unshift(...values.filter(value => value && value.length)); +}; + +ReadableStreamDefaultReader.prototype.substream = function() { + return new ReadableStream({ pull: pullFrom(this) }); +}; + +function pullFrom(reader) { + return async controller => { + const { done, value } = await reader.read(); + if (!done) { + controller.enqueue(value); + } else { + controller.close(); + } + }; +} + +ReadableStream.prototype.subarray = function(begin=0, end=Infinity) { + if (begin >= 0 && end >= 0) { + const reader = this.getReader(); + let bytesRead = 0; + return new ReadableStream({ + async pull (controller) { + const { done, value } = await reader.read(); + if (!done && bytesRead < end) { + if (bytesRead + value.length >= begin) { + controller.enqueue(value.subarray(Math.max(begin - bytesRead, 0), end - bytesRead)); + } + bytesRead += value.length; + await this.pull(controller); // Only necessary if the above call to enqueue() didn't happen + } else { + controller.close(); + } + } + }); + } + return new ReadableStream({ + pull: async controller => { + // TODO: Don't read entire stream into memory here. + controller.enqueue((await this.readToEnd()).subarray(begin, end)); + controller.close(); + } + }); +}; + +ReadableStreamDefaultReader.prototype.readToEnd = async function(join=util.concatUint8Array) { + const result = []; + while (true) { + const { done, value } = await this.read(); + if (done) break; + result.push(value); + } + return join(result); +}; diff --git a/src/util.js b/src/util.js index 27574185..d9c799e3 100644 --- a/src/util.js +++ b/src/util.js @@ -174,7 +174,7 @@ export default { * @returns {Uint8Array} An array of 8-bit integers */ b64_to_Uint8Array: function (base64) { - return b64.decode(base64.replace(/-/g, '+').replace(/_/g, '/')); + return b64.decode(util.str_to_Uint8Array(base64.replace(/-/g, '+').replace(/_/g, '/'))); }, /** @@ -417,14 +417,18 @@ export default { } }, - print_entire_stream: function (str, stream) { + print_entire_stream: function (str, stream, fn = result => result) { const teed = stream.tee(); teed[1].readToEnd().then(result => { - console.log(str + ': ' + util.Uint8Array_to_str(result)); + console.log(str + ': ', fn(result)); }); return teed[0]; }, + print_entire_stream_str: function (str, stream, fn = result => result) { + return util.print_entire_stream(str, stream, result => fn(util.Uint8Array_to_str(result))); + }, + getLeftNBits: function (array, bitcount) { const rest = bitcount % 8; if (rest === 0) { diff --git a/test/general/streaming.js b/test/general/streaming.js index 59fbd46f..2dfb62c7 100644 --- a/test/general/streaming.js +++ b/test/general/streaming.js @@ -57,4 +57,34 @@ describe('Streaming', function() { }); expect(decrypted.data).to.deep.equal(util.concatUint8Array(plaintext)); }); + + it('Encrypt and decrypt larger message roundtrip', async function() { + let plaintext = []; + let i = 0; + const data = new ReadableStream({ + async pull(controller) { + if (i++ < 10) { + let randomBytes = await openpgp.crypto.random.getRandomBytes(1024); + controller.enqueue(randomBytes); + plaintext.push(randomBytes); + } else { + controller.close(); + } + } + }); + const encrypted = await openpgp.encrypt({ + data, + passwords: ['test'], + }); + + const msgAsciiArmored = encrypted.data; + const message = await openpgp.message.readArmored(msgAsciiArmored); + const decrypted = await openpgp.decrypt({ + passwords: ['test'], + message, + format: 'binary' + }); + expect(util.isStream(decrypted.data)).to.be.true; + expect(await decrypted.data.readToEnd()).to.deep.equal(util.concatUint8Array(plaintext)); + }); });