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.
- package/js/src/server/background-jobs.js +3 -1
- package/js/src/server/backup-job.js +1 -0
- package/js/src/server/index.js +2 -2
- package/js/src/server/routes-telegram-contact-extras.js +102 -0
- package/js/src/server/routes-telegram-security-extras.js +64 -0
- package/js/src/server/routes-telegram.js +4 -0
- package/js/src/sources/secret-chat-crypto.js +275 -0
- package/js/src/sources/secret-chat-session.js +178 -0
- package/js/src/sources/secret-chat-tl.js +418 -0
- package/js/src/sources/telegram-actions.js +8 -1
- package/js/src/sources/telegram-chat-info.js +3 -0
- package/js/src/sources/telegram-contact-extras.js +290 -0
- package/js/src/sources/telegram-security-extras.js +208 -0
- package/js/src/sources/telegram-security.js +5 -0
- package/js/src/sources/telegram-write-outcome.js +40 -0
- package/js/src/storage/backup.js +17 -3
- package/js/src/web/app.css +76 -0
- package/js/src/web/app.js +3 -0
- package/js/src/web/chat-actions.js +49 -28
- package/js/src/web/chat-contact-extras.js +396 -0
- package/js/src/web/chat-contact-share.js +109 -0
- package/js/src/web/chat-contacts-synced.js +64 -0
- package/js/src/web/chat-contacts-window.js +24 -1
- package/js/src/web/chat-drafts-clear.js +52 -24
- package/js/src/web/chat-failure.js +2 -0
- package/js/src/web/chat-invite.js +2 -1
- package/js/src/web/chat-message.js +3 -0
- package/js/src/web/chat-paid-send.js +14 -8
- package/js/src/web/chat-privacy.js +19 -0
- package/js/src/web/chat-profile-extras.js +1 -1
- package/js/src/web/chat-replies-block.js +91 -0
- package/js/src/web/chat-security-extras.js +303 -0
- package/js/src/web/chat-security-forgot.js +240 -0
- package/js/src/web/chat-security.js +9 -0
- package/js/src/web/chat-selection.js +26 -7
- package/js/src/web/chat-sign-in-code.js +62 -0
- package/js/src/web/chat-view.js +3 -0
- package/js/src/web/contact-links.js +171 -0
- package/js/src/web/dom-telegram-calls.js +26 -0
- package/js/src/web/dom.js +3 -1
- package/js/src/web/locales/en-chat-account.js +120 -0
- package/js/src/web/locales/hi-chat-account.js +116 -0
- package/js/src/web/locales/ru-chat-account.js +116 -0
- package/js/src/web/locales/zh-chat-account.js +107 -0
- package/js/src/web/message-text.js +5 -2
- package/js/src/web/passkey-codec.js +100 -0
- package/js/src/web/person-panel.js +3 -0
- package/js/src/web/sign-in-code-guard.js +91 -0
- 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
|
};
|
package/js/src/server/index.js
CHANGED
|
@@ -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 });
|