diff --git a/app/sql.js b/app/sql.js index a437e853a8..c426b37588 100644 --- a/app/sql.js +++ b/app/sql.js @@ -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, } ); diff --git a/js/models/conversations.js b/js/models/conversations.js index 141a75f380..56201c00bd 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -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; }, diff --git a/libtextsecure/crypto.js b/libtextsecure/crypto.js index 82c52547ad..d90400280f 100644 --- a/libtextsecure/crypto.js +++ b/libtextsecure/crypto.js @@ -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, + }; }); }, diff --git a/libtextsecure/test/crypto_test.js b/libtextsecure/test/crypto_test.js index 298a273b87..c995158314 100644 --- a/libtextsecure/test/crypto_test.js +++ b/libtextsecure/test/crypto_test.js @@ -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'), '' ); }); diff --git a/package.json b/package.json index 52d2eb9516..f2a096eb7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts/test/util/combineNames_test.ts b/ts/test/util/combineNames_test.ts new file mode 100644 index 0000000000..fd2cca6254 --- /dev/null +++ b/ts/test/util/combineNames_test.ts @@ -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('채원', '도윤'), '도윤채원'); + }); +}); diff --git a/ts/util/combineNames.ts b/ts/util/combineNames.ts new file mode 100644 index 0000000000..24f9400fd6 --- /dev/null +++ b/ts/util/combineNames.ts @@ -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) + ); +} diff --git a/ts/util/index.ts b/ts/util/index.ts index 210f15a7c3..3d0726a84e 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -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, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 71337a6769..ece1a63bbf 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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(",