Branch data Line data Source code
1 : : #include "AccountConfig.h"
2 : : #include "CredentialStore.h"
3 : :
4 : : #include <QDir>
5 : : #include <QFile>
6 : : #include <QJsonDocument>
7 : : #include <QJsonObject>
8 : : #include <QEventLoop>
9 : : #include <QLoggingCategory>
10 : : #include <QSaveFile>
11 : : #include <QStandardPaths>
12 : : #include <QTimer>
13 : :
14 [ + + + - : 539 : Q_LOGGING_CATEGORY(lcAccountConfig, "mailjd.accountconfig")
+ - - - ]
15 : :
16 : : namespace {
17 : :
18 : 10 : bool deleteSecretBlocking(const QString &service, const QString &accountName,
19 : : QString *error) {
20 [ + - ]: 10 : CredentialStore store;
21 [ + - ]: 10 : QEventLoop loop;
22 [ + - ]: 10 : QTimer timeout;
23 [ + - ]: 10 : timeout.setSingleShot(true);
24 : :
25 : 10 : bool completed = false;
26 : 10 : bool success = false;
27 : :
28 [ + - ]: 10 : QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
29 [ # # ]: 0 : if (completed)
30 : 0 : return;
31 : 0 : completed = true;
32 [ # # ]: 0 : if (error)
33 : 0 : *error = QStringLiteral("Timed out deleting password from keyring");
34 : 0 : loop.quit();
35 : : });
36 : :
37 [ + - + - ]: 10 : store.deletePassword(service, accountName, [&](bool ok) {
38 [ - + ]: 10 : if (completed)
39 : 0 : return;
40 : 10 : completed = true;
41 : 10 : success = ok;
42 : 10 : loop.quit();
43 : : });
44 : :
45 [ + - ]: 10 : timeout.start(30000);
46 [ + - ]: 10 : loop.exec();
47 : :
48 [ + + + - : 10 : if (!success && error && error->isEmpty())
+ - + + ]
49 : 8 : *error = QStringLiteral("Could not delete password from keyring");
50 : 10 : return success;
51 : 10 : }
52 : :
53 : : } // namespace
54 : :
55 : 174 : QString AccountConfigLoader::defaultConfigDir() {
56 [ + - ]: 348 : return QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
57 [ + - ]: 348 : "/mailjd/accounts";
58 : : }
59 : :
60 : 82 : std::vector<AccountConfig> AccountConfigLoader::loadAll() {
61 [ + - + - ]: 82 : return loadAll(defaultConfigDir());
62 : : }
63 : :
64 : : std::vector<AccountConfig>
65 : 185 : AccountConfigLoader::loadAll(const QString &configDir) {
66 : 185 : std::vector<AccountConfig> accounts;
67 [ + - ]: 185 : QDir dir(configDir);
68 : :
69 [ + - + + ]: 185 : if (!dir.exists()) {
70 [ + - + - : 94 : qCWarning(lcAccountConfig)
+ + ]
71 [ + - + - ]: 47 : << "Config directory does not exist:" << configDir;
72 : 47 : return accounts;
73 : : }
74 : :
75 [ + - + + : 414 : const auto files = dir.entryList({"*.json"}, QDir::Files, QDir::Name);
- - ]
76 [ + + ]: 273 : for (const auto &fileName : files) {
77 [ + - ]: 135 : const auto path = dir.absoluteFilePath(fileName);
78 [ + - ]: 135 : auto account = loadFromFile(path);
79 [ + + ]: 135 : if (account.has_value()) {
80 [ + - + - ]: 133 : auto errors = validate(account.value());
81 [ + + ]: 133 : if (errors.isEmpty()) {
82 [ + - + - : 264 : qCInfo(lcAccountConfig)
+ + ]
83 [ + - + - : 132 : << "Loaded account:" << account->name << "from" << fileName;
+ - + - ]
84 [ + - + - ]: 132 : accounts.push_back(std::move(account.value()));
85 : : } else {
86 [ + - + - : 2 : qCWarning(lcAccountConfig)
+ + ]
87 [ + - + - : 1 : << "Invalid account config" << fileName << ":" << errors.join(", ");
+ - + - +
- + - ]
88 : : }
89 : 133 : }
90 : 135 : }
91 : :
92 [ + - + - : 276 : qCInfo(lcAccountConfig) << "Loaded" << accounts.size() << "accounts from"
+ - + - +
- + + ]
93 [ + - ]: 138 : << configDir;
94 : 138 : return accounts;
95 [ + - + - : 323 : }
- - - - ]
96 : :
97 : : std::optional<AccountConfig>
98 : 156 : AccountConfigLoader::loadFromFile(const QString &path) {
99 [ + - ]: 156 : QFile file(path);
100 [ + - + + ]: 156 : if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
101 [ + - + - : 4 : qCWarning(lcAccountConfig)
+ + ]
102 [ + - + - : 2 : << "Cannot open config file:" << path << file.errorString();
+ - + - ]
103 : 2 : return std::nullopt;
104 : : }
105 : :
106 : 154 : QJsonParseError parseError;
107 [ + - + - ]: 154 : auto doc = QJsonDocument::fromJson(file.readAll(), &parseError);
108 [ + - + + ]: 154 : if (doc.isNull()) {
109 [ + - + - : 8 : qCWarning(lcAccountConfig)
+ + ]
110 [ + - + - : 4 : << "JSON parse error in" << path << ":" << parseError.errorString();
+ - + - +
- ]
111 : 4 : return std::nullopt;
112 : : }
113 : :
114 [ + - + + ]: 150 : if (!doc.isObject()) {
115 [ + - + - : 2 : qCWarning(lcAccountConfig) << "JSON root is not an object in" << path;
+ - + - +
+ ]
116 : 1 : return std::nullopt;
117 : : }
118 : :
119 [ + - ]: 149 : const auto root = doc.object();
120 [ + - ]: 149 : AccountConfig config;
121 : :
122 [ + - + - : 149 : config.name = root.value("name").toString();
+ - ]
123 [ + - + - : 149 : config.email = root.value("email").toString();
+ - ]
124 : :
125 : : // IMAP
126 [ + - + - : 149 : const auto imapObj = root.value("imap").toObject();
+ - ]
127 [ + - + - : 149 : config.imap.host = imapObj.value("host").toString();
+ - ]
128 [ + - + - : 149 : config.imap.port = static_cast<quint16>(imapObj.value("port").toInt(993));
+ - ]
129 [ + - + - : 149 : config.imap.security = imapObj.value("security").toString("ssl");
+ - + - ]
130 [ + - + - : 149 : config.imap.username = imapObj.value("username").toString();
+ - ]
131 [ + - + - : 149 : config.imap.password = imapObj.value("password").toString().toUtf8();
+ - + - ]
132 : :
133 : : // SMTP
134 [ + - + - : 149 : const auto smtpObj = root.value("smtp").toObject();
+ - ]
135 [ + - + - : 149 : config.smtp.host = smtpObj.value("host").toString();
+ - ]
136 [ + - + - : 149 : config.smtp.port = static_cast<quint16>(smtpObj.value("port").toInt(587));
+ - ]
137 [ + - + - : 149 : config.smtp.security = smtpObj.value("security").toString("starttls");
+ - + - ]
138 [ + - + - : 149 : config.smtp.username = smtpObj.value("username").toString();
+ - ]
139 [ + - + - : 149 : config.smtp.password = smtpObj.value("password").toString().toUtf8();
+ - + - ]
140 : :
141 [ + + + + : 149 : if (!config.imap.password.isEmpty() || !config.smtp.password.isEmpty()) {
+ + ]
142 [ + - + + ]: 38 : if (saveToFile(config, path)) {
143 [ + - + - : 30 : qCInfo(lcAccountConfig)
+ + ]
144 [ + - + - ]: 15 : << "Migrated plaintext account passwords to keyring:" << path;
145 : : } else {
146 [ + - + - : 46 : qCWarning(lcAccountConfig)
+ + ]
147 : : << "Could not migrate plaintext account passwords; retaining"
148 [ + - ]: 23 : " the original config file:"
149 [ + - ]: 23 : << path;
150 : : }
151 : : }
152 : :
153 : 149 : return config;
154 : 156 : }
155 : :
156 : 173 : QStringList AccountConfigLoader::validate(const AccountConfig &config) {
157 : 173 : QStringList errors;
158 : :
159 [ + + ]: 173 : if (config.name.isEmpty())
160 [ + - + - ]: 13 : errors << "Missing 'name'";
161 [ + + ]: 173 : if (config.email.isEmpty())
162 [ + - + - ]: 16 : errors << "Missing 'email'";
163 : :
164 : : // IMAP validation
165 [ + + ]: 173 : if (config.imap.host.isEmpty())
166 [ + - + - ]: 14 : errors << "Missing 'imap.host'";
167 [ + + ]: 173 : if (config.imap.username.isEmpty())
168 [ + - + - ]: 13 : errors << "Missing 'imap.username'";
169 [ + + ]: 173 : if (config.imap.port == 0)
170 [ + - + - ]: 2 : errors << "Invalid 'imap.port'";
171 [ + + + + : 173 : if (config.imap.security != "ssl" && config.imap.security != "starttls")
+ + ]
172 [ + - + - ]: 3 : errors << "Invalid 'imap.security' (must be 'ssl' or 'starttls')";
173 : :
174 : : // SMTP validation
175 [ + + ]: 173 : if (config.smtp.host.isEmpty())
176 [ + - + - ]: 13 : errors << "Missing 'smtp.host'";
177 [ + + ]: 173 : if (config.smtp.username.isEmpty())
178 [ + - + - ]: 13 : errors << "Missing 'smtp.username'";
179 [ + + ]: 173 : if (config.smtp.port == 0)
180 [ + - + - ]: 2 : errors << "Invalid 'smtp.port'";
181 [ + + + + : 173 : if (config.smtp.security != "ssl" && config.smtp.security != "starttls")
+ + ]
182 [ + - + - ]: 2 : errors << "Invalid 'smtp.security' (must be 'ssl' or 'starttls')";
183 : :
184 : 173 : return errors;
185 : 0 : }
186 : :
187 : 13 : bool AccountConfigLoader::save(const AccountConfig &config,
188 : : const QString &configDir) {
189 [ + - ]: 13 : QDir dir(configDir);
190 [ + - + + ]: 13 : if (!dir.exists()) {
191 [ + - + - : 4 : if (!dir.mkpath(".")) {
+ + ]
192 [ + - + - : 2 : qCWarning(lcAccountConfig)
+ + ]
193 [ + - + - ]: 1 : << "Failed to create config directory:" << configDir;
194 : 1 : return false;
195 : : }
196 : : }
197 : :
198 [ + - + - ]: 12 : auto filename = slugify(config.name) + ".json";
199 [ + - ]: 12 : auto path = dir.absoluteFilePath(filename);
200 [ + - ]: 12 : return saveToFile(config, path);
201 : 13 : }
202 : :
203 : 56 : bool AccountConfigLoader::saveToFile(const AccountConfig &config,
204 : : const QString &path) {
205 : 88 : auto writeSecret = [&config](const QString &service,
206 : : const QByteArray &password,
207 : : QString *error) {
208 [ + + ]: 88 : if (password.isEmpty())
209 : 19 : return true;
210 : :
211 [ + - ]: 69 : CredentialStore store;
212 [ + - ]: 69 : QEventLoop loop;
213 [ + - ]: 69 : QTimer timeout;
214 [ + - ]: 69 : timeout.setSingleShot(true);
215 : :
216 : 69 : bool completed = false;
217 : 69 : bool success = false;
218 : 69 : QString writeError;
219 : :
220 [ + - ]: 69 : QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
221 [ # # ]: 0 : if (!completed) {
222 : 0 : completed = true;
223 : 0 : writeError = QStringLiteral("Timed out writing password to keyring");
224 : 0 : loop.quit();
225 : : }
226 : 0 : });
227 : :
228 [ + - ]: 69 : store.writePassword(service, config.name, password,
229 [ + - ]: 69 : [&](bool ok, const QString &err) {
230 [ - + ]: 69 : if (completed)
231 : 0 : return;
232 : 69 : completed = true;
233 : 69 : success = ok;
234 : 69 : writeError = err;
235 : 69 : loop.quit();
236 : : });
237 [ + - ]: 69 : timeout.start(30000);
238 [ + - ]: 69 : loop.exec();
239 : :
240 [ + + ]: 69 : if (!success) {
241 [ + - ]: 25 : if (error)
242 : 25 : *error = writeError.isEmpty()
243 [ - + - + ]: 50 : ? QStringLiteral("Could not write password to keyring")
244 : 25 : : writeError;
245 : 25 : return false;
246 : : }
247 : 44 : return true;
248 : 69 : };
249 : :
250 : 56 : QString keyringError;
251 [ + - + + ]: 112 : if (!writeSecret(QStringLiteral("imap"), config.imap.password,
252 : : &keyringError)) {
253 [ + - + - : 48 : qCWarning(lcAccountConfig)
+ + ]
254 [ + - ]: 24 : << "Refusing to save account after IMAP keyring write failed:"
255 [ + - ]: 24 : << keyringError;
256 : 24 : return false;
257 : : }
258 [ + - + + ]: 64 : if (!writeSecret(QStringLiteral("smtp"), config.smtp.password,
259 : : &keyringError)) {
260 [ + - + - : 2 : qCWarning(lcAccountConfig)
+ + ]
261 [ + - ]: 1 : << "Refusing to save account after SMTP keyring write failed:"
262 [ + - ]: 1 : << keyringError;
263 : 1 : return false;
264 : : }
265 : :
266 [ + - ]: 31 : QJsonObject imapObj;
267 [ + - + - : 31 : imapObj["host"] = config.imap.host;
+ - + - ]
268 [ + - + - : 31 : imapObj["port"] = config.imap.port;
+ - + - ]
269 [ + - + - : 31 : imapObj["security"] = config.imap.security;
+ - + - ]
270 [ + - + - : 31 : imapObj["username"] = config.imap.username;
+ - + - ]
271 : : // Omit passwords from JSON (stored in keyring)
272 [ + - + - : 31 : imapObj["password"] = QString();
+ - + - ]
273 : :
274 [ + - ]: 31 : QJsonObject smtpObj;
275 [ + - + - : 31 : smtpObj["host"] = config.smtp.host;
+ - + - ]
276 [ + - + - : 31 : smtpObj["port"] = config.smtp.port;
+ - + - ]
277 [ + - + - : 31 : smtpObj["security"] = config.smtp.security;
+ - + - ]
278 [ + - + - : 31 : smtpObj["username"] = config.smtp.username;
+ - + - ]
279 [ + - + - : 31 : smtpObj["password"] = QString();
+ - + - ]
280 : :
281 [ + - ]: 31 : QJsonObject root;
282 [ + - + - : 31 : root["name"] = config.name;
+ - + - ]
283 [ + - + - : 31 : root["email"] = config.email;
+ - + - ]
284 [ + - + - : 31 : root["imap"] = imapObj;
+ - + - ]
285 [ + - + - : 31 : root["smtp"] = smtpObj;
+ - + - ]
286 : :
287 [ + - ]: 31 : QJsonDocument doc(root);
288 : :
289 : : // T-501: Use QSaveFile for atomic write — prevents TOCTOU permission race
290 [ + - ]: 31 : QSaveFile file(path);
291 [ + - + + ]: 31 : if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
292 [ + - + - : 2 : qCWarning(lcAccountConfig)
+ + ]
293 [ + - + - : 1 : << "Cannot write config file:" << path << file.errorString();
+ - + - ]
294 : 1 : return false;
295 : : }
296 : :
297 : : // Set restrictive permissions BEFORE writing (prevents TOCTOU race)
298 [ + - ]: 30 : file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
299 : :
300 [ + - + - ]: 30 : file.write(doc.toJson(QJsonDocument::Indented));
301 : :
302 [ + - - + ]: 30 : if (!file.commit()) {
303 [ # # # # : 0 : qCWarning(lcAccountConfig)
# # ]
304 [ # # # # ]: 0 : << "Failed to commit config file:" << path;
305 : 0 : return false;
306 : : }
307 : :
308 [ + - + - : 60 : qCInfo(lcAccountConfig) << "Saved account:" << config.name << "to" << path;
+ - + - +
- + - +
+ ]
309 : 30 : return true;
310 : 56 : }
311 : :
312 : 8 : bool AccountConfigLoader::remove(const QString &accountName,
313 : : const QString &configDir) {
314 [ + - ]: 8 : QDir dir(configDir);
315 [ + - + + ]: 8 : if (!dir.exists()) {
316 [ + - + - : 2 : qCWarning(lcAccountConfig)
+ + ]
317 [ + - + - ]: 1 : << "Config directory does not exist:" << configDir;
318 : 1 : return false;
319 : : }
320 : :
321 : : // Strategy: find JSON file containing matching "name" field.
322 : : // Also check by slugified filename as fallback.
323 [ + - + + : 21 : const auto files = dir.entryList({"*.json"}, QDir::Files);
- - ]
324 [ + + ]: 11 : for (const auto &fileName : files) {
325 [ + - ]: 9 : auto path = dir.absoluteFilePath(fileName);
326 [ + - ]: 9 : auto account = loadFromFile(path);
327 [ + + + + : 9 : if (account.has_value() && account->name == accountName) {
+ + ]
328 [ + - + - ]: 5 : if (QFile::remove(path)) {
329 : 5 : QString keyringError;
330 [ + - + + ]: 5 : if (!deleteSecretBlocking(QStringLiteral("imap"), accountName,
331 : : &keyringError)) {
332 [ + - + - : 8 : qCWarning(lcAccountConfig)
+ + ]
333 [ + - ]: 4 : << "Failed to delete IMAP keyring secret for"
334 [ + - + - : 4 : << accountName << ":" << keyringError;
+ - ]
335 : : }
336 : 5 : keyringError.clear();
337 [ + - + + ]: 5 : if (!deleteSecretBlocking(QStringLiteral("smtp"), accountName,
338 : : &keyringError)) {
339 [ + - + - : 8 : qCWarning(lcAccountConfig)
+ + ]
340 [ + - ]: 4 : << "Failed to delete SMTP keyring secret for"
341 [ + - + - : 4 : << accountName << ":" << keyringError;
+ - ]
342 : : }
343 [ + - + - : 10 : qCInfo(lcAccountConfig)
+ + ]
344 [ + - + - : 5 : << "Deleted account:" << accountName << "file:" << fileName;
+ - + - ]
345 : 5 : return true;
346 : 5 : } else {
347 [ # # # # : 0 : qCWarning(lcAccountConfig) << "Failed to delete file:" << path;
# # # # #
# ]
348 : 0 : return false;
349 : : }
350 : : }
351 [ + + + + ]: 14 : }
352 : :
353 [ + - + - : 4 : qCWarning(lcAccountConfig)
+ + ]
354 [ + - + - ]: 2 : << "No config file found for account:" << accountName;
355 : 2 : return false;
356 [ + - + - : 15 : }
- - - - ]
357 : :
358 : 40 : QString AccountConfigLoader::slugify(const QString &name) {
359 : 40 : QString result;
360 [ + - ]: 40 : result.reserve(name.size());
361 : :
362 : : // Normalize: lowercase, replace non-ASCII and special chars
363 [ + - + - ]: 40 : auto normalized = name.toLower().normalized(QString::NormalizationForm_KD);
364 : :
365 [ + - + - : 573 : for (const auto &ch : normalized) {
+ + ]
366 [ + + + + : 533 : if (ch.isLetterOrNumber() && ch.unicode() < 128) {
+ + ]
367 [ + - ]: 497 : result.append(ch);
368 [ + + + + : 36 : } else if (ch == ' ' || ch == '-' || ch == '_') {
+ + + + ]
369 : : // Collapse consecutive separators
370 [ + + + - : 29 : if (!result.isEmpty() && result.back() != '_') {
+ + + + ]
371 [ + - ]: 25 : result.append('_');
372 : : }
373 : : }
374 : : // Skip all other characters (accents, special chars)
375 : : }
376 : :
377 : : // Remove trailing underscore
378 [ + - + + ]: 41 : while (result.endsWith('_')) {
379 [ + - ]: 1 : result.chop(1);
380 : : }
381 : :
382 : : // Limit length
383 [ + + ]: 40 : if (result.size() > 50) {
384 [ + - ]: 3 : result.truncate(50);
385 : : // Don't end on underscore after truncation
386 [ + - + + ]: 4 : while (result.endsWith('_')) {
387 [ + - ]: 1 : result.chop(1);
388 : : }
389 : : }
390 : :
391 : : // Fallback for empty result
392 [ + + ]: 40 : if (result.isEmpty()) {
393 [ + - ]: 5 : result = "account";
394 : : }
395 : :
396 : 40 : return result;
397 : 40 : }
398 : :
399 : : // SEC-01/02: Async keyring password resolution
400 : 67 : void AccountConfigLoader::resolvePasswords(
401 : : std::vector<AccountConfig> &accounts, CredentialStore *store,
402 : : std::function<void()> callback) {
403 [ + + + + : 67 : if (!store || accounts.empty()) {
+ + ]
404 [ + + + - ]: 3 : if (callback) callback();
405 : 15 : return;
406 : : }
407 : :
408 : : // Count how many passwords need resolving
409 : 64 : int pending = 0;
410 [ + + ]: 129 : for (const auto &acc : accounts) {
411 [ + + ]: 65 : if (acc.imap.password.isEmpty()) ++pending;
412 [ + + ]: 65 : if (acc.smtp.password.isEmpty()) ++pending;
413 : : }
414 : :
415 [ + + ]: 64 : if (pending == 0) {
416 [ + + + - ]: 12 : if (callback) callback();
417 : 12 : return;
418 : : }
419 : :
420 : : // Shared counter for tracking completion
421 [ + - ]: 52 : auto remaining = std::make_shared<int>(pending);
422 : :
423 [ + + ]: 105 : for (size_t i = 0; i < accounts.size(); ++i) {
424 : 53 : auto &acc = accounts[i];
425 : :
426 [ + + ]: 53 : if (acc.imap.password.isEmpty()) {
427 [ + - ]: 52 : store->readPassword(
428 : 156 : QStringLiteral("imap"), acc.name,
429 [ + - + - : 104 : [&acc, remaining, callback](bool success, const QByteArray &pw) {
- - ]
430 [ + - + + : 52 : if (success && !pw.isEmpty()) {
+ + ]
431 : 51 : acc.imap.password = pw;
432 [ + - + - : 102 : qCInfo(lcAccountConfig)
+ + ]
433 [ + - + - ]: 51 : << "Resolved IMAP password from keyring for" << acc.name;
434 : : }
435 [ - + - - : 52 : if (--(*remaining) == 0 && callback) callback();
- + ]
436 : 52 : });
437 : : }
438 : :
439 [ + - ]: 53 : if (acc.smtp.password.isEmpty()) {
440 [ + - ]: 53 : store->readPassword(
441 : 159 : QStringLiteral("smtp"), acc.name,
442 [ + - + - : 106 : [&acc, remaining, callback](bool success, const QByteArray &pw) {
- - ]
443 [ + + + - : 53 : if (success && !pw.isEmpty()) {
+ + ]
444 : 52 : acc.smtp.password = pw;
445 [ + - + - : 104 : qCInfo(lcAccountConfig)
+ + ]
446 [ + - + - ]: 52 : << "Resolved SMTP password from keyring for" << acc.name;
447 : : }
448 [ + + + + : 53 : if (--(*remaining) == 0 && callback) callback();
+ + ]
449 : 53 : });
450 : : }
451 : : }
452 : 52 : }
|