Update group table schema to support GV1->GV2 migration.

Also puts in protections to make sure we don't insert bad recipients or
groups.
This commit is contained in:
Greyson Parrelli 2020-10-20 17:13:00 -04:00 committed by Alan Evans
parent 985a220fca
commit 2d1bf33902
4 changed files with 177 additions and 57 deletions

View File

@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.SqlUtil;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
@ -62,6 +63,9 @@ public final class GroupDatabase extends Database {
private static final String TIMESTAMP = "timestamp"; private static final String TIMESTAMP = "timestamp";
static final String ACTIVE = "active"; static final String ACTIVE = "active";
static final String MMS = "mms"; static final String MMS = "mms";
private static final String EXPECTED_V2_ID = "expected_v2_id";
private static final String FORMER_V1_MEMBERS = "former_v1_members";
/* V2 Group columns */ /* V2 Group columns */
/** 32 bytes serialized {@link GroupMasterKey} */ /** 32 bytes serialized {@link GroupMasterKey} */
@ -71,9 +75,7 @@ public final class GroupDatabase extends Database {
/** Serialized {@link DecryptedGroup} protobuf */ /** Serialized {@link DecryptedGroup} protobuf */
private static final String V2_DECRYPTED_GROUP = "decrypted_group"; private static final String V2_DECRYPTED_GROUP = "decrypted_group";
public static final String CREATE_TABLE = public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
"CREATE TABLE " + TABLE_NAME +
" (" + ID + " INTEGER PRIMARY KEY, " +
GROUP_ID + " TEXT, " + GROUP_ID + " TEXT, " +
RECIPIENT_ID + " INTEGER, " + RECIPIENT_ID + " INTEGER, " +
TITLE + " TEXT, " + TITLE + " TEXT, " +
@ -88,15 +90,18 @@ public final class GroupDatabase extends Database {
MMS + " INTEGER DEFAULT 0, " + MMS + " INTEGER DEFAULT 0, " +
V2_MASTER_KEY + " BLOB, " + V2_MASTER_KEY + " BLOB, " +
V2_REVISION + " BLOB, " + V2_REVISION + " BLOB, " +
V2_DECRYPTED_GROUP + " BLOB);"; V2_DECRYPTED_GROUP + " BLOB, " +
EXPECTED_V2_ID + " TEXT DEFAULT NULL, " +
FORMER_V1_MEMBERS + " TEXT DEFAULT NULL);";
public static final String[] CREATE_INDEXS = { public static final String[] CREATE_INDEXS = {
"CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");", "CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");",
"CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");", "CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");",
"CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON " + TABLE_NAME + " (" + EXPECTED_V2_ID + ");"
}; };
private static final String[] GROUP_PROJECTION = { private static final String[] GROUP_PROJECTION = {
GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST, GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, FORMER_V1_MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP
}; };
@ -129,7 +134,7 @@ public final class GroupDatabase extends Database {
} }
} }
public boolean findGroup(@NonNull GroupId groupId) { public boolean groupExists(@NonNull GroupId groupId) {
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?",
new String[] {groupId.toString()}, new String[] {groupId.toString()},
null, null, null)) null, null, null))
@ -138,6 +143,27 @@ public final class GroupDatabase extends Database {
} }
} }
/**
* @return A gv1 group whose expected v2 ID matches the one provided.
*/
public Optional<GroupRecord> getGroupV1ByExpectedV2(@NonNull GroupId.V2 gv2Id) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, GROUP_PROJECTION, EXPECTED_V2_ID + " = ?", SqlUtil.buildArgs(gv2Id), null, null, null)) {
if (cursor.moveToFirst()) {
return getGroup(cursor);
} else {
return Optional.absent();
}
}
}
public void clearFormerV1Members(@NonNull GroupId.V2 id) {
ContentValues values = new ContentValues();
values.putNull(FORMER_V1_MEMBERS);
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(id));
}
Optional<GroupRecord> getGroup(Cursor cursor) { Optional<GroupRecord> getGroup(Cursor cursor) {
Reader reader = new Reader(cursor); Reader reader = new Reader(cursor);
return Optional.fromNullable(reader.getCurrent()); return Optional.fromNullable(reader.getCurrent());
@ -320,6 +346,9 @@ public final class GroupDatabase extends Database {
@Nullable SignalServiceAttachmentPointer avatar, @Nullable SignalServiceAttachmentPointer avatar,
@Nullable String relay) @Nullable String relay)
{ {
if (groupExists(groupId.deriveV2MigrationGroupId())) {
throw new LegacyGroupInsertException(groupId);
}
create(groupId, title, members, avatar, relay, null, null); create(groupId, title, members, avatar, relay, null, null);
} }
@ -334,6 +363,10 @@ public final class GroupDatabase extends Database {
{ {
GroupId.V2 groupId = GroupId.v2(groupMasterKey); GroupId.V2 groupId = GroupId.v2(groupMasterKey);
if (getGroupV1ByExpectedV2(groupId).isPresent()) {
throw new MissedGroupMigrationInsertException(groupId);
}
create(groupId, groupState.getTitle(), Collections.emptyList(), null, null, groupMasterKey, groupState); create(groupId, groupState.getTitle(), Collections.emptyList(), null, null, groupMasterKey, groupState);
return groupId; return groupId;
@ -378,6 +411,7 @@ public final class GroupDatabase extends Database {
contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0); contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0);
} else { } else {
contentValues.put(ACTIVE, 1); contentValues.put(ACTIVE, 1);
contentValues.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString());
} }
contentValues.put(MMS, groupId.isMms()); contentValues.put(MMS, groupId.isMms());
@ -630,20 +664,21 @@ public final class GroupDatabase extends Database {
return null; return null;
} }
return new GroupRecord(GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))), return new GroupRecord(GroupId.parseOrThrow(CursorUtil.requireString(cursor, GROUP_ID)),
RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))), RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)),
cursor.getString(cursor.getColumnIndexOrThrow(TITLE)), CursorUtil.requireString(cursor, TITLE),
cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), CursorUtil.requireString(cursor, MEMBERS),
cursor.getLong(cursor.getColumnIndexOrThrow(AVATAR_ID)), CursorUtil.requireString(cursor, FORMER_V1_MEMBERS),
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_KEY)), CursorUtil.requireLong(cursor, AVATAR_ID),
cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_CONTENT_TYPE)), CursorUtil.requireBlob(cursor, AVATAR_KEY),
cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_RELAY)), CursorUtil.requireString(cursor, AVATAR_CONTENT_TYPE),
cursor.getInt(cursor.getColumnIndexOrThrow(ACTIVE)) == 1, CursorUtil.requireString(cursor, AVATAR_RELAY),
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_DIGEST)), CursorUtil.requireBoolean(cursor, ACTIVE),
cursor.getInt(cursor.getColumnIndexOrThrow(MMS)) == 1, CursorUtil.requireBlob(cursor, AVATAR_DIGEST),
cursor.getBlob(cursor.getColumnIndexOrThrow(V2_MASTER_KEY)), CursorUtil.requireBoolean(cursor, MMS),
cursor.getInt(cursor.getColumnIndexOrThrow(V2_REVISION)), CursorUtil.requireBlob(cursor, V2_MASTER_KEY),
cursor.getBlob(cursor.getColumnIndexOrThrow(V2_DECRYPTED_GROUP))); CursorUtil.requireInt(cursor, V2_REVISION),
CursorUtil.requireBlob(cursor, V2_DECRYPTED_GROUP));
} }
@Override @Override
@ -659,6 +694,7 @@ public final class GroupDatabase extends Database {
private final RecipientId recipientId; private final RecipientId recipientId;
private final String title; private final String title;
private final List<RecipientId> members; private final List<RecipientId> members;
private final List<RecipientId> formerV1Members;
private final long avatarId; private final long avatarId;
private final byte[] avatarKey; private final byte[] avatarKey;
private final byte[] avatarDigest; private final byte[] avatarDigest;
@ -668,10 +704,21 @@ public final class GroupDatabase extends Database {
private final boolean mms; private final boolean mms;
@Nullable private final V2GroupProperties v2GroupProperties; @Nullable private final V2GroupProperties v2GroupProperties;
public GroupRecord(@NonNull GroupId id, @NonNull RecipientId recipientId, String title, String members, public GroupRecord(@NonNull GroupId id,
long avatarId, byte[] avatarKey, String avatarContentType, @NonNull RecipientId recipientId,
String relay, boolean active, byte[] avatarDigest, boolean mms, String title,
@Nullable byte[] groupMasterKeyBytes, int groupRevision, @Nullable byte[] decryptedGroupBytes) String members,
String formerV1Members,
long avatarId,
byte[] avatarKey,
String avatarContentType,
String relay,
boolean active,
byte[] avatarDigest,
boolean mms,
@Nullable byte[] groupMasterKeyBytes,
int groupRevision,
@Nullable byte[] decryptedGroupBytes)
{ {
this.id = id; this.id = id;
this.recipientId = recipientId; this.recipientId = recipientId;
@ -696,8 +743,17 @@ public final class GroupDatabase extends Database {
} }
this.v2GroupProperties = v2GroupProperties; this.v2GroupProperties = v2GroupProperties;
if (!TextUtils.isEmpty(members)) this.members = RecipientId.fromSerializedList(members); if (!TextUtils.isEmpty(members)) {
else this.members = new LinkedList<>(); this.members = RecipientId.fromSerializedList(members);
} else {
this.members = Collections.emptyList();
}
if (!TextUtils.isEmpty(formerV1Members)) {
this.formerV1Members = RecipientId.fromSerializedList(formerV1Members);
} else {
this.formerV1Members = Collections.emptyList();
}
} }
public GroupId getId() { public GroupId getId() {
@ -712,10 +768,14 @@ public final class GroupDatabase extends Database {
return title; return title;
} }
public List<RecipientId> getMembers() { public @NonNull List<RecipientId> getMembers() {
return members; return members;
} }
public @NonNull List<RecipientId> getFormerV1Members() {
return formerV1Members;
}
public boolean hasAvatar() { public boolean hasAvatar() {
return avatarId != 0; return avatarId != 0;
} }
@ -952,4 +1012,16 @@ public final class GroupDatabase extends Database {
return inGroup; return inGroup;
} }
} }
public static class LegacyGroupInsertException extends IllegalStateException {
public LegacyGroupInsertException(@Nullable GroupId id) {
super("Tried to create a new GV1 entry when we already had a migrated GV2! " + id);
}
}
public static class MissedGroupMigrationInsertException extends IllegalStateException {
public MissedGroupMigrationInsertException(@Nullable GroupId id) {
super("Tried to create a new GV2 entry when we already had a V1 group that mapped to the new ID! " + id);
}
}
} }

