@itd-api/crypto
v0.1.0
Published
Скрытые сообщения в постах и профилях итд.com: плагин шифрования для itd-api
Downloads
333
Maintainers
Readme
@itd-api/crypto
Скрытые сообщения в постах, комментариях и профилях итд.com — плагин к
itd-api.
Текст прячется в невидимых Unicode-символах внутри обычного поста либо записывается видимым шифротекстом. Читатель видит только обложку, а тот, у кого подключён этот пакет, получает исходное сообщение отдельным полем.
Отдельным пакетом — потому что клиенту API незачем знать про шифры, а формат скрытых
сообщений живёт своей жизнью: алгоритмы добавляются, не трогая itd-api.
Установка
npm i itd-api @itd-api/cryptoПоддерживается itd-api >=0.5.0 <1.0.0: crypto использует namespace
RequestOptions.extensions и стабильный operationId запроса.
Использование
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: 'секретный текст' },
{ extensions: { crypto: { encrypt: { cipher: 'invisible', cover: 'обычный пост' } } } },
);
// чтение: content не меняется, расшифровка приезжает рядом
const post = await itd.posts.get(created.id);
post.content; // 'обычный пост' и невидимая нагрузка следом
post.secret?.text; // 'секретный текст'Без обложки видимого текста не будет вовсе — получится «пустой» пост, в котором на самом деле есть сообщение:
await itd.posts.create(
{ content: 'только для своих' },
{ extensions: { crypto: { encrypt: 'invisible' } } },
);Где работает
Шифрование включается опцией extensions.crypto.encrypt у методов, принимающих текст:
Поддерживаемое действие определяется по стабильному operationId, а не по HTTP-пути. Поэтому
низкоуровневому itd.request() с encrypt требуется корректный явный ID; запрос raw, даже на
совпадающий URL, намеренно не маскируется под встроенный resource.
| Метод | Что шифруется |
|---|---|
| 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: 'скрытая подпись' },
{ extensions: { crypto: { 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: 'секрет' },
{ extensions: { crypto: { 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({}, { extensions: { crypto: { 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: 'секрет' },
{ extensions: { crypto: { encrypt: 'base64' } } },
);decode возвращает null, когда в строке ничего нет: по этому признаку плагин и решает,
зашифрован ли текст. При нескольких подключённых шифрах побеждает первый, который прочитал
текст, — порядок в ciphers задаёт приоритет.
Имена встроенных шифров собраны в CipherName — замороженном объекте, как перечисления
в самом itd-api. Своя строка остаётся валидной, а Object.values(CipherName) даёт список
известных:
import { CipherName } from '@itd-api/crypto';
await itd.posts.create(
{ content: 'секрет' },
{ extensions: { crypto: { encrypt: CipherName.Invisible } } },
);Что нужно знать
- Это обфускация, а не шифрование. Кто знает алфавит — прочитает сообщение. Для секретности комбинируйте с настоящим шифром: сначала зашифруйте текст сами, потом спрячьте результат.
- Длина. Четыре невидимых символа на каждый байт UTF-8: ×4 к длине для латиницы, ×8 для кириллицы. Лимит длины поста на сервере считается по ним же.
- Разметка.
spansзадаются по обложке, а не по секретному тексту: видимым остаётся только она, а нагрузка крепится в конец и смещений не сдвигает. Поэтомуspansвместе сencryptпринимаются, лишь когда обложка задана и вмещает каждый фрагмент; в остальных случаях —CryptError, чтобы на сервер не уехали смещения, указывающие в пустоту. - Уведомления не расшифровываются. Библиотека пересобирает их в единую форму уже после
плагина, и находка до вызывающего кода не доедет. Берите текст поста через
itd.posts.get(). - Поток realtime не покрыт: плагины работают в HTTP-транспорте, а поток событий идёт мимо него.
Лицензия
MIT
