Encrypt device name on account create, on first launch if needed

This commit is contained in:
Scott Nonnenberg 2018-12-13 11:12:33 -08:00
parent 775e31c854
commit 47f834cf5c
10 changed files with 282 additions and 95 deletions

View File

@ -681,6 +681,7 @@
textsecure.storage.user.getDeviceId() != '1'
) {
window.getSyncRequest();
window.getAccountManager().maybeUpdateDeviceName();
}
const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery';

View File

@ -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);

View File

@ -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',

View File

@ -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 =

View File

@ -36,6 +36,9 @@
loadProtoBufs('SubProtocol.proto');
loadProtoBufs('DeviceMessages.proto');
// Just for encrypting device names
loadProtoBufs('DeviceName.proto');
// Metadata-specific protos
loadProtoBufs('UnidentifiedDelivery.proto');
})();

View File

@ -31,5 +31,13 @@
getDeviceName() {
return textsecure.storage.get('device_name');
},
setDeviceNameEncrypted() {
return textsecure.storage.put('deviceNameEncrypted', true);
},
getDeviceNameEncrypted() {
return textsecure.storage.get('deviceNameEncrypted');
},
};
})();

View File

@ -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 = [

7
protos/DeviceName.proto Normal file
View File

@ -0,0 +1,7 @@
package signalservice;
message DeviceName {
optional bytes ephemeralPublic = 1;
optional bytes syntheticIv = 2;
optional bytes ciphertext = 3;
}

View File

@ -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'
);
}
});
});
});

View File

@ -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"
},