meta-sovereign 0.49.15 → 0.49.16

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 (49) hide show
  1. package/js/src/server/background-jobs.js +3 -1
  2. package/js/src/server/backup-job.js +1 -0
  3. package/js/src/server/index.js +2 -2
  4. package/js/src/server/routes-telegram-contact-extras.js +102 -0
  5. package/js/src/server/routes-telegram-security-extras.js +64 -0
  6. package/js/src/server/routes-telegram.js +4 -0
  7. package/js/src/sources/secret-chat-crypto.js +275 -0
  8. package/js/src/sources/secret-chat-session.js +178 -0
  9. package/js/src/sources/secret-chat-tl.js +418 -0
  10. package/js/src/sources/telegram-actions.js +8 -1
  11. package/js/src/sources/telegram-chat-info.js +3 -0
  12. package/js/src/sources/telegram-contact-extras.js +290 -0
  13. package/js/src/sources/telegram-security-extras.js +208 -0
  14. package/js/src/sources/telegram-security.js +5 -0
  15. package/js/src/sources/telegram-write-outcome.js +40 -0
  16. package/js/src/storage/backup.js +17 -3
  17. package/js/src/web/app.css +76 -0
  18. package/js/src/web/app.js +3 -0
  19. package/js/src/web/chat-actions.js +49 -28
  20. package/js/src/web/chat-contact-extras.js +396 -0
  21. package/js/src/web/chat-contact-share.js +109 -0
  22. package/js/src/web/chat-contacts-synced.js +64 -0
  23. package/js/src/web/chat-contacts-window.js +24 -1
  24. package/js/src/web/chat-drafts-clear.js +52 -24
  25. package/js/src/web/chat-failure.js +2 -0
  26. package/js/src/web/chat-invite.js +2 -1
  27. package/js/src/web/chat-message.js +3 -0
  28. package/js/src/web/chat-paid-send.js +14 -8
  29. package/js/src/web/chat-privacy.js +19 -0
  30. package/js/src/web/chat-profile-extras.js +1 -1
  31. package/js/src/web/chat-replies-block.js +91 -0
  32. package/js/src/web/chat-security-extras.js +303 -0
  33. package/js/src/web/chat-security-forgot.js +240 -0
  34. package/js/src/web/chat-security.js +9 -0
  35. package/js/src/web/chat-selection.js +26 -7
  36. package/js/src/web/chat-sign-in-code.js +62 -0
  37. package/js/src/web/chat-view.js +3 -0
  38. package/js/src/web/contact-links.js +171 -0
  39. package/js/src/web/dom-telegram-calls.js +26 -0
  40. package/js/src/web/dom.js +3 -1
  41. package/js/src/web/locales/en-chat-account.js +120 -0
  42. package/js/src/web/locales/hi-chat-account.js +116 -0
  43. package/js/src/web/locales/ru-chat-account.js +116 -0
  44. package/js/src/web/locales/zh-chat-account.js +107 -0
  45. package/js/src/web/message-text.js +5 -2
  46. package/js/src/web/passkey-codec.js +100 -0
  47. package/js/src/web/person-panel.js +3 -0
  48. package/js/src/web/sign-in-code-guard.js +91 -0
  49. package/package.json +5 -1
@@ -78,14 +78,16 @@ export const backgroundJobs = ({ ctx, store, log, later, backups }) => {
78
78
  });
79
79
  const purge = cachePurgeFor({ store, log, later });
80
80
  return {
81
+ // Returns a promise settled once a backup in flight has finished.
81
82
  stopJobs: () => {
82
- backup.stopBackups();
83
+ const backupDone = backup.stopBackups();
83
84
  audience.stopAudienceSync();
84
85
  purge.stopCachePurge();
85
86
  // The other timers and pollers the server started with the port.
86
87
  ctx.autoImport?.close();
87
88
  ctx.whatsappSignIn?.stop().catch(() => {});
88
89
  ctx.stopPulls?.();
90
+ return backupDone;
89
91
  },
90
92
  };
91
93
  };
@@ -87,5 +87,6 @@ export const backupJobFor = ({ ctx, store, log, later, backups }) => {
87
87
  });
88
88
  });
89
89
  }
90
+ // Resolves once a backup in flight has finished (and pruned).
90
91
  return { stopBackups: () => scheduler?.stop() };
91
92
  };
