From 47f834cf5ca7a761566624c7659beeab55910936 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 13 Dec 2018 11:12:33 -0800 Subject: [PATCH] Encrypt device name on account create, on first launch if needed --- js/background.js | 1 + js/modules/crypto.js | 53 ++++- js/modules/web_api.js | 12 ++ libtextsecure/account_manager.js | 220 +++++++++++++-------- libtextsecure/protobufs.js | 3 + libtextsecure/storage/user.js | 8 + libtextsecure/test/account_manager_test.js | 20 +- protos/DeviceName.proto | 7 + test/crypto_test.js | 39 +++- ts/util/lint/exceptions.json | 14 +- 10 files changed, 282 insertions(+), 95 deletions(-) create mode 100644 protos/DeviceName.proto diff --git a/js/background.js b/js/background.js index 4b7cd9107f..3482e65154 100644 --- a/js/background.js +++ b/js/background.js @@ -681,6 +681,7 @@ textsecure.storage.user.getDeviceId() != '1' ) { window.getSyncRequest(); + window.getAccountManager().maybeUpdateDeviceName(); } const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery'; diff --git a/js/modules/crypto.js b/js/modules/crypto.js index 0071e89435..31f9fb97d5 100644 --- a/js/modules/crypto.js +++ b/js/modules/crypto.js @@ -1,5 +1,5 @@ /* eslint-env browser */ -/* global dcodeIO */ +/* global dcodeIO, libsignal */ /* eslint-disable camelcase, no-bitwise */ @@ -10,9 +10,11 @@ module.exports = { concatenateBytes, constantTimeEqual, decryptAesCtr, + decryptDeviceName, decryptSymmetric, deriveAccessKey, encryptAesCtr, + encryptDeviceName, encryptSymmetric, fromEncodedBinaryToArrayBuffer, getAccessKeyVerifier, @@ -30,6 +32,55 @@ module.exports = { // High-level Operations +async function encryptDeviceName(deviceName, identityPublic) { + const plaintext = bytesFromString(deviceName); + const ephemeralKeyPair = await libsignal.KeyHelper.generateIdentityKeyPair(); + const masterSecret = await libsignal.Curve.async.calculateAgreement( + identityPublic, + ephemeralKeyPair.privKey + ); + + const key1 = await hmacSha256(masterSecret, bytesFromString('auth')); + const syntheticIv = _getFirstBytes(await hmacSha256(key1, plaintext), 16); + + const key2 = await hmacSha256(masterSecret, bytesFromString('cipher')); + const cipherKey = await hmacSha256(key2, syntheticIv); + + const counter = getZeroes(16); + const ciphertext = await encryptAesCtr(cipherKey, plaintext, counter); + + return { + ephemeralPublic: ephemeralKeyPair.pubKey, + syntheticIv, + ciphertext, + }; +} + +async function decryptDeviceName( + { ephemeralPublic, syntheticIv, ciphertext } = {}, + identityPrivate +) { + const masterSecret = await libsignal.Curve.async.calculateAgreement( + ephemeralPublic, + identityPrivate + ); + + const key2 = await hmacSha256(masterSecret, bytesFromString('cipher')); + const cipherKey = await hmacSha256(key2, syntheticIv); + + const counter = getZeroes(16); + const plaintext = await decryptAesCtr(cipherKey, ciphertext, counter); + + const key1 = await hmacSha256(masterSecret, bytesFromString('auth')); + const ourSyntheticIv = _getFirstBytes(await hmacSha256(key1, plaintext), 16); + + if (!constantTimeEqual(ourSyntheticIv, syntheticIv)) { + throw new Error('decryptDeviceName: synthetic IV did not match'); + } + + return stringFromBytes(plaintext); +} + async function deriveAccessKey(profileKey) { const iv = getZeroes(12); const plaintext = getZeroes(16); diff --git a/js/modules/web_api.js b/js/modules/web_api.js index ec2748392b..fb5ca5308e 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -325,6 +325,7 @@ function HTTPError(message, providedCode, response, stack) { const URL_CALLS = { accounts: 'v1/accounts', + updateDeviceName: 'v1/accounts/name', attachment: 'v1/attachments', deliveryCert: 'v1/certificate/delivery', supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', @@ -386,6 +387,7 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { sendMessages, sendMessagesUnauth, setSignedPreKey, + updateDeviceName, }; function _ajax(param) { @@ -568,6 +570,16 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { return response; } + function updateDeviceName(deviceName) { + return _ajax({ + call: 'updateDeviceName', + httpType: 'PUT', + jsonData: { + deviceName, + }, + }); + } + function getDevices() { return _ajax({ call: 'devices', diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index 960c18188e..443c82e66f 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -4,6 +4,7 @@ libsignal, WebSocketResource, btoa, + Signal, getString, libphonenumber, Event, @@ -45,6 +46,59 @@ requestSMSVerification(number) { return this.server.requestVerificationSMS(number); }, + async encryptDeviceName(name, providedIdentityKey) { + const identityKey = + providedIdentityKey || + (await textsecure.storage.protocol.getIdentityKeyPair()); + if (!identityKey) { + throw new Error( + 'Identity key was not provided and is not in database!' + ); + } + const encrypted = await Signal.Crypto.encryptDeviceName( + name, + identityKey.pubKey + ); + + const proto = new textsecure.protobuf.DeviceName(); + proto.ephemeralPublic = encrypted.ephemeralPublic; + proto.syntheticIv = encrypted.syntheticIv; + proto.ciphertext = encrypted.ciphertext; + + const arrayBuffer = proto.encode().toArrayBuffer(); + return Signal.Crypto.arrayBufferToBase64(arrayBuffer); + }, + async decryptDeviceName(base64) { + const identityKey = await textsecure.storage.protocol.getIdentityKeyPair(); + + const arrayBuffer = Signal.Crypto.base64ToArrayBuffer(base64); + const proto = textsecure.protobuf.DeviceName.decode(arrayBuffer); + const encrypted = { + ephemeralPublic: proto.ephemeralPublic.toArrayBuffer(), + syntheticIv: proto.syntheticIv.toArrayBuffer(), + ciphertext: proto.ciphertext.toArrayBuffer(), + }; + + const name = await Signal.Crypto.decryptDeviceName( + encrypted, + identityKey.privKey + ); + + return name; + }, + async maybeUpdateDeviceName() { + const isNameEncrypted = textsecure.storage.user.getDeviceNameEncrypted(); + if (isNameEncrypted) { + return; + } + const deviceName = await textsecure.storage.user.getDeviceName(); + const base64 = await this.encryptDeviceName(deviceName); + + await this.server.updateDeviceName(base64); + }, + async deviceNameIsEncrypted() { + await textsecure.storage.user.setDeviceNameEncrypted(); + }, registerSingleDevice(number, verificationCode) { const registerKeys = this.server.registerKeys.bind(this.server); const createAccount = this.createAccount.bind(this); @@ -335,7 +389,7 @@ }); }); }, - createAccount( + async createAccount( number, verificationCode, identityKeyPair, @@ -353,110 +407,106 @@ const previousNumber = getNumber(textsecure.storage.get('number_id')); - return this.server - .confirmCode( - number, - verificationCode, - password, - signalingKey, - registrationId, - deviceName, - { accessKey } - ) - .then(response => { - if (previousNumber && previousNumber !== number) { - window.log.warn( - 'New number is different from old number; deleting all previous data' - ); + const encryptedDeviceName = await this.encryptDeviceName( + deviceName, + identityKeyPair + ); + await this.deviceNameIsEncrypted(); - return textsecure.storage.protocol.removeAllData().then( - () => { - window.log.info('Successfully deleted previous data'); - return response; - }, - error => { - window.log.error( - 'Something went wrong deleting data from previous number', - error && error.stack ? error.stack : error - ); + const response = await this.server.confirmCode( + number, + verificationCode, + password, + signalingKey, + registrationId, + encryptedDeviceName, + { accessKey } + ); - return response; - } - ); - } + if (previousNumber && previousNumber !== number) { + window.log.warn( + 'New number is different from old number; deleting all previous data' + ); - return response; - }) - .then(async response => { - await Promise.all([ - textsecure.storage.remove('identityKey'), - textsecure.storage.remove('signaling_key'), - textsecure.storage.remove('password'), - textsecure.storage.remove('registrationId'), - textsecure.storage.remove('number_id'), - textsecure.storage.remove('device_name'), - textsecure.storage.remove('regionCode'), - textsecure.storage.remove('userAgent'), - textsecure.storage.remove('profileKey'), - textsecure.storage.remove('read-receipts-setting'), - ]); - - // update our own identity key, which may have changed - // if we're relinking after a reinstall on the master device - await textsecure.storage.protocol.saveIdentityWithAttributes(number, { - id: number, - publicKey: identityKeyPair.pubKey, - firstUse: true, - timestamp: Date.now(), - verified: textsecure.storage.protocol.VerifiedStatus.VERIFIED, - nonblockingApproval: true, - }); - - await textsecure.storage.put('identityKey', identityKeyPair); - await textsecure.storage.put('signaling_key', signalingKey); - await textsecure.storage.put('password', password); - await textsecure.storage.put('registrationId', registrationId); - if (profileKey) { - await textsecure.storage.put('profileKey', profileKey); - } - if (userAgent) { - await textsecure.storage.put('userAgent', userAgent); - } - - await textsecure.storage.put( - 'read-receipt-setting', - Boolean(readReceipts) + try { + await textsecure.storage.protocol.removeAllData(); + window.log.info('Successfully deleted previous data'); + } catch (error) { + window.log.error( + 'Something went wrong deleting data from previous number', + error && error.stack ? error.stack : error ); + } + } - await textsecure.storage.user.setNumberAndDeviceId( - number, - response.deviceId || 1, - deviceName - ); - await textsecure.storage.put( - 'regionCode', - libphonenumber.util.getRegionCodeForNumber(number) - ); - }); + await Promise.all([ + textsecure.storage.remove('identityKey'), + textsecure.storage.remove('signaling_key'), + textsecure.storage.remove('password'), + textsecure.storage.remove('registrationId'), + textsecure.storage.remove('number_id'), + textsecure.storage.remove('device_name'), + textsecure.storage.remove('regionCode'), + textsecure.storage.remove('userAgent'), + textsecure.storage.remove('profileKey'), + textsecure.storage.remove('read-receipts-setting'), + ]); + + // update our own identity key, which may have changed + // if we're relinking after a reinstall on the master device + await textsecure.storage.protocol.saveIdentityWithAttributes(number, { + id: number, + publicKey: identityKeyPair.pubKey, + firstUse: true, + timestamp: Date.now(), + verified: textsecure.storage.protocol.VerifiedStatus.VERIFIED, + nonblockingApproval: true, + }); + + await textsecure.storage.put('identityKey', identityKeyPair); + await textsecure.storage.put('signaling_key', signalingKey); + await textsecure.storage.put('password', password); + await textsecure.storage.put('registrationId', registrationId); + if (profileKey) { + await textsecure.storage.put('profileKey', profileKey); + } + if (userAgent) { + await textsecure.storage.put('userAgent', userAgent); + } + + await textsecure.storage.put( + 'read-receipt-setting', + Boolean(readReceipts) + ); + + await textsecure.storage.user.setNumberAndDeviceId( + number, + response.deviceId || 1, + deviceName + ); + await textsecure.storage.put( + 'regionCode', + libphonenumber.util.getRegionCodeForNumber(number) + ); }, - clearSessionsAndPreKeys() { + async clearSessionsAndPreKeys() { const store = textsecure.storage.protocol; window.log.info('clearing all sessions, prekeys, and signed prekeys'); - return Promise.all([ + await Promise.all([ store.clearPreKeyStore(), store.clearSignedPreKeysStore(), store.clearSessionStore(), ]); }, // Takes the same object returned by generateKeys - confirmKeys(keys) { + async confirmKeys(keys) { const store = textsecure.storage.protocol; const key = keys.signedPreKey; const confirmed = true; window.log.info('confirmKeys: confirming key', key.keyId); - return store.storeSignedPreKey(key.keyId, key.keyPair, confirmed); + await store.storeSignedPreKey(key.keyId, key.keyPair, confirmed); }, generateKeys(count, providedProgressCallback) { const progressCallback = diff --git a/libtextsecure/protobufs.js b/libtextsecure/protobufs.js index d2da5e2cc1..80c91e9e55 100644 --- a/libtextsecure/protobufs.js +++ b/libtextsecure/protobufs.js @@ -36,6 +36,9 @@ loadProtoBufs('SubProtocol.proto'); loadProtoBufs('DeviceMessages.proto'); + // Just for encrypting device names + loadProtoBufs('DeviceName.proto'); + // Metadata-specific protos loadProtoBufs('UnidentifiedDelivery.proto'); })(); diff --git a/libtextsecure/storage/user.js b/libtextsecure/storage/user.js index efbfd8e2db..e8b7e0fd55 100644 --- a/libtextsecure/storage/user.js +++ b/libtextsecure/storage/user.js @@ -31,5 +31,13 @@ getDeviceName() { return textsecure.storage.get('device_name'); }, + + setDeviceNameEncrypted() { + return textsecure.storage.put('deviceNameEncrypted', true); + }, + + getDeviceNameEncrypted() { + return textsecure.storage.get('deviceNameEncrypted'); + }, }; })(); diff --git a/libtextsecure/test/account_manager_test.js b/libtextsecure/test/account_manager_test.js index 12ffda788a..f1e98827d2 100644 --- a/libtextsecure/test/account_manager_test.js +++ b/libtextsecure/test/account_manager_test.js @@ -1,3 +1,5 @@ +/* global libsignal */ + describe('AccountManager', () => { let accountManager; @@ -10,9 +12,14 @@ describe('AccountManager', () => { let signedPreKeys; const DAY = 1000 * 60 * 60 * 24; - beforeEach(() => { + beforeEach(async () => { + const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair(); + originalProtocolStorage = window.textsecure.storage.protocol; window.textsecure.storage.protocol = { + getIdentityKeyPair() { + return identityKey; + }, loadSignedPreKeys() { return Promise.resolve(signedPreKeys); }, @@ -22,6 +29,17 @@ describe('AccountManager', () => { window.textsecure.storage.protocol = originalProtocolStorage; }); + describe('encrypted device name', () => { + it('roundtrips', async () => { + const deviceName = 'v2.5.0 on Ubunto 20.04'; + const encrypted = await accountManager.encryptDeviceName(deviceName); + assert.strictEqual(typeof encrypted, 'string'); + const decrypted = await accountManager.decryptDeviceName(encrypted); + + assert.strictEqual(decrypted, deviceName); + }); + }); + it('keeps three confirmed keys even if over a week old', () => { const now = Date.now(); signedPreKeys = [ diff --git a/protos/DeviceName.proto b/protos/DeviceName.proto new file mode 100644 index 0000000000..ec2859b187 --- /dev/null +++ b/protos/DeviceName.proto @@ -0,0 +1,7 @@ +package signalservice; + +message DeviceName { + optional bytes ephemeralPublic = 1; + optional bytes syntheticIv = 2; + optional bytes ciphertext = 3; +} diff --git a/test/crypto_test.js b/test/crypto_test.js index fe0800132a..a047c2b317 100644 --- a/test/crypto_test.js +++ b/test/crypto_test.js @@ -1,4 +1,4 @@ -/* global Signal, textsecure */ +/* global Signal, textsecure, libsignal */ 'use strict'; @@ -109,4 +109,41 @@ describe('Crypto', () => { throw new Error('Expected error to be thrown'); }); }); + + describe('encrypted device name', () => { + it('roundtrips', async () => { + const deviceName = 'v1.19.0 on Windows 10'; + const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair(); + + const encrypted = await Signal.Crypto.encryptDeviceName( + deviceName, + identityKey.pubKey + ); + const decrypted = await Signal.Crypto.decryptDeviceName( + encrypted, + identityKey.privKey + ); + + assert.strictEqual(decrypted, deviceName); + }); + + it('fails if iv is changed', async () => { + const deviceName = 'v1.19.0 on Windows 10'; + const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair(); + + const encrypted = await Signal.Crypto.encryptDeviceName( + deviceName, + identityKey.pubKey + ); + encrypted.syntheticIv = Signal.Crypto.getRandomBytes(16); + try { + await Signal.Crypto.decryptDeviceName(encrypted, identityKey.privKey); + } catch (error) { + assert.strictEqual( + error.message, + 'decryptDeviceName: synthetic IV did not match' + ); + } + }); + }); }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 37489874e4..6b264d1db5 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -244,7 +244,7 @@ "rule": "jQuery-wrap(", "path": "js/background.js", "line": " wrap(", - "lineNumber": 727, + "lineNumber": 728, "reasonCategory": "falseMatch", "updated": "2018-10-18T22:23:00.485Z" }, @@ -252,7 +252,7 @@ "rule": "jQuery-wrap(", "path": "js/background.js", "line": " await wrap(", - "lineNumber": 1257, + "lineNumber": 1258, "reasonCategory": "falseMatch", "updated": "2018-10-26T22:43:23.229Z" }, @@ -319,7 +319,7 @@ "rule": "jQuery-wrap(", "path": "js/modules/crypto.js", "line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');", - "lineNumber": 271, + "lineNumber": 322, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -327,7 +327,7 @@ "rule": "jQuery-wrap(", "path": "js/modules/crypto.js", "line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();", - "lineNumber": 274, + "lineNumber": 325, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -335,7 +335,7 @@ "rule": "jQuery-wrap(", "path": "js/modules/crypto.js", "line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();", - "lineNumber": 278, + "lineNumber": 329, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -343,7 +343,7 @@ "rule": "jQuery-wrap(", "path": "js/modules/crypto.js", "line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();", - "lineNumber": 282, + "lineNumber": 333, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -351,7 +351,7 @@ "rule": "jQuery-wrap(", "path": "js/modules/crypto.js", "line": " return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');", - "lineNumber": 285, + "lineNumber": 336, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" },