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