diff --git a/javascript/package.json b/javascript/package.json index 831e44ed0cb09a3a05b79ff9daad071e936675d8..1cc608b267fb1be5d7df4e7551dca5cb29716d6b 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -25,7 +25,9 @@ "dependencies": { "asn1js": "^2.0.21", "axios": "0.18.0", + "data-uri-to-blob": "^0.0.4", "libmime": "^4.0.1", + "libqp": "^1.1.0", "penpal": "^3.0.3", "pkijs": "^2.1.69", "pvutils": "^1.0.16", diff --git a/javascript/src/constants.js b/javascript/src/constants/authentication.js similarity index 100% rename from javascript/src/constants.js rename to javascript/src/constants/authentication.js diff --git a/javascript/src/constants/certificates.js b/javascript/src/constants/certificates.js new file mode 100644 index 0000000000000000000000000000000000000000..d151bfb0a79442711aaee29b7af0f21e2cfa2f97 --- /dev/null +++ b/javascript/src/constants/certificates.js @@ -0,0 +1,38 @@ +/** + * Certificate attribute types + * For reference http://oidref.com/2.5.4.3 + */ + +export const rdnmap = { + "2.5.4.3": "commonName", + "2.5.4.4": "surname", + "2.5.4.6": "country", + "2.5.4.7": "locality", + "2.5.4.8": "state", + "2.5.4.10": "organization", + "2.5.4.11": "organizationUnit", + "2.5.4.12": "title", + "2.5.4.42": "givenName", + "2.5.4.43": "initials", + "1.2.840.113549.1.9.1": "email" +}; + +/** + * Common algorithm OIDs and corresponding types + */ + +export const algomap = { + "1.2.840.113549.1.1.2": "MD2 with RSA", + "1.2.840.113549.1.1.4": "MD5 with RSA", + "1.2.840.10040.4.3": "SHA1 with DSA", + "1.2.840.10045.4.1": "SHA1 with ECDSA", + "1.2.840.10045.4.3.2": "SHA256 with ECDSA", + "1.2.840.10045.4.3.3": "SHA384 with ECDSA", + "1.2.840.10045.4.3.4": "SHA512 with ECDSA", + "1.2.840.113549.1.1.10": "RSA-PSS", + "1.2.840.113549.1.1.5": "SHA1 with RSA", + "1.2.840.113549.1.1.14": "SHA224 with RSA", + "1.2.840.113549.1.1.11": "SHA256 with RSA", + "1.2.840.113549.1.1.12": "SHA384 with RSA", + "1.2.840.113549.1.1.13": "SHA512 with RSA" +}; diff --git a/javascript/src/helpers/mailparser.js b/javascript/src/helpers/mailparser.js new file mode 100644 index 0000000000000000000000000000000000000000..8fd6f11fe5bcbd4f5259b0f4d7e306ab891543b1 --- /dev/null +++ b/javascript/src/helpers/mailparser.js @@ -0,0 +1,357 @@ +import libmime from "libmime"; +import libqp from "libqp"; + +const newline = /\r\n|\r|\n/g; +const SMIMEStart = + "This is a cryptographically signed message in MIME format.\r\n\r\n"; + +function findAllOccurences(text, subject, from, to) { + const result = []; + let index = text.indexOf(subject, from); + if (index < 0 || index > to) { + return result; + } + + result.push(index + 2); + + while (true) { + index = text.indexOf(subject, index + 1); + if (index < 0 || index > to) { + break; + } else { + result.push(index + 2); + } + } + + return result; +} + +function findFirstBoundary(body, from, to) { + if (from >= to) { + return null; + } + const search = "\r\n--"; + + let start = body.indexOf(search, from); + if (start < 0 || start > to) { + return null; + } + + start += 2; + + const end = body.indexOf("\r\n", start); + if (end < 0 || end > to) { + return null; + } + + const boundary = body.substring(start, end); + + const boundaryEnd = boundary + "--\r\n"; + + const startBoundaryEnd = body.indexOf(boundaryEnd, end); + + if (startBoundaryEnd < 0 || startBoundaryEnd > to) { + return findFirstBoundary(body, end + 2, to); + } + + return boundary; +} + +function calculateParts(body, from, to, previousBondary) { + let boundary = findFirstBoundary(body, from, to); + + if (boundary == null) { + return [{ indices: { from: from, to: to }, boundary: previousBondary }]; + } + + const realBoundary = boundary; + boundary = "\r\n" + boundary; + + const boundaryPairs = []; + + const boundaryIndices = findAllOccurences(body, boundary, from, to); + const boundaryIndicesLength = boundaryIndices.length; + for (let i = 0; i < boundaryIndicesLength; i++) { + const startBoundary = boundaryIndices[i]; + const endBoundary = body.indexOf("\r\n", startBoundary); + boundaryPairs.push({ start: startBoundary, end: endBoundary }); + } + + let bodies = []; + + for (let i = 0; i < boundaryIndicesLength - 1; i++) { + const firstPair = boundaryPairs[i]; + const secondPair = boundaryPairs[i + 1]; + const newFrom = firstPair.end + 2; + const newTo = secondPair.start - 2; + const bodyForBoundary = calculateParts(body, newFrom, newTo, realBoundary); + bodies = bodies.concat(bodyForBoundary); + } + + return bodies; +} + +function parsePartsHeaders(mimeBody, parts) { + const result = []; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const indices = part["indices"]; + let headersEnd = mimeBody + .substring(indices.from, indices.to) + .indexOf("\r\n\r\n"); + if (headersEnd < 0) { + headersEnd = indices.from; + } else { + headersEnd = headersEnd + indices.from + 4; + } + part["indices"].headersEnd = headersEnd; + + part["headers"] = libmime.decodeHeaders( + mimeBody.substring(indices.from, headersEnd) + ); + result.push(part); + } + + return result; +} + +export function fixNewLines(mime) { + return mime.replace(newline, "\r\n"); +} + +export function parseMIME(mime) { + let mimeStart = 0; + let headersEnd = mime.indexOf("\r\n\r\n"); //the first empty line + let mimeBody = ""; + if (headersEnd < 0 && mime.startsWith("\r\n")) { + mime = mime.substring(2); //should not happen + mimeBody = mime; + mimeStart = 0; + } else { + mimeBody = mime.substring(headersEnd + 4); + mimeStart = headersEnd + 4; + } + + let headers = libmime.decodeHeaders(mime.substring(0, headersEnd)); + + let indexOfSMIME = mimeBody.indexOf(SMIMEStart); + + if (indexOfSMIME >= 0) { + mimeBody = mimeBody.substring(indexOfSMIME + SMIMEStart.length); + mimeStart += indexOfSMIME + SMIMEStart.length; + } + + mimeBody = "\r\n" + mimeBody + "\r\n"; + const mimeBodyLen = mimeBody.length - 1; + let parts = calculateParts(mimeBody, 0, mimeBodyLen, null); + parts = parsePartsHeaders(mimeBody, parts); + + for (let i = 0; i < parts.length; i++) { + parts[i].indices.from = parts[i].indices.from + (mimeStart - 2); + parts[i].indices.headersEnd = parts[i].indices.headersEnd + (mimeStart - 2); + parts[i].indices.to = parts[i].indices.to + (mimeStart - 2); + } + + parts.push({ + indices: { from: 0, to: mime.length, headersEnd: headersEnd }, + headers: headers, + boundary: "mimemessage" + }); + + return parts; +} + +function getHeaderValue(header, part) { + if (part.headers !== null && part.headers !== undefined) { + if (part.headers[header] !== null && part.headers[header] !== undefined) { + if (part.headers[header].length > 0) { + return part.headers[header]; + } else { + return null; + } + } else { + return null; + } + } else { + return null; + } +} + +export function getGlobalHeaderValue(header, parts) { + for (let i = 0; i < parts.length; i++) { + if (parts[i].boundary === "mimemessage") { + return getHeaderValue(header, parts[i]); + } + } + + return null; +} + +function getBody(mime, part) { + if (part.indices === null || part.indices === undefined) { + return null; + } + + const indices = part.indices; + + return mime.substring(indices.headersEnd, indices.to); +} + +export function decodeMimeBody(descriptor, mimeString) { + let mimeBody = mimeString.slice( + descriptor.indices.headersEnd, + descriptor.indices.to + ); + + let contentTransferEncoding = getHeaderValue( + "content-transfer-encoding", + descriptor + ); + if (contentTransferEncoding) { + contentTransferEncoding = contentTransferEncoding[0]; + } else { + return mimeBody; + } + + let charset = "utf8"; + let contentType = getHeaderValue("content-type", descriptor); + if (contentType) { + contentType = contentType[0]; + let parsedContentType = libmime.parseHeaderValue(contentType); + if ( + parsedContentType && + parsedContentType.params && + parsedContentType.params.charset + ) { + if (parsedContentType.params.charset.toLowerCase() === "us-ascii") { + charset = "ascii"; + } else if (Buffer.isEncoding(parsedContentType.params.charset)) { + charset = parsedContentType.params.charset; + } else { + //TODO log the charset and make sure we can support it + } + } + } + + if (contentTransferEncoding.toLowerCase() === "quoted-printable") { + let buff = libqp.decode(mimeBody); + return buff.toString(charset); + } else if (contentTransferEncoding.toLowerCase() === "base64") { + let buff = Buffer.from(mimeBody, "base64"); + return buff.toString(charset); + } + + return mimeBody; +} + +export function getHTML(mime, parts) { + let html; + let htmlPart = null; + for (let i = 0; i < parts.length; i++) { + if (parts[i].boundary === "mimemessage") { + continue; + } + let contentType = getHeaderValue("content-type", parts[i]); + if (contentType === null || contentType === undefined) { + continue; + } + + contentType = contentType[0]; + if (contentType.indexOf("text/html") >= 0) { + htmlPart = parts[i]; + break; + } + } + + html = decodeMimeBody(htmlPart, mime); + + for (let i = 0; i < parts.length; i++) { + let contentDisposition = getHeaderValue("content-disposition", parts[i]); + if (contentDisposition === null || contentDisposition === undefined) { + continue; + } + contentDisposition = contentDisposition[0]; + if (contentDisposition.indexOf("inline") >= 0) { + let contentId = getHeaderValue("content-id", parts[i]); + if (contentId === null || contentId === undefined) { + continue; + } + + contentId = contentId[0]; + const contentIdLen = contentId.length; + contentId = contentId.substring(1, contentIdLen - 1); + contentId = "cid:" + contentId; + let contentType = getHeaderValue("content-type", parts[i]); + if (contentType === null || contentType === undefined) { + continue; + } + contentType = contentType[0]; + const normalizedBody = getBody(mime, parts[i]).replace(newline, ""); + const src = "data:" + contentType + ";base64, " + normalizedBody; + html = html.split(contentId).join(src); + } + } + return html; +} + +export function getPlain(mime, parts) { + let plain; + let plainPart = null; + for (let i = 0; i < parts.length; i++) { + let contentType = getHeaderValue("content-type", parts[i]); + if (contentType === null || contentType === undefined) { + continue; + } + contentType = contentType[0]; + if (contentType.indexOf("text/plain") >= 0) { + plainPart = parts[i]; + break; + } + } + + plain = decodeMimeBody(plainPart, mime); + return plain; +} + +export function getAttachments(mime, parts) { + const attachments = []; + for (let i = 0; i < parts.length; i++) { + let contentDisposition = getHeaderValue("content-disposition", parts[i]); + if (contentDisposition === null || contentDisposition === undefined) { + continue; + } + contentDisposition = contentDisposition[0]; + if (contentDisposition.indexOf("attachment") >= 0) { + attachments.push(parts[i]); + } + } + + return attachments; +} + +export function getAttachment(mime, part) { + let contentType = getHeaderValue("content-type", part); + if (contentType === null || contentType === undefined) { + return null; + } + contentType = contentType[0]; + let contentTransferEncoding = getHeaderValue( + "content-transfer-encoding", + part + ); + if (contentTransferEncoding) { + contentTransferEncoding = contentTransferEncoding[0]; + } else { + return null; + } + + let base64; + if (contentTransferEncoding.toLowerCase() === "base64") { + base64 = getBody(mime, part).replace(newline, ""); + } else { + const body = decodeMimeBody(part, mime); + base64 = window.btoa(body); + } + + return { contentType, base64 } +} diff --git a/javascript/src/iframe/viamapi-iframe.js b/javascript/src/iframe/viamapi-iframe.js index ff4a071dc6b1c28a3aa696efa98cc9696f1690c0..cf62bf4b9dcc0be361911a1c03f7aad5c417c0c1 100644 --- a/javascript/src/iframe/viamapi-iframe.js +++ b/javascript/src/iframe/viamapi-iframe.js @@ -1,3 +1,5 @@ +import { parseSMIME } from '../utilities/emailUtilities'; + const QRCode = require('qrcode'); const Penpal = require('penpal').default; @@ -7,7 +9,7 @@ import { encodeResponse, listIdentitiesFromLocalStorage, makeid } from '../utilities/appUtility'; -import {LOGIN_MODES} from '../constants'; +import {LOGIN_MODES} from '../constants/authentication'; import { createOneTimePassportCertificate, createPassportCertificate, @@ -718,6 +720,7 @@ const connection = Penpal.connectToParent({ }); }); }, + parseSMIME, getCurrentlyLoggedInUUID() { return new Penpal.Promise(result => { const authenticationPublicKey = localStorage.getItem("authenticatedIdentity"); diff --git a/javascript/src/utilities/dateTimeUtilities.js b/javascript/src/utilities/dateTimeUtilities.js new file mode 100644 index 0000000000000000000000000000000000000000..3b7c54e146b6b2d5d5401087f2875f54abc0e437 --- /dev/null +++ b/javascript/src/utilities/dateTimeUtilities.js @@ -0,0 +1,7 @@ +export const formatDateNumeric = date => + date.toLocaleDateString("en-US", { + year: "numeric", + month: "numeric", + day: "numeric" + }); + diff --git a/javascript/src/utilities/emailUtilities.js b/javascript/src/utilities/emailUtilities.js new file mode 100644 index 0000000000000000000000000000000000000000..7bc1971b2afc9248124784529618b4fafd2ef936 --- /dev/null +++ b/javascript/src/utilities/emailUtilities.js @@ -0,0 +1,104 @@ +import dataUriToBlob from "data-uri-to-blob"; +import libmime from 'libmime'; + +import { + fixNewLines, + parseMIME, + getHTML, + getPlain, + getAttachments, + getAttachment, + getGlobalHeaderValue +} from "../helpers/mailparser"; +import { getCertificateChain } from "./signingUtilities"; + +const SIGNATURE_CONTENT_TYPE = "application/pkcs7-signature"; +export const DEFAULT_ATTACHMENT_NAME = 'attachment'; + +export const parseSMIME = smimeString => { + return new Promise(resolve => { + setTimeout(async () => { + const emailString = fixNewLines(smimeString); + const parts = parseMIME(emailString); + const html = getHTML(emailString, parts); + const plain = getPlain(emailString, parts); + const rawAttachments = getAttachments(emailString, parts); + + const attachments = []; + let signatureBase64; + for (const rawAttachment of rawAttachments) { + const { contentType, base64 } = getAttachment( + emailString, + rawAttachment + ); + + if (contentType.indexOf(SIGNATURE_CONTENT_TYPE) !== -1) { + signatureBase64 = base64; + } + + const dataURI = "data:" + contentType + ";base64, " + base64; + const blob = dataUriToBlob(dataURI); + const filename = getFilenameFromHeaders(rawAttachment.headers); + + attachments.push({ + blob, + filename + }); + } + + const certificateChain = getCertificateChain(signatureBase64); + + const message = { + from: getGlobalHeaderValue("from", parts), + to: getGlobalHeaderValue("to", parts), + cc: getGlobalHeaderValue("cc", parts), + subject: getGlobalHeaderValue("subject", parts).join(" "), + html: extractHtmlBodyFromString(html), + plain, + attachments, + certificateChain + }; + + resolve(message); + }, 50); + }); +}; + +/** + * Function extracts file name from content type header + * @param headers + * @returns {string} ('file.txt') + */ +export const getFilenameFromHeaders = headers => { + const headersToSearch = ['content-type', 'content-disposition']; + + const filename = headers && Object.keys(headers) + .filter(key => headersToSearch.includes(key)) + .reduce((result, key) => { + const headerValue = libmime.parseHeaderValue(headers[key][0]); + return result || headerValue.params.name || headerValue.params.filename; + }, ''); + + return filename || DEFAULT_ATTACHMENT_NAME; +}; + +/** + * Function extracts all tags within <body></body> from provided string + * and removes whitespaces between tags and HTML comments. + * @param string + * @returns {*} + */ +export const extractHtmlBodyFromString = string => { + const extractBodyRegex = /<body.*?>([\s\S]+)<\/body>/gm; + const bodyMatch = extractBodyRegex.exec(string); + + let body; + + if (bodyMatch && bodyMatch[1]) { + body = bodyMatch[1] + .replace(/>\s+</gm, '><') + .replace(/<!--[\s\S]*?-->/gm, '').trim() + } + + return body; +}; \ No newline at end of file diff --git a/javascript/src/utilities/signingUtilities.js b/javascript/src/utilities/signingUtilities.js index dad16e412bdaa725838b6560b4faa7b71fb79a0f..3dd93bf140e5de3f127d455f0ef37b0e02ce760f 100644 --- a/javascript/src/utilities/signingUtilities.js +++ b/javascript/src/utilities/signingUtilities.js @@ -1,4 +1,9 @@ import {canTryPincode, failPincodeAttempt, getTimeLeftInLocalStorage, makeid} from './appUtility'; +import {bufferToHexCodes, stringToArrayBuffer} from 'pvutils'; +import {fromBER} from 'asn1js'; +import {ContentInfo, SignedData} from 'pkijs'; +import {formatDateNumeric} from './dateTimeUtilities'; +import {algomap, rdnmap} from '../constants/certificates'; const libmime = require('libmime'); const pkijs = require('pkijs'); @@ -952,4 +957,84 @@ function capitalizeHeader(string) { function makeBoundary() { let len = 20 + Math.random() * 20; return 'W0RyLiBEYW15YW4gTWl0ZXZd--' + makeid(len) -} \ No newline at end of file +} + +export const parseCertificates = signatureBase64 => { + try { + const certificateDecoded = atob(signatureBase64); + const buffer = stringToArrayBuffer(certificateDecoded); + const asn1 = fromBER(buffer); + + const contentInfo = new ContentInfo({ schema: asn1.result }); + const signedData = new SignedData({ schema: contentInfo.content }); + + return signedData.certificates.map((certificate, index) => { + const certificateData = { issuer: {}, subject: {}, validity: {} }; + const serialNumber = bufferToHexCodes( + certificate.serialNumber.valueBlock.valueHex + ); + const issuer = certificate.issuer.typesAndValues; + const subject = certificate.subject.typesAndValues; + const notAfter = formatDateNumeric(certificate.notAfter.value); + const notBefore = formatDateNumeric(certificate.notBefore.value); + let signatureAlgorithm = + algomap[certificate.signatureAlgorithm.algorithmId]; + if (typeof signatureAlgorithm === "undefined") { + signatureAlgorithm = certificate.signatureAlgorithm.algorithmId; + } else { + signatureAlgorithm = `${signatureAlgorithm}`; + } + + for (const typeAndValue of issuer) { + let typeVal = rdnmap[typeAndValue.type]; + if (typeof typeVal === "undefined") { + typeVal = typeAndValue.type; + } + const subjVal = typeAndValue.value.valueBlock.value; + certificateData.issuer[typeVal] = subjVal; + } + + for (const typeAndValue of subject) { + let typeVal = rdnmap[typeAndValue.type]; + if (typeof typeVal === "undefined") { + typeVal = typeAndValue.type; + } + const subjVal = typeAndValue.value.valueBlock.value; + certificateData.subject[typeVal] = subjVal; + } + + certificateData.signatureAlgorithm = signatureAlgorithm; + certificateData.serialNumber = serialNumber; + certificateData.validity = { + notAfter, + notBefore + }; + + return certificateData; + }); + } catch (e) { + console.error("Error parsing certificate", e); + } +}; + +export const getCertificateChain = signatureBase64 => { + const certificateChain = []; + + try { + const certificates = parseCertificates(signatureBase64); + + // Add first certificate in the chain + certificateChain.push(certificates[0]); + + // Go through all certificates to build a chain from first certificate to the root + certificates.forEach(certificate => { + if (certificateChain[0].issuer.commonName === certificate.subject.commonName) { + certificateChain.unshift(certificate); + } + }); + } catch (e) { + console.warn("Error getting certificate data", e); + } + + return certificateChain; +}; diff --git a/javascript/yarn.lock b/javascript/yarn.lock index 78cbf18503b123887b2b4ff2da0c717160a9a320..e1ea2f56622300f72230db5db0cfa7c949639441 100644 --- a/javascript/yarn.lock +++ b/javascript/yarn.lock @@ -1478,6 +1478,10 @@ damerau-levenshtein@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" +data-uri-to-blob@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/data-uri-to-blob/-/data-uri-to-blob-0.0.4.tgz#087a7bff42f41a6cc0b2e2fb7312a7c29904fbaa" + date-now@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" @@ -2900,7 +2904,7 @@ libmime@^4.0.1: libbase64 "1.0.3" libqp "1.1.0" -libqp@1.1.0: +libqp@1.1.0, libqp@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/libqp/-/libqp-1.1.0.tgz#f5e6e06ad74b794fb5b5b66988bf728ef1dedbe8"