This repository was archived by the owner on Sep 11, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 819
Megolm session import and export #617
Merged
Merged
Changes from 4 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1d5d44d
TextEncoder polyfill
richvdh f8e5677
Encryption and decryption for megolm backups
richvdh d63f7e8
Expose megolm import/export via the devtools
richvdh e37bf6b
Skip crypto tests on PhantomJS
richvdh 09ce74c
Fix a couple of minor review comments
richvdh 31df78f
Use text-encoding-utf-8 as a TextEncoder polyfill
richvdh 8b60cb9
Megolm export: Clear bit 63 of the salt
richvdh fdc213c
Megolm export: fix test
richvdh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,312 @@ | ||
/* | ||
Copyright 2017 Vector Creations Ltd | ||
|
||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
|
||
http://www.apache.org/licenses/LICENSE-2.0 | ||
|
||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
*/ | ||
|
||
"use strict"; | ||
|
||
// polyfill textencoder if necessary | ||
let TextEncoder = window.TextEncoder; | ||
if (!TextEncoder) { | ||
TextEncoder = require('./TextEncoderPolyfill'); | ||
} | ||
let TextDecoder = window.TextDecoder; | ||
if (TextDecoder) { | ||
TextDecoder = require('./TextDecoderPolyfill'); | ||
} | ||
|
||
const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle; | ||
|
||
/** | ||
* Decrypt a megolm key file | ||
* | ||
* @param {ArrayBuffer} file | ||
* @param {String} password | ||
* @return {Promise<String>} promise for decrypted output | ||
*/ | ||
export function decryptMegolmKeyFile(data, password) { | ||
const body = unpackMegolmKeyFile(data); | ||
|
||
// check we have a version byte | ||
if (body.length < 1) { | ||
throw new Error('Invalid file: too short'); | ||
} | ||
|
||
const version = body[0]; | ||
if (version !== 1) { | ||
throw new Error('Unsupported version'); | ||
} | ||
|
||
const ciphertextLength = body.length-(1+16+16+4+32); | ||
if (body.length < 0) { | ||
throw new Error('Invalid file: too short'); | ||
} | ||
|
||
const salt = body.subarray(1, 1+16); | ||
const iv = body.subarray(17, 17+16); | ||
const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36]; | ||
const ciphertext = body.subarray(37, 37+ciphertextLength); | ||
const hmac = body.subarray(-32); | ||
|
||
return deriveKeys(salt, iterations, password).then((keys) => { | ||
const [aes_key, sha_key] = keys; | ||
|
||
const toVerify = body.subarray(0, -32); | ||
return subtleCrypto.verify( | ||
{name: 'HMAC'}, | ||
sha_key, | ||
hmac, | ||
toVerify, | ||
).then((isValid) => { | ||
if (!isValid) { | ||
throw new Error('Authentication check failed: incorrect password?') | ||
} | ||
|
||
return subtleCrypto.decrypt( | ||
{ | ||
name: "AES-CTR", | ||
counter: iv, | ||
length: 64, | ||
}, | ||
aes_key, | ||
ciphertext, | ||
); | ||
}); | ||
}).then((plaintext) => { | ||
return new TextDecoder().decode(new Uint8Array(plaintext)); | ||
}); | ||
} | ||
|
||
|
||
/** | ||
* Encrypt a megolm key file | ||
* | ||
* @param {String} data | ||
* @param {String} password | ||
* @param {Object=} options | ||
* @param {Nunber=} options.kdf_rounds Number of iterations to perform of the | ||
* key-derivation function. | ||
* @return {Promise<ArrayBuffer>} promise for encrypted output | ||
*/ | ||
export function encryptMegolmKeyFile(data, password, options) { | ||
options = options || {}; | ||
const kdf_rounds = options.kdf_rounds || 100000; | ||
|
||
const salt = new Uint8Array(16); | ||
window.crypto.getRandomValues(salt); | ||
const iv = new Uint8Array(16); | ||
window.crypto.getRandomValues(iv); | ||
|
||
return deriveKeys(salt, kdf_rounds, password).then((keys) => { | ||
const [aes_key, sha_key] = keys; | ||
|
||
return subtleCrypto.encrypt( | ||
{ | ||
name: "AES-CTR", | ||
counter: iv, | ||
length: 64, | ||
}, | ||
aes_key, | ||
new TextEncoder().encode(data), | ||
).then((ciphertext) => { | ||
const cipherArray = new Uint8Array(ciphertext); | ||
const bodyLength = (1+salt.length+iv.length+4+cipherArray.length+32); | ||
const resultBuffer = new Uint8Array(bodyLength); | ||
let idx = 0; | ||
resultBuffer[idx++] = 1; // version | ||
resultBuffer.set(salt, idx); idx += salt.length; | ||
resultBuffer.set(iv, idx); idx += iv.length; | ||
resultBuffer[idx++] = kdf_rounds >> 24; | ||
resultBuffer[idx++] = (kdf_rounds >> 16) & 0xff; | ||
resultBuffer[idx++] = (kdf_rounds >> 8) & 0xff; | ||
resultBuffer[idx++] = kdf_rounds & 0xff; | ||
resultBuffer.set(cipherArray, idx); idx += cipherArray.length; | ||
|
||
const toSign = resultBuffer.subarray(0, idx); | ||
|
||
return subtleCrypto.sign( | ||
{name: 'HMAC'}, | ||
sha_key, | ||
toSign, | ||
).then((hmac) => { | ||
hmac = new Uint8Array(hmac); | ||
resultBuffer.set(hmac, idx); | ||
return packMegolmKeyFile(resultBuffer); | ||
}); | ||
}); | ||
}); | ||
} | ||
|
||
/** | ||
* Derive the AES and SHA keys for the file | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should probably use "hmac key" or "hmac-sha256 key" rather than "sha key" for referring to the hmac key. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yup. done. |
||
* | ||
* @param {Unit8Array} salt salt for pbkdf | ||
* @param {Number} iterations number of pbkdf iterations | ||
* @param {String} password password | ||
* @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, sha key] | ||
*/ | ||
function deriveKeys(salt, iterations, password) { | ||
return subtleCrypto.importKey( | ||
'raw', | ||
new TextEncoder().encode(password), | ||
{name: 'PBKDF2'}, | ||
false, | ||
['deriveBits'] | ||
).then((key) => { | ||
return subtleCrypto.deriveBits( | ||
{ | ||
name: 'PBKDF2', | ||
salt: salt, | ||
iterations: iterations, | ||
hash: 'SHA-512', | ||
}, | ||
key, | ||
512 | ||
); | ||
}).then((keybits) => { | ||
const aes_key = keybits.slice(0, 32); | ||
const sha_key = keybits.slice(32); | ||
|
||
const aes_prom = subtleCrypto.importKey( | ||
'raw', | ||
aes_key, | ||
{name: 'AES-CTR'}, | ||
false, | ||
['encrypt', 'decrypt'] | ||
); | ||
const sha_prom = subtleCrypto.importKey( | ||
'raw', | ||
sha_key, | ||
{ | ||
name: 'HMAC', | ||
hash: {name: 'SHA-256'}, | ||
}, | ||
false, | ||
['sign', 'verify'] | ||
); | ||
return Promise.all([aes_prom, sha_prom]); | ||
}); | ||
} | ||
|
||
const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----'; | ||
const TRAILER_LINE = '-----END MEGOLM SESSION DATA-----'; | ||
|
||
/** | ||
* Unbase64 an ascii-armoured megolm key file | ||
* | ||
* Strips the header and trailer lines, and unbase64s the content | ||
* | ||
* @param {ArrayBuffer} data input file | ||
* @return {Uint8Array} unbase64ed content | ||
*/ | ||
function unpackMegolmKeyFile(data) { | ||
// parse the file as a great big String. This should be safe, because there | ||
// should be no non-ASCII characters, and it means that we can do string | ||
// comparisons to find the header and footer, and feed it into window.atob. | ||
const fileStr = new TextDecoder().decode(new Uint8Array(data)); | ||
|
||
// look for the start line | ||
let lineStart = 0; | ||
while (1) { | ||
const lineEnd = fileStr.indexOf('\n', lineStart); | ||
if (lineEnd < 0) { | ||
throw new Error('Header line not found'); | ||
} | ||
const line = fileStr.slice(lineStart, lineEnd).trim(); | ||
|
||
// start the next line after the newline | ||
lineStart = lineEnd+1; | ||
|
||
if (line === HEADER_LINE) { | ||
break; | ||
} | ||
} | ||
|
||
const dataStart = lineStart; | ||
|
||
// look for the end line | ||
while (1) { | ||
const lineEnd = fileStr.indexOf('\n', lineStart); | ||
const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd) | ||
.trim(); | ||
if (line === TRAILER_LINE) { | ||
break; | ||
} | ||
|
||
if (lineEnd < 0) { | ||
throw new Error('Trailer line not found'); | ||
} | ||
|
||
// start the next line after the newline | ||
lineStart = lineEnd+1; | ||
} | ||
|
||
const dataEnd = lineStart; | ||
return decodeBase64(fileStr.slice(dataStart, dataEnd)); | ||
} | ||
|
||
/** | ||
* ascii-armour a megolm key file | ||
* | ||
* base64s the content, and adds header and trailer lines | ||
* | ||
* @param {Uint8Array} data raw data | ||
* @return {ArrayBuffer} formatted file | ||
*/ | ||
function packMegolmKeyFile(data) { | ||
// we split into lines before base64ing, because encodeBase64 doesn't deal | ||
// terribly well with large arrays. | ||
const LINE_LENGTH = (72 * 4 / 3); | ||
const nLines = Math.ceil(data.length / LINE_LENGTH); | ||
const lines = new Array(nLines + 3); | ||
lines[0] = HEADER_LINE; | ||
let o = 0; | ||
let i; | ||
for (i = 1; i <= nLines; i++) { | ||
lines[i] = encodeBase64(data.subarray(o, o+LINE_LENGTH)); | ||
o += LINE_LENGTH; | ||
} | ||
lines[i++] = TRAILER_LINE; | ||
lines[i] = ''; | ||
return (new TextEncoder().encode(lines.join('\n'))).buffer; | ||
} | ||
|
||
/** | ||
* Encode a typed array of uint8 as base64. | ||
* @param {Uint8Array} uint8Array The data to encode. | ||
* @return {string} The base64. | ||
*/ | ||
function encodeBase64(uint8Array) { | ||
// Misinterpt the Uint8Array as Latin-1. | ||
// window.btoa expects a unicode string with codepoints in the range 0-255. | ||
var latin1String = String.fromCharCode.apply(null, uint8Array); | ||
// Use the builtin base64 encoder. | ||
return window.btoa(latin1String); | ||
} | ||
|
||
/** | ||
* Decode a base64 string to a typed array of uint8. | ||
* @param {string} base64 The base64 to decode. | ||
* @return {Uint8Array} The decoded data. | ||
*/ | ||
function decodeBase64(base64) { | ||
// window.atob returns a unicode string with codepoints in the range 0-255. | ||
var latin1String = window.atob(base64); | ||
// Encode the string as a Uint8Array | ||
var uint8Array = new Uint8Array(latin1String.length); | ||
for (var i = 0; i < latin1String.length; i++) { | ||
uint8Array[i] = latin1String.charCodeAt(i); | ||
} | ||
return uint8Array; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (ciphertextLength < 0), no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. fixed by #660