View File

@ -554,27 +554,54 @@ public class RecipientDatabase extends Database {
} }
public @NonNull RecipientId getOrInsertFromGroupId(@NonNull GroupId groupId) { public @NonNull RecipientId getOrInsertFromGroupId(@NonNull GroupId groupId) {
GetOrInsertResult result = getOrInsertByColumn(GROUP_ID, groupId.toString()); Optional<RecipientId> existing = getByColumn(GROUP_ID, groupId.toString());
if (result.neededInsert) { if (existing.isPresent()) {
return existing.get();
} else if (groupId.isV1() && DatabaseFactory.getGroupDatabase(context).groupExists(groupId.requireV1().deriveV2MigrationGroupId())) {
throw new GroupDatabase.LegacyGroupInsertException(groupId);
} else if (groupId.isV2() && DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()).isPresent()) {
throw new GroupDatabase.MissedGroupMigrationInsertException(groupId);
} else {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(GROUP_ID, groupId.toString());
long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values);
if (id < 0) {
existing = getByColumn(GROUP_ID, groupId.toString());
if (existing.isPresent()) {
return existing.get();
} else if (groupId.isV1() && DatabaseFactory.getGroupDatabase(context).groupExists(groupId.requireV1().deriveV2MigrationGroupId())) {
throw new GroupDatabase.LegacyGroupInsertException(groupId);
} else if (groupId.isV2() && DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()).isPresent()) {
throw new GroupDatabase.MissedGroupMigrationInsertException(groupId);
} else {
throw new AssertionError("Failed to insert recipient!");
}
} else {
ContentValues groupUpdates = new ContentValues();
if (groupId.isMms()) { if (groupId.isMms()) {
values.put(GROUP_TYPE, GroupType.MMS.getId()); groupUpdates.put(GROUP_TYPE, GroupType.MMS.getId());
} else { } else {
if (groupId.isV2()) { if (groupId.isV2()) {
values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId()); groupUpdates.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId());
} else { } else {
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId()); groupUpdates.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
} }
values.put(DIRTY, DirtyState.INSERT.getId()); groupUpdates.put(DIRTY, DirtyState.INSERT.getId());
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); groupUpdates.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()));
} }
update(result.recipientId, values); RecipientId recipientId = RecipientId.from(id);
}
return result.recipientId; update(recipientId, groupUpdates);
return recipientId;
}
}
} }
public Cursor getBlocked() { public Cursor getBlocked() {

View File

@ -158,8 +158,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int MENTION_CLEANUP_V2 = 77; private static final int MENTION_CLEANUP_V2 = 77;
private static final int REACTION_CLEANUP = 78; private static final int REACTION_CLEANUP = 78;
private static final int CAPABILITIES_REFACTOR = 79; private static final int CAPABILITIES_REFACTOR = 79;
private static final int GV1_MIGRATION = 80;
private static final int DATABASE_VERSION = 79; private static final int DATABASE_VERSION = 80;
private static final String DATABASE_NAME = "signal.db"; private static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -1139,6 +1140,26 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("UPDATE recipient SET capabilities = 2 WHERE gv2_capability = -1"); db.execSQL("UPDATE recipient SET capabilities = 2 WHERE gv2_capability = -1");
} }
if (oldVersion < GV1_MIGRATION) {
db.execSQL("ALTER TABLE groups ADD COLUMN expected_v2_id TEXT DEFAULT NULL");
db.execSQL("ALTER TABLE groups ADD COLUMN former_v1_members TEXT DEFAULT NULL");
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON groups (expected_v2_id)");
int count = 0;
try (Cursor cursor = db.rawQuery("SELECT * FROM groups WHERE group_id LIKE '__textsecure_group__!%' AND LENGTH(group_id) = 53", null)) {
while (cursor.moveToNext()) {
String gv1 = CursorUtil.requireString(cursor, "group_id");
String gv2 = GroupId.parseOrThrow(gv1).requireV1().deriveV2MigrationGroupId().toString();
ContentValues values = new ContentValues();
values.put("expected_v2_id", gv2);
count += db.update("groups", values, "group_id = ?", SqlUtil.buildArgs(gv1));
}
}
Log.i(TAG, "Updated " + count + " GV1 groups with expected GV2 IDs.");
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -202,7 +202,7 @@ public final class GroupManager {
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
{ {
if (!DatabaseFactory.getGroupDatabase(context).findGroup(groupId)) { if (!DatabaseFactory.getGroupDatabase(context).groupExists(groupId)) {
Log.i(TAG, "Group is not available locally " + groupId); Log.i(TAG, "Group is not available locally " + groupId);
return; return;
} }