@itd-api/crypto
Скрытые сообщения в постах, комментариях и профилях итд.com — плагин к itd-api.
Текст прячется в невидимых Unicode-символах внутри обычного поста либо записывается видимым шифротекстом. Читатель видит только обложку, а тот, у кого подключён этот пакет, получает исходное сообщение отдельным полем.
Отдельным пакетом — потому что клиенту API незачем знать про шифры, а формат скрытых сообщений живёт своей жизнью: алгоритмы добавляются, не трогая itd-api.
Установка
npm i itd-api @itd-api/cryptoНужен itd-api версии не ниже 0.0.6 — в ней появился itd.use().
Использование
import { ItdClient } from 'itd-api';
import { crypt } from '@itd-api/crypto';
const itd = new ItdClient({ auth: process.env.ITD_TOKEN });
itd.use(crypt());
// отправка: текст прогоняется через шифр, обложка остаётся видимой
const created = await itd.posts.create(
{ content: 'секретный текст' },
{ encrypt: { cipher: 'invisible', cover: 'обычный пост' } },
);
// чтение: content не меняется, расшифровка приезжает рядом
const post = await itd.posts.get(created.id);
post.content; // 'обычный пост' и невидимая нагрузка следом
post.secret?.text; // 'секретный текст'Без обложки видимого текста не будет вовсе — получится «пустой» пост, в котором на самом деле есть сообщение:
await itd.posts.create({ content: 'только для своих' }, { encrypt: 'invisible' });Где работает
Шифрование включается опцией encrypt у методов, принимающих текст:
| Метод | Что шифруется |
|---|---|
itd.posts.create | content |
itd.posts.update | content |
itd.posts.repost | content |
itd.posts.comment | content |
itd.comments.reply | content |
itd.comments.update | content |
itd.users.updateMe | displayName, bio |
itd.users.createProfile | displayName |
Расшифровка работает всюду и сама: ответ просматривается целиком, поэтому находки появляются и у постов ленты, и у исходного поста репоста, и у комментариев внутри поста, и у авторов — везде, где есть content, bio или displayName.
for await (const post of itd.posts.iterate({ tab: 'following' })) {
if (post.secret) console.log(`@${post.author.username} спрятал: ${post.secret.text}`);
}У профиля полей два, и шифруются они независимо — все находки лежат в secrets:
await itd.users.updateMe(
{ bio: 'скрытая подпись' },
{ encrypt: { fields: ['bio'], cover: 'то, что видят все' } },
);
const profile = await itd.users.get('username');
profile.secrets; // [{ cipher: 'invisible', field: 'bio', text: 'скрытая подпись' }]Шифры
| Имя | Как выглядит | Обложка |
|---|---|---|
invisible | невидимые символы U+206A…U+206F | да |
beecrypt | видимый текст из букв жъЖЪ | нет |
Подключены оба, шифрует по умолчанию первый — invisible. Расшифровка перебирает все: какой прочитал текст, тот и попадёт в secret.cipher.
await itd.posts.create({ content: 'секрет' }, { encrypt: 'beecrypt' });
// content уходит как «ЖъЪжЖъЖЪжъ…» — сообщение не спрятано, а записано другими буквами
const post = await itd.posts.get(id);
post.secret; // { cipher: 'beecrypt', field: 'content', text: 'секрет' }У beecrypt шифротекст виден целиком, поэтому обложки у него нет: переданная cover не игнорируется, а отвергается ошибкой — молча потерять видимый текст хуже.
Настройки
import { crypt, invisible } from '@itd-api/crypto';
itd.use(
crypt({
ciphers: [invisible], // по умолчанию — все встроенные, BUILT_IN_CIPHERS
decrypt: true, // искать ли скрытое в ответах; по умолчанию да
}),
);
// расшифровку можно выключить или включить у отдельного вызова
await itd.posts.list({ decrypt: false });Читать secret можно и без дополнений типов — помощниками secretOf и secretsOf:
import { secretOf } from '@itd-api/crypto';
const text = secretOf(post)?.text;Алгоритм invisible
Алфавит — шесть невидимых символов U+206A…U+206F, основание системы счисления 6. Каждый байт UTF-8 записывается четырьмя символами (6⁴ = 1296 ≥ 256); фиксированная ширина заменяет разделитель, которым мог быть только пробел, а пробелы сервер схлопывает. Нагрузка крепится к обложке без маркеров — извлечение сводится к фильтрации строки по алфавиту.
Алфавит именно такой, потому что сервер итд.com нормализует текст поста при сохранении:
| Символы | Что происходит |
|---|---|
U+2000, U+2001, U+2002, U+200A, U+202F | заменяются пробелом, соседние схлопываются |
U+200B, U+200C | удаляются полностью |
U+200F, U+206A–U+206F | проходят без изменений |
Из последней строки исключён U+200F (RLM): он выживает, но разворачивает направление текста и ломает вид поста.
Алгоритм доступен и напрямую, без клиента:
import { encodeInvisible, decodeInvisible, stripInvisible } from '@itd-api/crypto';
const content = `обычный текст${encodeInvisible('секрет')}`;
decodeInvisible(content); // 'секрет'
stripInvisible(content); // 'обычный текст'Алгоритм beecrypt
Текст переводится в UTF-8, потом в base64, а каждая пара битов символов base64 заменяется буквой: 00 → ж, 01 → ъ, 10 → Ж, 11 → Ъ.
import { encodeBeeCrypt, decodeBeeCrypt } from '@itd-api/crypto';
encodeBeeCrypt('A'); // 'ъъжъъъжъжЪЪъжЪЪъ'Разбор строгий: любая посторонняя буква — и текст не считается зашифрованным. Пробелы и переносы строк пропускаются, поэтому перенос посреди шифротекста ничего не ломает. Проверок три — алфавит, корректный base64 и корректный UTF-8; без них строкой «жжжжжжжж» можно было бы «расшифровать» что угодно.
Длина растёт примерно в 5–6 раз от исходного текста — больше, чем у invisible для латиницы, но меньше для кириллицы.
Свой шифр
Контракт из двух методов — ни о запросах, ни о моделях шифр не знает:
import { crypt, type Cipher } from '@itd-api/crypto';
const base64: Cipher = {
name: 'base64',
encode: (text) => `[${btoa(unescape(encodeURIComponent(text)))}]`,
decode: (text) => {
const match = /^\[(.+)]$/.exec(text);
return match ? decodeURIComponent(escape(atob(match[1]))) : null;
},
};
itd.use(crypt({ ciphers: [base64] }));
await itd.posts.create({ content: 'секрет' }, { encrypt: 'base64' });decode возвращает null, когда в строке ничего нет: по этому признаку плагин и решает, зашифрован ли текст. При нескольких подключённых шифрах побеждает первый, который прочитал текст, — порядок в ciphers задаёт приоритет.
Имена встроенных шифров собраны в CipherName — замороженном объекте, как перечисления в самом itd-api. Своя строка остаётся валидной, а Object.values(CipherName) даёт список известных:
import { CipherName } from '@itd-api/crypto';
await itd.posts.create({ content: 'секрет' }, { encrypt: CipherName.Invisible });Что нужно знать
- Это обфускация, а не шифрование. Кто знает алфавит — прочитает сообщение. Для секретности комбинируйте с настоящим шифром: сначала зашифруйте текст сами, потом спрячьте результат.
- Длина. Четыре невидимых символа на каждый байт UTF-8: ×4 к длине для латиницы, ×8 для кириллицы. Лимит длины поста на сервере считается по ним же.
- Разметка.
spansзадаются по обложке, а не по секретному тексту: видимым остаётся только она, а нагрузка крепится в конец и смещений не сдвигает. Поэтомуspansвместе сencryptпринимаются, лишь когда обложка задана и вмещает каждый фрагмент; в остальных случаях —CryptError, чтобы на сервер не уехали смещения, указывающие в пустоту. - Уведомления не расшифровываются. Библиотека пересобирает их в единую форму уже после плагина, и находка до вызывающего кода не доедет. Берите текст поста через
itd.posts.get(). - Поток realtime не покрыт: плагины работают в HTTP-транспорте, а поток событий идёт мимо него.