emailengine-app 2.65.0 → 2.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.github/workflows/deploy.yml +8 -8
  2. package/.github/workflows/release.yaml +9 -9
  3. package/.github/workflows/test.yml +2 -2
  4. package/CHANGELOG.md +53 -0
  5. package/bin/emailengine.js +3 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/lib/account.js +35 -29
  8. package/lib/consts.js +5 -0
  9. package/lib/email-client/gmail-client.js +23 -27
  10. package/lib/email-client/imap/mailbox.js +46 -19
  11. package/lib/email-client/imap/sync-operations.js +51 -19
  12. package/lib/email-client/imap-client.js +28 -5
  13. package/lib/email-client/outlook-client.js +155 -1
  14. package/lib/oauth/gmail.js +52 -1
  15. package/lib/passkeys.js +206 -0
  16. package/lib/routes-ui.js +522 -21
  17. package/lib/ui-routes/oauth-routes.js +6 -1
  18. package/package.json +13 -11
  19. package/sbom.json +1 -1
  20. package/static/js/login-passkey.js +75 -0
  21. package/static/js/passkey-register.js +107 -0
  22. package/static/licenses.html +238 -38
  23. package/static/vendor/handlebars/handlebars.min-v4.7.9.js +29 -0
  24. package/static/vendor/simplewebauthn/browser.min.js +2 -0
  25. package/translations/de.mo +0 -0
  26. package/translations/de.po +91 -53
  27. package/translations/en.mo +0 -0
  28. package/translations/en.po +84 -52
  29. package/translations/et.mo +0 -0
  30. package/translations/et.po +95 -60
  31. package/translations/fr.mo +0 -0
  32. package/translations/fr.po +102 -65
  33. package/translations/ja.mo +0 -0
  34. package/translations/ja.po +93 -57
  35. package/translations/messages.pot +101 -76
  36. package/translations/nl.mo +0 -0
  37. package/translations/nl.po +92 -56
  38. package/translations/pl.mo +0 -0
  39. package/translations/pl.po +106 -70
  40. package/views/account/login.hbs +35 -25
  41. package/views/account/password.hbs +4 -4
  42. package/views/account/security.hbs +101 -12
  43. package/views/account/totp.hbs +3 -3
  44. package/views/config/oauth/app.hbs +25 -0
  45. package/views/layout/app.hbs +2 -2
  46. package/views/layout/login.hbs +6 -1
  47. package/views/oauth-scope-error.hbs +29 -0
  48. package/workers/api.js +81 -3
  49. package/workers/imap.js +4 -0
  50. package/static/vendor/handlebars/handlebars.min-v4.7.7.js +0 -29
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { redis } = require('./db');
5
+ const settings = require('./settings');
6
+ const { REDIS_PREFIX, WEBAUTHN_CHALLENGE_TTL } = require('./consts');
7
+
8
+ const KEY_PREFIX = `${REDIS_PREFIX}webauthn:`;
9
+
10
+ // Passkey data does not require encryption at rest. Unlike TOTP seeds or OAuth
11
+ // client secrets (which are shared secrets), passkeys use public-key cryptography.
12
+ // Only the public key is stored here -- the private key never leaves the
13
+ // authenticator device. An attacker with Redis access cannot use a public key
14
+ // to authenticate, so encrypting it adds no meaningful security.
15
+
16
+ function hydrateCredential(data) {
17
+ if (!data || !data.id) {
18
+ return null;
19
+ }
20
+ data.counter = parseInt(data.counter, 10) || 0;
21
+ try {
22
+ data.transports = JSON.parse(data.transports || '[]');
23
+ } catch (err) {
24
+ data.transports = [];
25
+ }
26
+ return data;
27
+ }
28
+
29
+ async function fetchCredentialsBySet(setKey) {
30
+ let credIds = await redis.smembers(setKey);
31
+ if (!credIds || !credIds.length) {
32
+ return [];
33
+ }
34
+
35
+ let pipeline = redis.pipeline();
36
+ for (let id of credIds) {
37
+ pipeline.hgetall(`${KEY_PREFIX}cred:${id}`);
38
+ }
39
+ let results = await pipeline.exec();
40
+
41
+ let credentials = [];
42
+ for (let [err, data] of results) {
43
+ let cred = !err && hydrateCredential(data);
44
+ if (cred) {
45
+ credentials.push(cred);
46
+ }
47
+ }
48
+ return credentials;
49
+ }
50
+
51
+ redis.defineCommand('webauthnSaveIfUnderLimit', {
52
+ numberOfKeys: 3,
53
+ lua: `
54
+ local userSetKey = KEYS[1]
55
+ local credKey = KEYS[2]
56
+ local allSetKey = KEYS[3]
57
+ local maxCount = tonumber(ARGV[1])
58
+ local credId = ARGV[2]
59
+
60
+ local currentCount = redis.call('SCARD', userSetKey)
61
+ if currentCount >= maxCount then
62
+ return 0
63
+ end
64
+
65
+ redis.call('SADD', userSetKey, credId)
66
+ redis.call('SADD', allSetKey, credId)
67
+ redis.call('HSET', credKey, unpack(ARGV, 3))
68
+ return 1
69
+ `
70
+ });
71
+
72
+ function serializeCredential({ id, publicKey, counter, transports, name, user }) {
73
+ return {
74
+ id,
75
+ publicKey,
76
+ counter: String(counter),
77
+ transports: JSON.stringify(transports || []),
78
+ name: name || 'Unnamed passkey',
79
+ user,
80
+ createdAt: new Date().toISOString()
81
+ };
82
+ }
83
+
84
+ function credentialKeys(id, user) {
85
+ return {
86
+ credKey: `${KEY_PREFIX}cred:${id}`,
87
+ userSetKey: `${KEY_PREFIX}creds:${user}`,
88
+ allSetKey: `${KEY_PREFIX}all`
89
+ };
90
+ }
91
+
92
+ module.exports = {
93
+ async getRpConfig() {
94
+ let serviceUrl = await settings.get('serviceUrl');
95
+ if (!serviceUrl) {
96
+ return { rpId: null, origin: null };
97
+ }
98
+ let url = new URL(serviceUrl);
99
+ return { rpId: url.hostname, origin: url.origin };
100
+ },
101
+
102
+ async storeChallenge(challenge) {
103
+ let challengeId = crypto.randomBytes(32).toString('hex');
104
+ await redis.set(`${KEY_PREFIX}challenge:${challengeId}`, challenge, 'EX', WEBAUTHN_CHALLENGE_TTL);
105
+ return challengeId;
106
+ },
107
+
108
+ async consumeChallenge(challengeId) {
109
+ if (!challengeId || typeof challengeId !== 'string') {
110
+ return null;
111
+ }
112
+ let key = `${KEY_PREFIX}challenge:${challengeId}`;
113
+ let challenge = await redis.getdel(key);
114
+ return challenge || null;
115
+ },
116
+
117
+ async saveCredential(cred) {
118
+ let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
119
+
120
+ let pipeline = redis.pipeline();
121
+ pipeline.hset(credKey, serializeCredential(cred));
122
+ pipeline.sadd(userSetKey, cred.id);
123
+ pipeline.sadd(allSetKey, cred.id);
124
+ await pipeline.exec();
125
+ },
126
+
127
+ async saveCredentialIfUnderLimit(cred, maxCount) {
128
+ let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
129
+ let fields = serializeCredential(cred);
130
+ let fieldArgs = Object.entries(fields).flat();
131
+
132
+ let added = await redis.webauthnSaveIfUnderLimit(userSetKey, credKey, allSetKey, maxCount, cred.id, ...fieldArgs);
133
+
134
+ return !!added;
135
+ },
136
+
137
+ async getCredential(credentialId) {
138
+ if (!credentialId || typeof credentialId !== 'string') {
139
+ return null;
140
+ }
141
+ let data = await redis.hgetall(`${KEY_PREFIX}cred:${credentialId}`);
142
+ return hydrateCredential(data);
143
+ },
144
+
145
+ async updateCounter(credentialId, newCounter) {
146
+ await redis.hset(`${KEY_PREFIX}cred:${credentialId}`, 'counter', String(newCounter));
147
+ },
148
+
149
+ async listCredentials(user) {
150
+ let credentials = await fetchCredentialsBySet(`${KEY_PREFIX}creds:${user}`);
151
+ credentials.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
152
+ return credentials;
153
+ },
154
+
155
+ async deleteCredential(credentialId, user) {
156
+ let cred = await this.getCredential(credentialId);
157
+ if (!cred) {
158
+ return false;
159
+ }
160
+ if (user && cred.user !== user) {
161
+ return false;
162
+ }
163
+
164
+ let pipeline = redis.pipeline();
165
+ pipeline.del(`${KEY_PREFIX}cred:${credentialId}`);
166
+ pipeline.srem(`${KEY_PREFIX}all`, credentialId);
167
+ if (cred.user) {
168
+ pipeline.srem(`${KEY_PREFIX}creds:${cred.user}`, credentialId);
169
+ }
170
+ await pipeline.exec();
171
+ return true;
172
+ },
173
+
174
+ async countCredentials(user) {
175
+ return await redis.scard(`${KEY_PREFIX}creds:${user}`);
176
+ },
177
+
178
+ async hasPasskeys() {
179
+ let count = await redis.scard(`${KEY_PREFIX}all`);
180
+ return count > 0;
181
+ },
182
+
183
+ async getAllCredentials() {
184
+ return await fetchCredentialsBySet(`${KEY_PREFIX}all`);
185
+ },
186
+
187
+ async deleteAllCredentials(user) {
188
+ let userSetKey = `${KEY_PREFIX}creds:${user}`;
189
+ let allSetKey = `${KEY_PREFIX}all`;
190
+
191
+ let credIds = await redis.smembers(userSetKey);
192
+ if (!credIds || !credIds.length) {
193
+ return 0;
194
+ }
195
+
196
+ let pipeline = redis.pipeline();
197
+ for (let id of credIds) {
198
+ pipeline.del(`${KEY_PREFIX}cred:${id}`);
199
+ pipeline.srem(allSetKey, id);
200
+ }
201
+ pipeline.del(userSetKey);
202
+ await pipeline.exec();
203
+
204
+ return credIds.length;
205
+ }
206
+ };