Handle both given and family name in decrypted profile name

* Decrypt given and family names from profile name string
* Handle both given and family name from decrypted profile name
* Ensure we properly handle profiles with no family name
This commit is contained in:
Scott Nonnenberg 2020-01-13 14:28:28 -08:00 committed by Ken Powers
parent 4f50c0b093
commit 11266cb775
9 changed files with 326 additions and 39 deletions

View file

@ -5,6 +5,7 @@ const sql = require('@journeyapps/sqlcipher');
const { app, dialog, clipboard } = require('electron');
const { redactAll } = require('../js/modules/privacy');
const { remove: removeUserConfig } = require('./user_config');
const { combineNames } = require('../ts/util/combineNames');
const pify = require('pify');
const uuidv4 = require('uuid/v4');
@ -938,18 +939,18 @@ async function updateToSchemaVersion15(currentVersion, instance) {
await instance.run('ALTER TABLE emojis RENAME TO emojis_old;');
await instance.run(`CREATE TABLE emojis(
shortName TEXT PRIMARY KEY,
lastUsage INTEGER
);`);
shortName TEXT PRIMARY KEY,
lastUsage INTEGER
);`);
await instance.run(`CREATE INDEX emojis_lastUsage
ON emojis (
lastUsage
);`);
ON emojis (
lastUsage
);`);
await instance.run('DELETE FROM emojis WHERE shortName = 1');
await instance.run(`INSERT INTO emojis(shortName, lastUsage)
SELECT shortName, lastUsage FROM emojis_old;
`);
SELECT shortName, lastUsage FROM emojis_old;
`);
await instance.run('DROP TABLE emojis_old;');
@ -1180,7 +1181,35 @@ async function updateToSchemaVersion18(currentVersion, instance) {
throw error;
}
}
async function updateToSchemaVersion19(currentVersion, instance) {
if (currentVersion >= 19) {
return;
}
console.log('updateToSchemaVersion19: starting...');
await instance.run('BEGIN TRANSACTION;');
await instance.run(
`ALTER TABLE conversations
ADD COLUMN profileFamilyName TEXT;`
);
await instance.run(
`ALTER TABLE conversations
ADD COLUMN profileFullName TEXT;`
);
// Preload new field with the profileName we already have
await instance.run('UPDATE conversations SET profileFullName = profileName');
try {
await instance.run('PRAGMA user_version = 19;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion19: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -1200,6 +1229,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion16,
updateToSchemaVersion17,
updateToSchemaVersion18,
updateToSchemaVersion19,
];
async function updateSchema(instance) {
@ -1605,8 +1635,16 @@ async function getConversationCount() {
}
async function saveConversation(data) {
// eslint-disable-next-line camelcase
const { id, active_at, type, members, name, profileName } = data;
const {
id,
// eslint-disable-next-line camelcase
active_at,
type,
members,
name,
profileName,
profileFamilyName,
} = data;
await db.run(
`INSERT INTO conversations (
@ -1617,7 +1655,9 @@ async function saveConversation(data) {
type,
members,
name,
profileName
profileName,
profileFamilyName,
profileFullName
) values (
$id,
$json,
@ -1626,7 +1666,9 @@ async function saveConversation(data) {
$type,
$members,
$name,
$profileName
$profileName,
$profileFamilyName,
$profileFullName
);`,
{
$id: id,
@ -1637,6 +1679,8 @@ async function saveConversation(data) {
$members: members ? members.join(' ') : null,
$name: name,
$profileName: profileName,
$profileFamilyName: profileFamilyName,
$profileFullName: combineNames(profileName, profileFamilyName),
}
);
}
@ -1660,8 +1704,16 @@ async function saveConversations(arrayOfConversations) {
saveConversations.needsSerial = true;
async function updateConversation(data) {
// eslint-disable-next-line camelcase
const { id, active_at, type, members, name, profileName } = data;
const {
id,
// eslint-disable-next-line camelcase
active_at,
type,
members,
name,
profileName,
profileFamilyName,
} = data;
await db.run(
`UPDATE conversations SET
@ -1671,7 +1723,9 @@ async function updateConversation(data) {
type = $type,
members = $members,
name = $name,
profileName = $profileName
profileName = $profileName,
profileFamilyName = $profileFamilyName,
profileFullName = $profileFullName
WHERE id = $id;`,
{
$id: id,
@ -1682,6 +1736,8 @@ async function updateConversation(data) {
$members: members ? members.join(' ') : null,
$name: name,
$profileName: profileName,
$profileFamilyName: profileFamilyName,
$profileFullName: combineNames(profileName, profileFamilyName),
}
);
}
@ -1769,14 +1825,14 @@ async function searchConversations(query, { limit } = {}) {
(
id LIKE $id OR
name LIKE $name OR
profileName LIKE $profileName
profileFullName LIKE $profileFullName
)
ORDER BY active_at DESC
LIMIT $limit`,
{
$id: `%${query}%`,
$name: `%${query}%`,
$profileName: `%${query}%`,
$profileFullName: `%${query}%`,
$limit: limit || 100,
}
);

View file

@ -1787,16 +1787,19 @@
const data = window.Signal.Crypto.base64ToArrayBuffer(encryptedName);
// decrypt
const decrypted = await textsecure.crypto.decryptProfileName(
const { given, family } = await textsecure.crypto.decryptProfileName(
data,
keyBuffer
);
// encode
const profileName = window.Signal.Crypto.stringFromBytes(decrypted);
const profileName = window.Signal.Crypto.stringFromBytes(given);
const profileFamilyName = family
? window.Signal.Crypto.stringFromBytes(family)
: null;
// set
this.set({ profileName });
this.set({ profileName, profileFamilyName });
},
async setProfileAvatar(avatarPath) {
if (!avatarPath) {
@ -1840,6 +1843,7 @@
profileKey,
accessKey: null,
profileName: null,
profileFamilyName: null,
profileAvatar: null,
sealedSender: SEALED_SENDER.UNKNOWN,
});
@ -1865,6 +1869,7 @@
profileAvatar: null,
profileKey: null,
profileName: null,
profileFamilyName: null,
accessKey: null,
sealedSender: SEALED_SENDER.UNKNOWN,
});
@ -1951,7 +1956,10 @@
getProfileName() {
if (this.isPrivate()) {
return this.get('profileName');
return Util.combineNames(
this.get('profileName'),
this.get('profileFamilyName')
);
}
return null;
},