@@ -916,13 +916,13 @@ export const startServer = async ({
916
916
  bus?.close();
917
917
  ctx.replies.close().catch(() => {});
918
918
  ctx.closeAutomations().catch(() => {});
919
- ctx.stopJobs();
919
+ const jobsDone = ctx.stopJobs();
920
920
  ctx.chatIndex.close();
921
921
  stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
922
922
  wsHandle?.close();
923
923
  signaling?.close();
924
924
  closeFront();
925
- server.close(() => resolve());
925
+ server.close(() => Promise.resolve(jobsDone).then(resolve, resolve));
926
926
  for (const socket of sockets) {
927
927
  socket.destroy();
928
928
  }
@@ -0,0 +1,102 @@
1
+ // Contact notes, share-my-contact links, phone links, photos for contacts,
2
+ // suggested birthdays, synced contacts and blocking from Replies (R-Y217):
3
+ // the routes of the POSTS table in routes-telegram.js, each validated
4
+ // strictly before the account is asked.
5
+ //
6
+ // Its routes:
7
+ // POST /api/telegram/contact-note {chat, note} -> { ok, note } the private
8
+ // note on a contact (at most 128 characters, "" clears)
9
+ // POST /api/telegram/contact-token {} -> { ok, url, expires } the link
10
+ // t.me/contact/<token> to share the user's contact
11
+ // POST /api/telegram/contact-token-open {token} -> { ok, chat, name } the
12
+ // person that link names
13
+ // POST /api/telegram/phone-resolve {phone} -> { ok, found, chat?, name? }
14
+ // the person with that number (t.me/+<digits>)
15
+ // POST /api/telegram/contact-photo {chat, mode, token?} -> { ok, mode }
16
+ // mode save (only the user sees it), suggest, or reset
17
+ // (the personal photo removed; no token)
18
+ // POST /api/telegram/birthday-suggest {chat, day, month, year?} -> { ok }
19
+ // POST /api/telegram/saved-contacts {} -> { ok, count } the imported
20
+ // contacts Telegram keeps
21
+ // POST /api/telegram/saved-contacts-reset {} -> { ok } those deleted
22
+ // POST /api/telegram/block-from-replies {msgId, deleteMessage?,
23
+ // deleteHistory?, reportSpam?} -> { ok } the sender
24
+ // of a message of the Replies chat blocked
25
+
26
+ import { route } from './routes-telegram-admin-extras.js';
27
+ import {
28
+ MODES,
29
+ NOTE_MAX,
30
+ PHONE_DIGITS,
31
+ TOKEN,
32
+ USER,
33
+ validMsgId,
34
+ } from '../sources/telegram-contact-extras.js';
35
+
36
+ const UPLOAD = /^[a-f0-9]{32}$/;
37
+ const user = (b) => USER.test(String(b.chat ?? ''));
38
+ const flag = (v) => v === undefined || typeof v === 'boolean';
39
+ const always = () => true;
40
+
41
+ export const CONTACT_EXTRAS_POSTS = {
42
+ 'contact-note': route(
43
+ 'contactNote',
44
+ 'chat and note required',
45
+ (b) => user(b) && typeof b.note === 'string' && b.note.length <= NOTE_MAX,
46
+ (b) => ({ chat: String(b.chat), note: b.note })
47
+ ),
48
+ 'contact-token': route('contactToken', 'unsupported', always, () => ({})),
49
+ 'contact-token-open': route(
50
+ 'contactTokenOpen',
51
+ 'token required',
52
+ (b) => typeof b.token === 'string' && TOKEN.test(b.token),
53
+ (b) => ({ token: b.token })
54
+ ),
55
+ 'phone-resolve': route(
56
+ 'phoneResolve',
57
+ 'phone required',
58
+ (b) =>
59
+ typeof b.phone === 'string' &&
60
+ PHONE_DIGITS.test(b.phone.replace(/^\+/, '')),
61
+ (b) => ({ phone: b.phone })
62
+ ),
63
+ 'contact-photo': route(
64
+ 'contactPhoto',
65
+ 'chat, mode and token required',
66
+ (b) =>
67
+ user(b) &&
68
+ MODES.includes(b.mode) &&
69
+ (b.mode === 'reset'
70
+ ? b.token === undefined
71
+ : typeof b.token === 'string' && UPLOAD.test(b.token)),
72
+ (b) => ({ chat: String(b.chat), mode: b.mode, token: b.token })
73
+ ),
74
+ 'birthday-suggest': route(
75
+ 'birthdaySuggest',
76
+ 'chat, day and month required',
77
+ (b) => user(b) && Number.isInteger(b.day) && Number.isInteger(b.month),
78
+ ({ chat, day, month, year }) => ({ chat: String(chat), day, month, year })
79
+ ),
80
+ 'saved-contacts': route('savedContacts', 'unsupported', always, () => ({})),
81
+ 'saved-contacts-reset': route(
82
+ 'savedContactsReset',
83
+ 'unsupported',
84
+ always,
85
+ () => ({})
86
+ ),
87
+ 'block-from-replies': route(
88
+ 'blockFromReplies',
89
+ 'msgId required',
90
+ (b) =>
91
+ validMsgId(b.msgId) &&
92
+ flag(b.deleteMessage) &&
93
+ flag(b.deleteHistory) &&
94
+ flag(b.reportSpam),
95
+ ({ msgId, deleteMessage, deleteHistory, reportSpam }) => ({
96
+ msgId,
97
+ deleteMessage,
98
+ deleteHistory,
99
+ reportSpam,
100
+ })
101
+ ),
102
+ };
@@ -0,0 +1,64 @@
1
+ // More of Privacy and Security (R-Y218): the routes of the POSTS table in
2
+ // routes-telegram.js, each validated strictly before the account is asked.
3
+ // A code in a body is passed on and never kept.
4
+ //
5
+ // Its routes:
6
+ // POST /api/telegram/login-email-send {email} -> { ok, pattern, length }
7
+ // POST /api/telegram/login-email-verify {code} -> { ok, email }
8
+ // POST /api/telegram/recovery-request -> { ok, pattern } a code mailed to
9
+ // the recovery email
10
+ // POST /api/telegram/recovery-confirm {code} -> { ok } the password gone
11
+ // POST /api/telegram/password-reset -> { ok, result: 'ok' | 'wait' |
12
+ // 'failed-wait', until }
13
+ // POST /api/telegram/password-reset-cancel -> { ok }
14
+ // POST /api/telegram/passkeys -> { ok, passkeys: [{ id, name, created,
15
+ // lastUsed }] }
16
+ // POST /api/telegram/passkey-options -> { ok, options } WebAuthn JSON
17
+ // POST /api/telegram/passkey-register {id, rawId, clientData,
18
+ // attestation} -> { ok, passkey } (base64url strings)
19
+ // POST /api/telegram/passkey-delete {id} -> { ok }
20
+ // POST /api/telegram/sign-in-codes -> { ok, codes } fresh codes from 777000
21
+ // POST /api/telegram/invalidate-codes {codes: ['12345']} -> { ok }
22
+
23
+ import { route } from './routes-telegram-admin-extras.js';
24
+
25
+ const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}$/;
26
+ const CODE = /^[0-9A-Za-z]{1,16}$/;
27
+ const str = (v, max) =>
28
+ typeof v === 'string' && v.length > 0 && v.length <= max;
29
+ const nothing = () => true;
30
+ const code = (b) => CODE.test(String(b.code ?? ''));
31
+
32
+ export const SECURITY_EXTRAS_POSTS = {
33
+ 'login-email-send': route('loginEmailSend', 'a valid email required', (b) =>
34
+ EMAIL.test(String(b.email ?? ''))
35
+ ),
36
+ 'login-email-verify': route('loginEmailVerify', 'the code required', code),
37
+ 'recovery-request': route('recoveryRequest', 'no body', nothing),
38
+ 'recovery-confirm': route('recoveryConfirm', 'the code required', code),
39
+ 'password-reset': route('passwordReset', 'no body', nothing),
40
+ 'password-reset-cancel': route('passwordResetCancel', 'no body', nothing),
41
+ passkeys: route('passkeys', 'no body', nothing),
42
+ 'passkey-options': route('passkeyOptions', 'no body', nothing),
43
+ 'passkey-register': route(
44
+ 'passkeyRegister',
45
+ 'id, rawId, clientData and attestation required',
46
+ (b) =>
47
+ ['id', 'rawId', 'clientData', 'attestation'].every((k) =>
48
+ str(b[k], 100_000)
49
+ )
50
+ ),
51
+ 'passkey-delete': route('passkeyDelete', 'the passkey id required', (b) =>
52
+ str(b.id, 1024)
53
+ ),
54
+ 'sign-in-codes': route('signInCodes', 'no body', nothing),
55
+ 'invalidate-codes': route(
56
+ 'invalidateCodes',
57
+ 'one to ten codes required',
58
+ (b) =>
59
+ Array.isArray(b.codes) &&
60
+ b.codes.length > 0 &&
61
+ b.codes.length <= 10 &&
62
+ b.codes.every((v) => /^\d{5,6}$/.test(String(v)))
63
+ ),
64
+ };
@@ -243,6 +243,7 @@ import { FOLDER_EDIT_POSTS } from './routes-telegram-folder-edit.js';
243
243
  import { CHATLIST_POSTS } from './routes-telegram-chatlists.js';
244
244
  import { STICKER_SETTINGS_POSTS } from './routes-telegram-sticker-settings.js';
245
245
  import { CHAT_THEME_POSTS } from './routes-telegram-chat-themes.js';
246
+ import { CONTACT_EXTRAS_POSTS } from './routes-telegram-contact-extras.js';
246
247
  import { CLOUD_THEME_POSTS } from './routes-telegram-cloud-themes.js';
247
248
  import { COMMON_CHATS_POSTS } from './routes-telegram-common-chats.js';
248
249
  import { STATS_POSTS } from './routes-telegram-stats.js';
@@ -267,6 +268,7 @@ import { EMOJI_STATUS_POSTS } from './routes-telegram-emoji-status.js';
267
268
  import { ROUND_POSTS } from './routes-telegram-round.js';
268
269
  import { PRIVACY_POSTS } from './routes-telegram-privacy.js';
269
270
  import { SECURITY_POSTS } from './routes-telegram-security.js';
271
+ import { SECURITY_EXTRAS_POSTS } from './routes-telegram-security-extras.js';
270
272
  import { SHARE_POSTS } from './routes-telegram-share.js';
271
273
  import { INLINE_POSTS } from './routes-telegram-inline.js';
272
274
  import { MESSAGE_LINK_POSTS } from './routes-telegram-message-links.js';
@@ -1022,6 +1024,7 @@ const POSTS = {
1022
1024
  ...ADMIN_POSTS,
1023
1025
  ...EPHEMERAL_POSTS,
1024
1026
  ...YEXTRAS_POSTS,
1027
+ ...CONTACT_EXTRAS_POSTS,
1025
1028
  ...GROUP_EXTRAS_POSTS,
1026
1029
  ...INSTANT_VIEW_POSTS,
1027
1030
  ...STICKER_EXTRAS_POSTS,
@@ -1071,6 +1074,7 @@ const POSTS = {
1071
1074
  ...ROUND_POSTS,
1072
1075
  ...PRIVACY_POSTS,
1073
1076
  ...SECURITY_POSTS,
1077
+ ...SECURITY_EXTRAS_POSTS,
1074
1078
  ...SHARE_POSTS,
1075
1079
  ...INLINE_POSTS,
1076
1080
  ...TTL_POSTS,
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The cryptography of secret chats (R-Y219; learned from Telegram's public
3
+ * description, https://core.telegram.org/api/end-to-end, and the layer-73
4
+ * schema credited in ./secret-chat-tl.js; never copied from a GPL client).
5
+ * Groundwork only: nothing imports this module but the secret-chat session
6
+ * and the tests, and no route or page uses it. Pure: the random bytes come
7
+ * in as an argument, so the tests fix them.
8
+ *
9
+ * Key exchange: the same Diffie-Hellman steps as a call's (./private-call-dh.js):
10
+ * the requester sends g_a, the accepter answers g_b, both get
11
+ * `key = g^(ab) mod p` (256 bytes) and its fingerprint (the low 64 bits of
12
+ * SHA1(key)). Telegram's checks are in `checkParameters` and `publicInRange`.
13
+ *
14
+ * Messages (MTProto 2.0): the payload is the serialised
15
+ * DecryptedMessageLayer with its length (int32, little endian) in front and
16
+ * 12..1024 random bytes behind, so that the whole is a multiple of 16:
17
+ *
18
+ * msg_key_large = SHA256(key[88+x .. 88+x+32] + plaintext)
19
+ * msg_key = msg_key_large[8 .. 24]
20
+ * sha256_a = SHA256(msg_key + key[x .. x+36])
21
+ * sha256_b = SHA256(key[40+x .. 40+x+36] + msg_key)
22
+ * aes_key = a[0..8] + b[8..24] + a[24..32]
23
+ * aes_iv = b[0..8] + a[8..24] + b[24..32]
24
+ *
25
+ * with x = 0 for what the chat's originator sends and x = 8 for what the
26
+ * other side sends. The packet is `key_fingerprint (8) + msg_key (16) +
27
+ * AES-256-IGE(plaintext)`.
28
+ */
29
+
30
+ import crypto from 'node:crypto';
31
+ import {
32
+ bigOf,
33
+ bytesOf,
34
+ checkDhConfig,
35
+ exponentOf,
36
+ fingerprintOf,
37
+ publicInRange,
38
+ publicOf,
39
+ sharedKey,
40
+ } from './private-call-dh.js';
41
+
42
+ export const MIN_PADDING = 12;
43
+ export const MAX_PADDING = 1024;
44
+ export const KEY_BYTES = 256;
45
+
46
+ /** The default random source; tests pass their own. */
47
+ export const systemRandom = {
48
+ bytes: (n) => crypto.randomBytes(n),
49
+ int: (max) => crypto.randomInt(max),
50
+ };
51
+
52
+ const sha256 = (...parts) => {
53
+ const hash = crypto.createHash('sha256');
54
+ parts.forEach((p) => hash.update(p));
55
+ return hash.digest();
56
+ };
57
+
58
+ const xor16 = (a, b) => {
59
+ const out = Buffer.alloc(16);
60
+ for (let i = 0; i < 16; i += 1) {
61
+ out[i] = a[i] ^ b[i];
62
+ }
63
+ return out;
64
+ };
65
+
66
+ // One block through AES-256 without padding or chaining.
67
+ const blocks = (mode, key) => {
68
+ const cipher = (
69
+ mode === 'encrypt' ? crypto.createCipheriv : crypto.createDecipheriv
70
+ )('aes-256-ecb', key, Buffer.alloc(0));
71
+ cipher.setAutoPadding(false);
72
+ return (block) => cipher.update(block);
73
+ };
74
+
75
+ /** AES-256-IGE (key 32 bytes, iv 32 bytes, data a multiple of 16). */
76
+ export const igeEncrypt = (key, iv, data) => {
77
+ const run = blocks('encrypt', key);
78
+ let c = iv.subarray(0, 16);
79
+ let p = iv.subarray(16, 32);
80
+ const out = Buffer.alloc(data.length);
81
+ for (let i = 0; i < data.length; i += 16) {
82
+ const plain = data.subarray(i, i + 16);
83
+ const cipher = xor16(run(xor16(plain, c)), p);
84
+ cipher.copy(out, i);
85
+ c = cipher;
86
+ p = plain;
87
+ }
88
+ return out;
89
+ };
90
+
91
+ export const igeDecrypt = (key, iv, data) => {
92
+ const run = blocks('decrypt', key);
93
+ let c = iv.subarray(0, 16);
94
+ let p = iv.subarray(16, 32);
95
+ const out = Buffer.alloc(data.length);
96
+ for (let i = 0; i < data.length; i += 16) {
97
+ const cipher = data.subarray(i, i + 16);
98
+ const plain = xor16(run(xor16(cipher, p)), c);
99
+ plain.copy(out, i);
100
+ c = cipher;
101
+ p = plain;
102
+ }
103
+ return out;
104
+ };
105
+
106
+ /** msg_key for a plaintext (with its length and padding). */
107
+ export const messageKey = (key, plaintext, x) =>
108
+ sha256(key.subarray(88 + x, 88 + x + 32), plaintext).subarray(8, 24);
109
+
110
+ /** The AES key and iv of a packet, from the shared key and msg_key. */
111
+ export const packetKeys = (key, msgKey, x) => {
112
+ const a = sha256(msgKey, key.subarray(x, x + 36));
113
+ const b = sha256(key.subarray(40 + x, 40 + x + 36), msgKey);
114
+ return {
115
+ aesKey: Buffer.concat([
116
+ a.subarray(0, 8),
117
+ b.subarray(8, 24),
118
+ a.subarray(24, 32),
119
+ ]),
120
+ aesIv: Buffer.concat([
121
+ b.subarray(0, 8),
122
+ a.subarray(8, 24),
123
+ b.subarray(24, 32),
124
+ ]),
125
+ };
126
+ };
127
+
128
+ // 12..1024 padding bytes making `4 + length + padding` a multiple of 16.
129
+ const paddingLength = (length, random) => {
130
+ const least = MIN_PADDING + ((16 - ((4 + length + MIN_PADDING) % 16)) % 16);
131
+ const spare = Math.floor((MAX_PADDING - least) / 16);
132
+ return least + 16 * random.int(spare + 1);
133
+ };
134
+
135
+ /** The plaintext of a packet: length, payload, padding. */
136
+ export const padPayload = (payload, random = systemRandom) => {
137
+ const head = Buffer.alloc(4);
138
+ head.writeInt32LE(payload.length);
139
+ return Buffer.concat([
140
+ head,
141
+ payload,
142
+ random.bytes(paddingLength(payload.length, random)),
143
+ ]);
144
+ };
145
+
146
+ const fingerprintBytes = (fingerprint) => {
147
+ const b = Buffer.alloc(8);
148
+ b.writeBigInt64LE(BigInt(fingerprint));
149
+ return b;
150
+ };
151
+
152
+ /**
153
+ * The `data` of messages.sendEncrypted for a serialised
154
+ * DecryptedMessageLayer. `originator`: whether we started the chat.
155
+ */
156
+ export const encryptPayload = ({
157
+ key,
158
+ fingerprint,
159
+ payload,
160
+ originator,
161
+ random = systemRandom,
162
+ }) => {
163
+ const x = originator ? 0 : 8;
164
+ const plaintext = padPayload(payload, random);
165
+ const msgKey = messageKey(key, plaintext, x);
166
+ const { aesKey, aesIv } = packetKeys(key, msgKey, x);
167
+ return Buffer.concat([
168
+ fingerprintBytes(fingerprint),
169
+ msgKey,
170
+ igeEncrypt(aesKey, aesIv, plaintext),
171
+ ]);
172
+ };
173
+
174
+ /**
175
+ * The payload of a received packet. `fromOriginator`: whether the chat's
176
+ * originator sent it. `{ ok: true, payload }`, or `{ ok: false, reason }`
177
+ * with reason `short`, `fingerprint`, `msg-key` or `length`.
178
+ */
179
+ export const decryptPayload = ({ key, fingerprint, data, fromOriginator }) => {
180
+ const packet = Buffer.from(data ?? []);
181
+ if (packet.length < 8 + 16 + 16 || (packet.length - 24) % 16 !== 0) {
182
+ return { ok: false, reason: 'short' };
183
+ }
184
+ if (!packet.subarray(0, 8).equals(fingerprintBytes(fingerprint))) {
185
+ return { ok: false, reason: 'fingerprint' };
186
+ }
187
+ const x = fromOriginator ? 0 : 8;
188
+ const msgKey = packet.subarray(8, 24);
189
+ const { aesKey, aesIv } = packetKeys(key, msgKey, x);
190
+ const plaintext = igeDecrypt(aesKey, aesIv, packet.subarray(24));
191
+ const expected = messageKey(key, plaintext, x);
192
+ if (!crypto.timingSafeEqual(expected, msgKey)) {
193
+ return { ok: false, reason: 'msg-key' };
194
+ }
195
+ const length = plaintext.readInt32LE(0);
196
+ const padding = plaintext.length - 4 - length;
197
+ if (length < 0 || padding < MIN_PADDING || padding > MAX_PADDING) {
198
+ return { ok: false, reason: 'length' };
199
+ }
200
+ return { ok: true, payload: plaintext.subarray(4, 4 + length) };
201
+ };
202
+
203
+ /** Whether messages.getDhConfig's `g` and `p` are sound. */
204
+ export const checkParameters = (config) => checkDhConfig(config);
205
+
206
+ /** Our side of a new chat: the secret exponent and `g_a`. */
207
+ export const startExchange = (config, serverRandom, local) => {
208
+ const a = exponentOf(serverRandom, local);
209
+ return { a, ga: publicOf(config, a) };
210
+ };
211
+
212
+ /**
213
+ * The accepting side: from the requester's `g_a`, our `g_b`, the key and
214
+ * its fingerprint; `{ ok: false, reason }` when `g_a` is out of range.
215
+ */
216
+ export const acceptExchange = (config, ga, serverRandom, local) => {
217
+ if (!publicInRange(ga, config.p)) {
218
+ return { ok: false, reason: 'range' };
219
+ }
220
+ const b = exponentOf(serverRandom, local);
221
+ const key = sharedKey(config, ga, b);
222
+ return {
223
+ ok: true,
224
+ gb: publicOf(config, b),
225
+ key,
226
+ fingerprint: fingerprintOf(key),
227
+ };
228
+ };
229
+
230
+ /**
231
+ * The requester's last step, from the accepter's `g_b` (`g_a_or_b`) and the
232
+ * fingerprint Telegram reports: the key, or why it is refused.
233
+ */
234
+ export const finishExchange = (config, a, gb, fingerprint) => {
235
+ if (!publicInRange(gb, config.p)) {
236
+ return { ok: false, reason: 'range' };
237
+ }
238
+ const key = sharedKey(config, gb, a);
239
+ if (fingerprintOf(key) !== String(fingerprint)) {
240
+ return { ok: false, reason: 'fingerprint' };
241
+ }
242
+ return { ok: true, key, fingerprint: fingerprintOf(key) };
243
+ };
244
+
245
+ // ---- the key as the two people compare it ---------------------------------
246
+
247
+ /**
248
+ * The key as a picture to compare, derived from SHA256(key || g_a): an
249
+ * 8 x 8 identicon (mirrored, so 32 independent cells, each a colour index
250
+ * 0..5) and the hash as four lines of hex. A derived value only; the key
251
+ * itself never leaves the server. Both sides compute the same from the
252
+ * same key, so a man in the middle shows up as a different picture.
253
+ */
254
+ export const keyVisual = (key, ga) => {
255
+ const hash = sha256(Buffer.from(key), Buffer.from(ga ?? []));
256
+ const cells = [];
257
+ for (let row = 0; row < 8; row += 1) {
258
+ const half = [];
259
+ for (let col = 0; col < 4; col += 1) {
260
+ const byte = hash[row * 4 + col];
261
+ half.push(byte % 6);
262
+ }
263
+ cells.push([...half, ...[...half].reverse()]);
264
+ }
265
+ const hex = hash.toString('hex');
266
+ const lines = [0, 16, 32, 48].map((at) =>
267
+ hex
268
+ .slice(at, at + 16)
269
+ .replace(/(.{4})/g, '$1 ')
270
+ .trim()
271
+ );
272
+ return { cells, lines };
273
+ };
274
+
275
+ export { bigOf, bytesOf, fingerprintOf };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * One secret chat's cryptographic session: the key, the sequence numbers
3
+ * and the log of what was sent (R-Y219; learned from
4
+ * https://core.telegram.org/api/end-to-end/seq_no, never copied). Pure and
5
+ * immutable: every step returns the next state, a plain JSON object the
6
+ * server keeps in its secret store (the key is hex in it; it never leaves
7
+ * the server).
8
+ *
9
+ * Sequence numbers. The chat's originator numbers what it sends 1, 3, 5, ...
10
+ * (`out_seq_no = 2 * sent + 1`) and the other side 0, 2, 4, ...
11
+ * (`2 * sent`). Every layer carries `in_seq_no`, the `out_seq_no` the sender
12
+ * expects next from the other side (`2 * received + the other's parity`),
13
+ * which also tells the receiver what it may forget of its sent log. A
14
+ * received message whose `out_seq_no` is below the expected one is a
15
+ * duplicate (dropped); above it, a gap: it is dropped as well and a resend of
16
+ * the range `expected .. out_seq_no` is asked for (the other side then sends
17
+ * it again with the same numbers).
18
+ */
19
+
20
+ import {
21
+ decryptPayload,
22
+ encryptPayload,
23
+ systemRandom,
24
+ } from './secret-chat-crypto.js';
25
+ import {
26
+ SECRET_LAYER,
27
+ decodeLayer,
28
+ decodeMessage,
29
+ encodeLayer,
30
+ encodeMessage,
31
+ } from './secret-chat-tl.js';
32
+
33
+ const OUTBOX_MAX = 100;
34
+
35
+ const parityOf = (role) => (role === 'admin' ? 1 : 0);
36
+ const otherParity = (role) => 1 - parityOf(role);
37
+
38
+ /** A fresh session once the key is agreed. */
39
+ export const createSession = ({ id, accessHash, role, key, fingerprint }) => ({
40
+ id: String(id),
41
+ accessHash: String(accessHash),
42
+ role,
43
+ keyHex: Buffer.from(key).toString('hex'),
44
+ fingerprint: String(fingerprint),
45
+ inCount: 0,
46
+ outCount: 0,
47
+ peerLayer: 0,
48
+ ttl: 0,
49
+ outbox: [],
50
+ });
51
+
52
+ /** The out_seq_no the next message gets. */
53
+ export const nextOutSeq = (s) => 2 * s.outCount + parityOf(s.role);
54
+
55
+ /** The out_seq_no expected from the other side next. */
56
+ export const expectedInSeq = (s) => 2 * s.inCount + otherParity(s.role);
57
+
58
+ const wrap = (s, { message, outSeqNo }, random) => {
59
+ const layer = encodeLayer({
60
+ randomBytes: random.bytes(15 + random.int(16)),
61
+ layer: SECRET_LAYER,
62
+ inSeqNo: expectedInSeq(s),
63
+ outSeqNo,
64
+ message,
65
+ });
66
+ return encryptPayload({
67
+ key: Buffer.from(s.keyHex, 'hex'),
68
+ fingerprint: s.fingerprint,
69
+ payload: layer,
70
+ originator: s.role === 'admin',
71
+ random,
72
+ });
73
+ };
74
+
75
+ /**
76
+ * Encrypts `message` (see ./secret-chat-tl.js) as the next one we send:
77
+ * `{ data, state, outSeqNo }`. The message is kept for a resend.
78
+ */
79
+ export const seal = (state, message, random = systemRandom) => {
80
+ const outSeqNo = nextOutSeq(state);
81
+ const data = wrap(state, { message, outSeqNo }, random);
82
+ const kept = { seq: outSeqNo, hex: encodeMessage(message).toString('hex') };
83
+ return {
84
+ data,
85
+ outSeqNo,
86
+ state: {
87
+ ...state,
88
+ outCount: state.outCount + 1,
89
+ outbox: [...state.outbox, kept].slice(-OUTBOX_MAX),
90
+ },
91
+ };
92
+ };
93
+
94
+ /**
95
+ * The messages with out_seq_no from `start` to `end` (raw numbers, every
96
+ * second one is ours) sealed again as they were, for the other side's
97
+ * resend request; one we no longer have goes as a noop, so the numbers
98
+ * still follow on. Returns the packets.
99
+ */
100
+ export const resendRange = (state, start, end, random = systemRandom) => {
101
+ const packets = [];
102
+ const first = Math.max(start, 0);
103
+ const last = Math.min(end, nextOutSeq(state) - 2);
104
+ for (let seq = first; seq <= last && packets.length < OUTBOX_MAX; seq += 1) {
105
+ if (seq % 2 !== parityOf(state.role)) {
106
+ continue;
107
+ }
108
+ const kept = state.outbox.find((m) => m.seq === seq);
109
+ const message = kept
110
+ ? decodeMessage(Buffer.from(kept.hex, 'hex'))
111
+ : {
112
+ type: 'service',
113
+ randomId: BigInt(seq) + (1n << 40n),
114
+ action: { type: 'noop' },
115
+ };
116
+ packets.push({
117
+ seq,
118
+ data: wrap(state, { message, outSeqNo: seq }, random),
119
+ });
120
+ }
121
+ return packets;
122
+ };
123
+
124
+ /**
125
+ * Decrypts a received packet. One of
126
+ * { ok: false, reason } refused (see decryptPayload,
127
+ * plus `malformed` and `seq`)
128
+ * { ok: true, duplicate: true } already seen
129
+ * { ok: true, gap: { start, end }, state } a message is missing: ask for
130
+ * the resend of start..end
131
+ * { ok: true, layer, message, state } a message, in order
132
+ */
133
+ export const open = (state, data) => {
134
+ const plain = decryptPayload({
135
+ key: Buffer.from(state.keyHex, 'hex'),
136
+ fingerprint: state.fingerprint,
137
+ data,
138
+ fromOriginator: state.role !== 'admin',
139
+ });
140
+ if (!plain.ok) {
141
+ return plain;
142
+ }
143
+ let layer;
144
+ try {
145
+ layer = decodeLayer(plain.payload);
146
+ } catch {
147
+ return { ok: false, reason: 'malformed' };
148
+ }
149
+ if (layer.outSeqNo % 2 !== otherParity(state.role) || layer.outSeqNo < 0) {
150
+ return { ok: false, reason: 'seq' };
151
+ }
152
+ const expected = expectedInSeq(state);
153
+ if (layer.outSeqNo < expected) {
154
+ return { ok: true, duplicate: true };
155
+ }
156
+ // What the other side has received of ours need not be kept any more.
157
+ const settled = {
158
+ ...state,
159
+ peerLayer: Math.max(state.peerLayer, layer.layer),
160
+ outbox: state.outbox.filter((m) => m.seq >= layer.inSeqNo),
161
+ };
162
+ if (layer.outSeqNo > expected) {
163
+ return {
164
+ ok: true,
165
+ gap: { start: expected, end: layer.outSeqNo },
166
+ state: settled,
167
+ };
168
+ }
169
+ return {
170
+ ok: true,
171
+ layer,
172
+ message: layer.message,
173
+ state: { ...settled, inCount: state.inCount + 1 },
174
+ };
175
+ };
176
+
177
+ /** The same session with a new self-destruct timer (seconds, 0: off). */
178
+ export const withTtl = (state, ttl) => ({ ...state, ttl });