1 /* 2 * Copyright (C) 2020 Finn Herzfeld 3 * 4 * This program is free software: you can redistribute it and/or modify 5 * it under the terms of the GNU General Public License as published by 6 * the Free Software Foundation, either version 3 of the License, or 7 * (at your option) any later version. 8 * 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17 18 package io.finn.signald.storage; 19 20 import com.fasterxml.jackson.annotation.JsonIgnore; 21 import com.fasterxml.jackson.annotation.JsonInclude; 22 import com.fasterxml.jackson.annotation.JsonProperty; 23 import com.fasterxml.jackson.annotation.JsonSetter; 24 import com.fasterxml.jackson.databind.ObjectMapper; 25 import com.fasterxml.jackson.databind.ObjectWriter; 26 import io.finn.signald.Account; 27 import io.finn.signald.BuildConfig; 28 import io.finn.signald.Manager; 29 import io.finn.signald.clientprotocol.v1.JsonAddress; 30 import io.finn.signald.db.*; 31 import io.finn.signald.exceptions.InvalidStorageFileException; 32 import io.finn.signald.util.GroupsUtil; 33 import io.finn.signald.util.JSONUtil; 34 import io.prometheus.client.Counter; 35 import java.io.File; 36 import java.io.IOException; 37 import java.nio.file.Files; 38 import java.nio.file.NoSuchFileException; 39 import java.sql.SQLException; 40 import java.util.List; 41 import java.util.UUID; 42 import java.util.stream.Collectors; 43 import org.apache.logging.log4j.LogManager; 44 import org.apache.logging.log4j.Logger; 45 import org.asamk.signal.util.RandomUtils; 46 import org.signal.zkgroup.InvalidInputException; 47 import org.signal.zkgroup.profiles.ProfileKey; 48 import org.whispersystems.signalservice.api.SignalServiceAccountManager; 49 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; 50 import org.whispersystems.signalservice.api.push.SignalServiceAddress; 51 import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; 52 import org.whispersystems.util.Base64; 53 54 @JsonInclude(JsonInclude.Include.NON_DEFAULT) 55 public class AccountData { 56 @JsonProperty("username") String legacyUsername; 57 @JsonProperty("password") String legacyPassword; 58 public JsonAddress address; 59 @JsonProperty("deviceId") Integer legacyDeviceId; 60 @JsonProperty("signalingKey") String legacySignalingKey; 61 @JsonProperty("preKeyIdOffset") public int legacyPreKeyIdOffset; 62 @JsonProperty("nextSignedPreKeyId") public int legacyNextSignedPreKeyId; 63 @JsonProperty("backgroundActionsLastRun") public BackgroundActionsLastRun legacyBackgroundActionsLastRun = new BackgroundActionsLastRun(); 64 @JsonProperty("lastAccountRefresh") public int legacyLastAccountRefresh; 65 @JsonProperty public String legacyProfileKey; 66 @JsonProperty("axolotlStore") public SignalProtocolStore legacyProtocolStore; 67 @JsonProperty("recipientStore") public RecipientStore legacyRecipientStore = new RecipientStore(); 68 69 public boolean registered; 70 public GroupStore groupStore; 71 public GroupsV2Storage groupsV2; 72 public ContactStore contactStore; 73 public ProfileCredentialStore profileCredentialStore = new ProfileCredentialStore(); 74 public int version; 75 76 @JsonIgnore private boolean deleted = false; 77 @JsonIgnore private Recipient self; 78 79 static final int VERSION_IMPORT_CONTACT_PROFILES = 1; 80 81 private static String dataPath; 82 private static final Logger logger = LogManager.getLogger(); 83 84 static final Counter savesCount = 85 Counter.build().name(BuildConfig.NAME + "_saves_total").help("Total number of times the JSON file was written to the disk").labelNames("account_uuid").register(); 86 AccountData()87 AccountData() {} 88 89 // create a new pending account AccountData(String pendingIdentifier)90 public AccountData(String pendingIdentifier) { 91 legacyUsername = pendingIdentifier; 92 address = new JsonAddress(pendingIdentifier); 93 } 94 load(File storageFile)95 public static AccountData load(File storageFile) throws IOException, SQLException { 96 ObjectMapper mapper = JSONUtil.GetMapper(); 97 98 // TODO: Add locking mechanism to prevent two instances of signald from using the same account at the same time. 99 AccountData a = mapper.readValue(storageFile, AccountData.class); 100 logger.debug("Loaded account data for " + (a.address == null ? "null" : a.address.toRedactedString())); 101 a.validate(); 102 a.update(); 103 a.initialize(); 104 return a; 105 } 106 initialize()107 private void initialize() throws IOException, SQLException { 108 if (address != null && address.uuid != null) { 109 self = new RecipientsTable(address.getUUID()).get(address.getUUID()); 110 } 111 } 112 createLinkedAccount(SignalServiceAccountManager.NewDeviceRegistrationReturn registration, String password, int registrationId, int deviceId, UUID server)113 public static AccountData createLinkedAccount(SignalServiceAccountManager.NewDeviceRegistrationReturn registration, String password, int registrationId, int deviceId, 114 UUID server) throws InvalidInputException, IOException, SQLException { 115 logger.debug("Creating new local account by linking"); 116 AccountData a = new AccountData(); 117 a.address = new JsonAddress(registration.getNumber(), registration.getUuid()); 118 a.initialize(); 119 120 if (registration.getProfileKey() != null) { 121 a.profileCredentialStore.storeProfileKey(a.self, registration.getProfileKey()); 122 } else { 123 a.generateProfileKey(); 124 } 125 126 a.registered = true; 127 a.init(); 128 a.save(); 129 130 AccountsTable.add(registration.getNumber(), registration.getUuid(), Manager.getFileName(registration.getNumber()), server); 131 Account account = new Account(registration.getUuid()); 132 account.setDeviceId(deviceId); 133 account.setPassword(password); 134 account.setIdentityKeyPair(registration.getIdentity()); 135 account.setLocalRegistrationId(registrationId); 136 137 return a; 138 } 139 140 @JsonIgnore getResolver()141 public RecipientsTable getResolver() { 142 return new RecipientsTable(getUUID()); 143 } 144 update()145 private void update() throws IOException, SQLException { 146 if (address == null) { 147 address = new JsonAddress(legacyUsername); 148 } else if (address.uuid != null && self == null) { 149 self = new RecipientsTable(address.getUUID()).get(address.getUUID()); 150 ProfileAndCredentialEntry profileKeyEntry = profileCredentialStore.get(self.getAddress()); 151 if (profileKeyEntry != null) { 152 if (profileKeyEntry.getServiceAddress().getUuid() == null && address.uuid != null) { 153 profileKeyEntry.setAddress(self.getAddress()); 154 } 155 } 156 } 157 if (groupsV2 == null) { 158 groupsV2 = new GroupsV2Storage(); 159 } 160 if (contactStore == null) { 161 contactStore = new ContactStore(); 162 } 163 164 for (GroupInfo g : groupStore.getGroups()) { 165 getMigratedGroupId(Base64.encodeBytes(g.groupId)); // Delete v1 groups that have been migrated to a v2 group 166 } 167 168 if (version < VERSION_IMPORT_CONTACT_PROFILES) { 169 // migrate profile keys from contacts to profileCredentialStore 170 for (ContactStore.ContactInfo c : contactStore.getContacts()) { 171 if (c.profileKey == null) { 172 continue; 173 } 174 try { 175 ProfileKey p = new ProfileKey(Base64.decode(c.profileKey)); 176 Recipient recipient = new RecipientsTable(getUUID()).get(c.address); 177 profileCredentialStore.storeProfileKey(recipient, p); 178 } catch (InvalidInputException e) { 179 logger.warn("Invalid profile key while migrating profile keys from contacts", e); 180 } 181 } 182 183 if (legacyProfileKey != null) { 184 try { 185 ProfileKey p = new ProfileKey(Base64.decode(legacyProfileKey)); 186 profileCredentialStore.storeProfileKey(self, p); 187 } catch (InvalidInputException e) { 188 logger.warn("Invalid profile key while migrating own profile key", e); 189 } 190 } 191 192 version = VERSION_IMPORT_CONTACT_PROFILES; 193 save(); 194 } 195 } 196 saveIfNeeded()197 public void saveIfNeeded() throws IOException { 198 if (profileCredentialStore.isUnsaved()) { 199 save(); 200 } 201 } 202 save()203 public void save() throws IOException { 204 if (deleted) { 205 logger.debug("refusing to save deleted account"); 206 return; 207 } 208 validate(); 209 210 savesCount.labels(address.uuid == null ? "null" : address.uuid).inc(); 211 212 ObjectWriter writer = JSONUtil.GetWriter(); 213 File dataPathFile = new File(dataPath); 214 if (!dataPathFile.exists()) { 215 dataPathFile.mkdirs(); 216 } 217 File destination = new File(dataPath + "/.tmp-" + legacyUsername); 218 logger.debug("Saving account to disk"); 219 writer.writeValue(destination, this); 220 profileCredentialStore.markSaved(); 221 destination.renameTo(new File(dataPath + "/" + legacyUsername)); 222 } 223 validate()224 public void validate() throws InvalidStorageFileException { 225 if (!PhoneNumberFormatter.isValidNumber(this.legacyUsername, null)) { 226 throw new InvalidStorageFileException("phone number " + this.legacyUsername + " is not valid"); 227 } 228 } 229 init()230 public void init() throws InvalidInputException, IOException, SQLException { 231 if (address == null && legacyUsername != null) { 232 address = new JsonAddress(legacyUsername); 233 } 234 235 if (address != null && address.number != null && legacyUsername == null) { 236 legacyUsername = address.number; 237 } 238 239 if (groupStore == null) { 240 groupStore = new GroupStore(); 241 } 242 243 if (groupsV2 == null) { 244 groupsV2 = new GroupsV2Storage(); 245 } 246 247 if (contactStore == null) { 248 contactStore = new ContactStore(); 249 } 250 251 if (address != null && address.uuid != null) { 252 if (self == null) { 253 self = new RecipientsTable(address.getUUID()).get(address.getUUID()); 254 } 255 ProfileAndCredentialEntry profileKeyEntry = profileCredentialStore.get(self.getAddress()); 256 if (profileKeyEntry == null) { 257 generateProfileKey(); 258 } else { 259 if (profileKeyEntry.getServiceAddress().getUuid() == null && address.uuid != null) { 260 profileKeyEntry.setAddress(self.getAddress()); 261 } 262 } 263 } 264 } 265 266 // Generates a profile key if one does not exist generateProfileKey()267 public void generateProfileKey() throws InvalidInputException { 268 if (profileCredentialStore.get(self.getAddress()) == null) { 269 byte[] key = new byte[32]; 270 RandomUtils.getSecureRandom().nextBytes(key); 271 profileCredentialStore.storeProfileKey(self, new ProfileKey(key)); 272 } 273 } 274 markForDeletion()275 public void markForDeletion() { deleted = true; } 276 isDeleted()277 public boolean isDeleted() { return deleted; } 278 delete()279 public void delete() throws SQLException, IOException { 280 if (getUUID() != null) { 281 PreKeysTable.deleteAccount(getUUID()); 282 SessionsTable.deleteAccount(getUUID()); 283 SignedPreKeysTable.deleteAccount(getUUID()); 284 IdentityKeysTable.deleteAccount(getUUID()); 285 RecipientsTable.deleteAccount(getUUID()); 286 AccountDataTable.deleteAccount(getUUID()); 287 AccountsTable.deleteAccount(getUUID()); 288 } 289 290 MessageQueueTable.deleteAccount(legacyUsername); 291 try { 292 Files.delete(new File(dataPath + "/" + legacyUsername).toPath()); 293 } catch (NoSuchFileException ignored) { 294 } 295 try { 296 Files.delete(new File(dataPath + "/" + legacyUsername + ".d").toPath()); 297 } catch (NoSuchFileException ignored) { 298 } 299 } 300 301 @JsonSetter("groupsV2Supported") migrateGroupsV2SupportedFlag(boolean flag)302 public void migrateGroupsV2SupportedFlag(boolean flag) { 303 // no op 304 } 305 306 @JsonIgnore setDataPath(String path)307 public static void setDataPath(String path) { 308 dataPath = path + "/data"; 309 } 310 311 @JsonIgnore getSelfUnidentifiedAccessKey()312 public byte[] getSelfUnidentifiedAccessKey() { 313 return UnidentifiedAccess.deriveAccessKeyFrom(profileCredentialStore.get(self.getAddress()).getProfileKey()); 314 } 315 316 @JsonIgnore getUUID()317 public UUID getUUID() { 318 if (address == null) { 319 return null; 320 } 321 return address.getUUID(); 322 } 323 getMigratedGroupId(String groupV1Id)324 public String getMigratedGroupId(String groupV1Id) throws IOException { 325 String groupV2Id = Base64.encodeBytes(GroupsUtil.getGroupId(GroupsUtil.deriveV2MigrationMasterKey(Base64.decode(groupV1Id)))); 326 List<Group> v2Groups = groupsV2.groups.stream().filter(g -> g.getID().equals(groupV2Id)).collect(Collectors.toList()); 327 if (v2Groups.size() > 0) { 328 groupStore.deleteGroup(groupV1Id); 329 return v2Groups.get(0).getID(); 330 } 331 return groupV1Id; 332 } 333 334 @JsonIgnore getProfileKey()335 public ProfileKey getProfileKey() throws InvalidInputException { 336 ProfileAndCredentialEntry entry = profileCredentialStore.get(self.getAddress()); 337 if (entry == null) { 338 generateProfileKey(); 339 entry = profileCredentialStore.get(self.getAddress()); 340 } 341 return entry.getProfileKey(); 342 } 343 344 @JsonIgnore setProfileKey(ProfileKey profileKey)345 public void setProfileKey(ProfileKey profileKey) { 346 profileCredentialStore.storeProfileKey(self, profileKey); 347 } 348 349 // Jackson getters and setters 350 351 // migrate old threadStore which tracked expiration timers, now moved to groups and contacts setThreadStore(LegacyThreadStore threadStore)352 public void setThreadStore(LegacyThreadStore threadStore) { 353 logger.info("Migrating thread store"); 354 for (LegacyThreadInfo t : threadStore.getThreads()) { 355 GroupInfo g = groupStore.getGroup(t.id); 356 if (g != null) { 357 // thread ID matches a known group 358 g.messageExpirationTime = t.messageExpirationTime; 359 groupStore.updateGroup(g); 360 } else { 361 // thread ID does not match a known group. Assume it's a PM 362 try { 363 Recipient recipient = new RecipientsTable(address.getUUID()).get(t.id); 364 ContactStore.ContactInfo c = contactStore.getContact(recipient); 365 c.messageExpirationTime = t.messageExpirationTime; 366 contactStore.updateContact(c); 367 } catch (IOException | SQLException e) { 368 logger.warn("exception while importing contact: ", e); 369 } 370 } 371 } 372 } 373 374 @JsonIgnore getDatabase()375 public Database getDatabase() { 376 return new Database(getUUID()); 377 } 378 379 @JsonIgnore setUUID(UUID ownUuid)380 public void setUUID(UUID ownUuid) { 381 address.uuid = ownUuid.toString(); 382 } 383 getLegacyUsername()384 public String getLegacyUsername() { return legacyUsername; } 385 migrateToDB(UUID accountUUID)386 public boolean migrateToDB(UUID accountUUID) throws SQLException { 387 boolean needsSave = false; 388 Account account = new Account(accountUUID); 389 390 if (legacyPassword != null) { 391 account.setPassword(legacyPassword); 392 legacyPassword = null; 393 needsSave = true; 394 logger.debug("migrated account password to database"); 395 } 396 397 if (legacyDeviceId != null) { 398 account.setDeviceId(legacyDeviceId); 399 legacyDeviceId = null; 400 needsSave = true; 401 logger.debug("migrated local device id to database"); 402 } else if (account.getDeviceId() < 0) { 403 account.setDeviceId(SignalServiceAddress.DEFAULT_DEVICE_ID); 404 } 405 406 if (legacyLastAccountRefresh > 0) { 407 account.setLastAccountRefresh(legacyLastAccountRefresh); 408 legacyLastAccountRefresh = -1; 409 needsSave = true; 410 } 411 412 if (legacyNextSignedPreKeyId > -1) { 413 account.setNextSignedPreKeyId(legacyNextSignedPreKeyId); 414 legacyNextSignedPreKeyId = -1; 415 needsSave = true; 416 } 417 418 if (legacyPreKeyIdOffset > 0) { 419 account.setPreKeyIdOffset(legacyPreKeyIdOffset); 420 legacyPreKeyIdOffset = -1; 421 needsSave = true; 422 } 423 return needsSave; 424 } 425 } 426