View file

@ -9,7 +9,7 @@
const PROFILE_IV_LENGTH = 12; // bytes
const PROFILE_KEY_LENGTH = 32; // bytes
const PROFILE_TAG_LENGTH = 128; // bits
const PROFILE_NAME_PADDED_LENGTH = 26; // bytes
const PROFILE_NAME_PADDED_LENGTH = 53; // bytes
function verifyDigest(data, theirDigest) {
return crypto.subtle.digest({ name: 'SHA-256' }, data).then(ourDigest => {
@ -208,18 +208,39 @@
'base64'
).toArrayBuffer();
return textsecure.crypto.decryptProfile(data, key).then(decrypted => {
// unpad
const padded = new Uint8Array(decrypted);
let i;
for (i = padded.length; i > 0; i -= 1) {
if (padded[i - 1] !== 0x00) {
// Given name is the start of the string to the first null character
let givenEnd;
for (givenEnd = 0; givenEnd < padded.length; givenEnd += 1) {
if (padded[givenEnd] === 0x00) {
break;
}
}
return dcodeIO.ByteBuffer.wrap(padded)
.slice(0, i)
.toArrayBuffer();
// Family name is the next chunk of non-null characters after that first null
let familyEnd;
for (
familyEnd = givenEnd + 1;
familyEnd < padded.length;
familyEnd += 1
) {
if (padded[familyEnd] === 0x00) {
break;
}
}
const foundFamilyName = familyEnd > givenEnd + 1;
return {
given: dcodeIO.ByteBuffer.wrap(padded)
.slice(0, givenEnd)
.toArrayBuffer(),
family: foundFamilyName
? dcodeIO.ByteBuffer.wrap(padded)
.slice(givenEnd + 1, familyEnd)
.toArrayBuffer()
: null,
};
});
},

View file

@ -1,7 +1,7 @@
/* global libsignal, textsecure */
describe('encrypting and decrypting profile data', () => {
const NAME_PADDED_LENGTH = 26;
const NAME_PADDED_LENGTH = 53;
describe('encrypting and decrypting profile names', () => {
it('pads, encrypts, decrypts, and unpads a short string', () => {
const name = 'Alice';
@ -14,11 +14,78 @@ describe('encrypting and decrypting profile data', () => {
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
return textsecure.crypto
.decryptProfileName(encrypted, key)
.then(decrypted => {
.then(({ given, family }) => {
assert.strictEqual(family, null);
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8'),
dcodeIO.ByteBuffer.wrap(given).toString('utf8'),
name
);
});
});
});
it('handles a given name of the max, 53 characters', () => {
const name = '01234567890123456789012345678901234567890123456789123';
const buffer = dcodeIO.ByteBuffer.wrap(name).toArrayBuffer();
const key = libsignal.crypto.getRandomBytes(32);
return textsecure.crypto
.encryptProfileName(buffer, key)
.then(encrypted => {
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
return textsecure.crypto
.decryptProfileName(encrypted, key)
.then(({ given, family }) => {
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(given).toString('utf8'),
name
);
assert.strictEqual(family, null);
});
});
});
it('handles family/given name of the max, 53 characters', () => {
const name = '01234567890123456789\u000001234567890123456789012345678912';
const buffer = dcodeIO.ByteBuffer.wrap(name).toArrayBuffer();
const key = libsignal.crypto.getRandomBytes(32);
return textsecure.crypto
.encryptProfileName(buffer, key)
.then(encrypted => {
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
return textsecure.crypto
.decryptProfileName(encrypted, key)
.then(({ given, family }) => {
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(given).toString('utf8'),
'01234567890123456789'
);
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(family).toString('utf8'),
'01234567890123456789012345678912'
);
});
});
});
it('handles a string with family/given name', () => {
const name = 'Alice\0Jones';
const buffer = dcodeIO.ByteBuffer.wrap(name).toArrayBuffer();
const key = libsignal.crypto.getRandomBytes(32);
return textsecure.crypto
.encryptProfileName(buffer, key)
.then(encrypted => {
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
return textsecure.crypto
.decryptProfileName(encrypted, key)
.then(({ given, family }) => {
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(given).toString('utf8'),
'Alice'
);
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(family).toString('utf8'),
'Jones'
);
});
});
});
@ -32,10 +99,11 @@ describe('encrypting and decrypting profile data', () => {
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
return textsecure.crypto
.decryptProfileName(encrypted, key)
.then(decrypted => {
assert.strictEqual(decrypted.byteLength, 0);
.then(({ given, family }) => {
assert.strictEqual(family, null);
assert.strictEqual(given.byteLength, 0);
assert.strictEqual(
dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8'),
dcodeIO.ByteBuffer.wrap(given).toString('utf8'),
''
);
});

View file

@ -127,8 +127,8 @@
"redux-ts-utils": "3.2.2",
"reselect": "4.0.0",
"rimraf": "2.6.2",
"sanitize.css": "11.0.0",
"sanitize-filename": "1.6.3",
"sanitize.css": "11.0.0",
"semver": "5.4.1",
"sharp": "0.23.0",
"spellchecker": "3.7.0",

View file

@ -0,0 +1,29 @@
import { assert } from 'chai';
import { combineNames } from '../../util/combineNames';
describe('combineNames', () => {
it('returns null if no names provided', () => {
assert.strictEqual(combineNames('', ''), null);
});
it('returns first name only if family name not provided', () => {
assert.strictEqual(combineNames('Alice'), 'Alice');
});
it('returns returns combined names', () => {
assert.strictEqual(combineNames('Alice', 'Jones'), 'Alice Jones');
});
it('returns given name first if names in Chinese', () => {
assert.strictEqual(combineNames('振宁', '杨'), '杨振宁');
});
it('returns given name first if names in Japanese', () => {
assert.strictEqual(combineNames('泰夫', '木田'), '木田泰夫');
});
it('returns given name first if names in Korean', () => {
assert.strictEqual(combineNames('채원', '도윤'), '도윤채원');
});
});

95
ts/util/combineNames.ts Normal file
View file

@ -0,0 +1,95 @@
// We don't include unicode-12.1.0 because it's over 100MB in size
// From https://github.com/mathiasbynens/unicode-12.1.0/tree/master/Block
// tslint:disable variable-name
const CJK_Compatibility = /[\u3300-\u33FF]/;
const CJK_Compatibility_Forms = /[\uFE30-\uFE4F]/;
const CJK_Compatibility_Ideographs = /[\uF900-\uFAFF]/;
const CJK_Compatibility_Ideographs_Supplement = /\uD87E[\uDC00-\uDE1F]/;
const CJK_Radicals_Supplement = /[\u2E80-\u2EFF]/;
const CJK_Strokes = /[\u31C0-\u31EF]/;
const CJK_Symbols_And_Punctuation = /[\u3000-\u303F]/;
const CJK_Unified_Ideographs = /[\u4E00-\u9FFF]/;
const CJK_Unified_Ideographs_Extension_A = /[\u3400-\u4DBF]/;
const CJK_Unified_Ideographs_Extension_B = /[\uD840-\uD868][\uDC00-\uDFFF]|\uD869[\uDC00-\uDEDF]/;
const CJK_Unified_Ideographs_Extension_C = /\uD869[\uDF00-\uDFFF]|[\uD86A-\uD86C][\uDC00-\uDFFF]|\uD86D[\uDC00-\uDF3F]/;
const CJK_Unified_Ideographs_Extension_D = /\uD86D[\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1F]/;
const CJK_Unified_Ideographs_Extension_E = /\uD86E[\uDC20-\uDFFF]|[\uD86F-\uD872][\uDC00-\uDFFF]|\uD873[\uDC00-\uDEAF]/;
const Enclosed_CJK_Letters_And_Months = /[\u3200-\u32FF]/;
const Kangxi_Radicals = /[\u2F00-\u2FDF]/;
const Ideographic_Description_Characters = /[\u2FF0-\u2FFF]/;
const Hiragana = /[\u3040-\u309F]/;
const Katakana = /[\u30A0-\u30FF]/;
const Katakana_Phonetic_Extensions = /[\u31F0-\u31FF]/;
const Hangul_Compatibility_Jamo = /[\u3130-\u318F]/;
const Hangul_Jamo = /[\u1100-\u11FF]/;
const Hangul_Jamo_Extended_A = /[\uA960-\uA97F]/;
const Hangul_Jamo_Extended_B = /[\uD7B0-\uD7FF]/;
const Hangul_Syllables = /[\uAC00-\uD7AF]/;
// From https://github.com/mathiasbynens/unicode-12.1.0/tree/master/Binary_Property/Ideographic
const isIdeographic = /[\u3006\u3007\u3021-\u3029\u3038-\u303A\u3400-\u4DB5\u4E00-\u9FEF\uF900-\uFA6D\uFA70-\uFAD9]|[\uD81C-\uD820\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD821[\uDC00-\uDFF7]|\uD822[\uDC00-\uDEF2]|\uD82C[\uDD70-\uDEFB]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]/;
export function combineNames(given: string, family?: string): null | string {
if (!given) {
return null;
}
// Users who haven't upgraded to dual-name, or went minimal, will just have a given name
if (!family) {
return given;
}
if (isAllCKJV(family) && isAllCKJV(given)) {
return `${family}${given}`;
}
return `${given} ${family}`;
}
function isAllCKJV(name: string): boolean {
for (const codePoint of name) {
if (!isCKJV(codePoint)) {
return false;
}
}
return true;
}
// tslint:disable-next-line cyclomatic-complexity
function isCKJV(codePoint: string) {
if (codePoint === ' ') {
return true;
}
return (
CJK_Compatibility.test(codePoint) ||
CJK_Compatibility_Forms.test(codePoint) ||
CJK_Compatibility_Ideographs.test(codePoint) ||
CJK_Compatibility_Ideographs_Supplement.test(codePoint) ||
CJK_Radicals_Supplement.test(codePoint) ||
CJK_Strokes.test(codePoint) ||
CJK_Symbols_And_Punctuation.test(codePoint) ||
CJK_Unified_Ideographs.test(codePoint) ||
CJK_Unified_Ideographs_Extension_A.test(codePoint) ||
CJK_Unified_Ideographs_Extension_B.test(codePoint) ||
CJK_Unified_Ideographs_Extension_C.test(codePoint) ||
CJK_Unified_Ideographs_Extension_D.test(codePoint) ||
CJK_Unified_Ideographs_Extension_E.test(codePoint) ||
Enclosed_CJK_Letters_And_Months.test(codePoint) ||
Kangxi_Radicals.test(codePoint) ||
Ideographic_Description_Characters.test(codePoint) ||
Hiragana.test(codePoint) ||
Katakana.test(codePoint) ||
Katakana_Phonetic_Extensions.test(codePoint) ||
Hangul_Compatibility_Jamo.test(codePoint) ||
Hangul_Jamo.test(codePoint) ||
Hangul_Jamo_Extended_A.test(codePoint) ||
Hangul_Jamo_Extended_B.test(codePoint) ||
Hangul_Syllables.test(codePoint) ||
isIdeographic.test(codePoint)
);
}

View file

@ -1,5 +1,6 @@
import * as GoogleChrome from './GoogleChrome';
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
import { combineNames } from './combineNames';
import { createBatcher } from './batcher';
import { createWaitBatcher } from './waitBatcher';
import { isFileDangerous } from './isFileDangerous';
@ -9,6 +10,7 @@ import { makeLookup } from './makeLookup';
export {
arrayBufferToObjectURL,
combineNames,
createBatcher,
createWaitBatcher,
GoogleChrome,

View file

@ -1212,10 +1212,18 @@
{
"rule": "jQuery-wrap(",
"path": "libtextsecure/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(padded)",
"lineNumber": 220,
"line": " given: dcodeIO.ByteBuffer.wrap(padded)",
"lineNumber": 235,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
"updated": "2020-01-10T23:53:06.768Z"
},
{
"rule": "jQuery-wrap(",
"path": "libtextsecure/crypto.js",
"line": " ? dcodeIO.ByteBuffer.wrap(padded)",
"lineNumber": 239,
"reasonCategory": "falseMatch",
"updated": "2020-01-10T23:53:06.768Z"
},
{
"rule": "jQuery-wrap(",