@itd-api/crypto
Шифрование отдельных участков текста в постах, комментариях и других текстовых полях итд.com — плагин к itd-api.
В одном поле могут одновременно находиться открытый текст и несколько независимо зашифрованных участков. При отправке плагин помещает отдельные участки в транспортные контейнеры, а целое поле без внутренних границ разметки кодирует без обёртки. После ответа он собирает готовый текст в decoded. Значение, которое вернул сервер, при этом остаётся в исходном поле без изменений.
Отдельный пакет позволяет подключать эту возможность только там, где она нужна, и добавлять собственные алгоритмы без изменений основного клиента.
Не криптографическая защита
Встроенные invisible и beecrypt скрывают представление текста, но не обеспечивают секретность. Любой, кто знает формат, сможет восстановить сообщение. Для настоящей защиты сначала зашифруйте данные криптографическим алгоритмом, а затем передайте результат этому плагину.
Установка
npm i itd-api @itd-api/cryptoПакет работает во всех средах основного клиента и не требует платформенных модулей Node.js.
Первый пример
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: 'видно секрет видно',
spans: [
{ type: 'bold', offset: 6, length: 6 },
{ type: 'crypto', cipher: 'invisible', offset: 6, length: 6 },
],
});
created.content; // исходное транспортное значение от сервера
created.spans; // серверная разметка в координатах content
created.decoded?.content?.text; // 'видно секрет видно'
created.decoded?.content?.spans; // обычная и crypto-разметка готового текстаcontent содержит именно то, что сохранил сервер: открытые части и невидимый контейнер. Для отображения расшифрованного результата используйте decoded.content.text, а для его форматирования — decoded.content.spans.
Получившийся объект имеет такой вид:
{
content: 'видно <невидимый транспортный контейнер> видно',
spans: [{ type: 'bold', offset: 6, length: 57 }],
decoded: {
content: {
text: 'видно секрет видно',
spans: [
{ type: 'bold', offset: 6, length: 6 },
{
type: 'crypto',
cipher: 'invisible',
cipherId: 0,
offset: 6,
length: 6,
},
],
},
},
}Длина серверного bold относится к транспортному content. Внутри decoded тот же фрагмент уже пересчитан к исходному тексту.
Как указать зашифрованные участки
Через обычный массив spans
У постов криптографический участок задаётся разметкой типа crypto:
await itd.posts.create({
content: 'первый секрет и второй секрет',
spans: [
{ type: 'crypto', cipher: 'invisible', offset: 7, length: 6 },
{ type: 'crypto', cipher: 'my-cipher', offset: 22, length: 6 },
],
});Поле cipher принимает имя или числовой идентификатор подключённого шифра. Разметка crypto служит только плагину: перед отправкой она удаляется и никогда не попадает в серверный массив spans.
Через MarkupBuilder
Когда текст собирается по частям, существующий MarkupBuilder сам рассчитывает offset и length. Криптографический участок задаётся универсальным методом span(), поэтому отдельный builder не требуется:
import { post } from 'itd-api';
import { CipherName } from '@itd-api/crypto';
await itd.posts.create(
post().markup((m) =>
m
.text('Открыто, ')
.span('первый секрет', {
type: 'crypto',
cipher: CipherName.Invisible,
})
.text(', снова открыто, ')
.span(
(secret) => secret.bold('второй секрет'),
{
type: 'crypto',
cipher: CipherName.Invisible,
},
),
),
);Вложенная разметка сохраняется: в примере второй участок одновременно зашифрован и выделен жирным. Смещения обоих типов разметки относятся к одной исходной строке и рассчитываются builder автоматически.
Через extensions.crypto.spans
Комментарии принимают строку отдельно от остальных параметров, а программно рассчитанные диапазоны не всегда удобно добавлять в модель поста. Для таких случаев есть настройки одного вызова:
await itd.posts.comment('post-id', 'видно один и два видно', {
extensions: {
crypto: {
spans: [
{ cipher: 'invisible', offset: 6, length: 4 },
{ cipher: 5, offset: 13, length: 3 },
],
},
},
});Оба способа можно использовать одновременно. Диапазоны объединяются, сортируются и проверяются как один список.
Все offset и length измеряются в кодовых единицах UTF-16. Это те же координаты, которые используют String#slice, DOM Selection и обычные spans в itd-api.
Шифрование поля целиком
Если spans не передан, свойство encrypt шифрует все переданные непустые текстовые поля операции целиком:
await itd.users.updateMe(
{ displayName: 'Скрытое имя', bio: 'Скрытая подпись' },
{ extensions: { crypto: { encrypt: 'beecrypt' } } },
);У операции users.updateMe независимо обрабатываются displayName и bio. Чтобы зашифровать только одно из них, укажите полный диапазон с именем поля:
await itd.users.updateMe(
{ displayName: 'Открытое имя', bio: 'Скрытая подпись' },
{
extensions: {
crypto: {
spans: [
{ field: 'bio', cipher: 'beecrypt', offset: 0, length: 16 },
],
},
},
},
);Если у операции несколько текстовых полей, field обязателен в каждом диапазоне. Для операции с одним полем его можно опустить.
encrypt и spans — разные режимы, поэтому передавать их одновременно нельзя. Пустой массив spans: [] также считается ошибкой; для целого поля используйте encrypt.
Поддерживаемые поля
Поддержка отправки определяется по стабильному operationId, а не по адресу запроса.
| Операция | Текстовые поля | Серверная разметка |
|---|---|---|
posts.create, posts.update, posts.repost | content | spans |
posts.comment, comments.reply, comments.update | content | spans, если вернул сервер |
users.createProfile | displayName | нет |
users.updateMe | displayName, bio | нет |
При чтении плагин рекурсивно проверяет content, displayName, bio и preview. Поэтому расшифровка работает в постах, комментариях, профилях, авторах, кратких данных пользователей, участниках уведомлений и самих уведомлениях.
Невидимые маркеры подтверждены только для content и preview. Поэтому invisible и другие шифры с requiresInvisibleAlphabet: true нельзя применять к displayName и bio. Эти поля можно шифровать целиком через beecrypt или пользовательский алгоритм, которому невидимый алфавит не нужен.
Низкоуровневый запрос должен иметь известный operationId либо метаданные подключаемой операции. Совпадения одного HTTP-адреса недостаточно: иначе плагин не сможет безопасно определить текстовые поля.
Обычная разметка
Визуальная разметка может пересекать зашифрованный участок любым способом:
- совпадать с ним;
- полностью охватывать его;
- находиться внутри;
- пересекать начало или конец;
- охватывать несколько зашифрованных участков.
Поддерживаются визуальные типы bold, italic, underline, strike, spoiler, monospace и quote. Если граница такой разметки находится внутри crypto-диапазона, плагин делит его на несколько соседних контейнеров. После чтения контейнеры снова объединяются, а исходная граница форматирования восстанавливается точно.
crypto: [4, 10)
bold: [2, 6)
Перед отправкой crypto делится на [4, 6) и [6, 10).
После чтения bold снова занимает [2, 6).link, mention и hashtag несут не только визуальное оформление. Их пересечение с зашифрованным участком отклоняется до сетевого запроса. Полностью открытая семантическая разметка передаётся как обычно с учётом сдвига предыдущих контейнеров.
Результат decoded
decoded индексируется исходным именем поля:
profile.decoded = {
displayName: {
text: 'Расшифрованное имя',
spans: [
{ type: 'crypto', cipher: 'beecrypt', cipherId: 1, offset: 0, length: 18 },
],
},
bio: {
text: 'Расшифрованная подпись',
spans: [
{ type: 'crypto', cipher: 'beecrypt', cipherId: 1, offset: 0, length: 22 },
],
},
};Основные правила:
- исходные строки и серверные
spansне изменяются; decoded[field].textсодержит открытый и расшифрованный текст одной строкой;decoded[field].spansиспользует координаты этой строки;- обычная разметка сохраняет остальные свойства, например
urlилиusername; CryptoSpanсодержит читаемое имя и стабильный идентификатор алгоритма;- соседние участки одного шифра объединяются;
- нераспознанный или повреждённый контейнер остаётся в исходном виде;
- поле без успешной расшифровки не добавляется в
decoded; - если находок нет во всём объекте, свойство
decodedне создаётся.
Страницы, вложенные модели и события
Плагин обходит результат целиком, поэтому не требует отдельной настройки для списка, репоста или вложенного комментария:
const page = await itd.posts.list();
for (const post of page.items) {
console.log(post.decoded?.content?.text);
console.log(post.originalPost?.decoded?.content?.text);
console.log(post.comments?.[0]?.decoded?.content?.text);
console.log(post.author.decoded?.displayName?.text);
}Для нормализованных событий подключите тот же объект отдельно:
const crypto = crypt();
itd.use(crypto);
itd.notifications.events.use(crypto);
itd.notifications.events.on('notification', ({ notification }) => {
console.log(notification.decoded?.preview?.text);
});Обход не изменяет исходный результат. Если расшифровка ничего не нашла, сохраняется тот же объект; иначе копируются только ветви с изменениями. Благодаря позиции плагина снаружи @itd-api/cache одинаково обрабатываются сетевой ответ и результат из кэша, а сохранённый в кэше объект не получает локальное поле decoded.
Автоматическую расшифровку можно выключить глобально и включить для одного вызова:
itd.use(crypt({ decrypt: false }));
const post = await itd.posts.get('post-id', {
extensions: { crypto: { decrypt: true } },
});И наоборот, при глобально включённом режиме отдельный вызов принимает decrypt: false.
Встроенные шифры
| Имя | Идентификатор | Отправка | Фрагменты | Требует невидимый алфавит |
|---|---|---|---|---|
invisible | 0 | да | да | да |
beecrypt | 1 | да | нет | нет |
Идентификатор записывается в транспортный контейнер фрагмента и возвращается в CryptoSpan.cipherId для любого распознанного участка. Он является постоянной частью формата: уже опубликованный идентификатор нельзя назначать другому алгоритму. Для пользовательских алгоритмов используйте идентификаторы начиная с 2.
invisible
Алфавит — шесть невидимых символов U+206A…U+206F, основание системы счисления — 6. Каждый байт UTF-8 записывается четырьмя символами: 6⁴ = 1296, чего достаточно для всех значений байта от 0 до 255.
Целое поле без внутренних границ visual-разметки хранит только закодированную нагрузку. Отдельные фрагменты окружаются общими маркерами и идентификатором шифра:
начало | идентификатор | нагрузка | конецДля invisible один контейнер добавляет девять кодовых единиц UTF-16 сверх нагрузки: четыре на начало, одну на идентификатор 0 и четыре на конец. Поэтому при серверном лимите content в 1000 позиций целое поле вмещает до 250 байт исходного текста, а отдельный фрагмент с контейнером — до 247 байт.
Алгоритм можно использовать напрямую:
import {
decodeInvisible,
encodeInvisible,
stripInvisible,
} from '@itd-api/crypto';
const encoded = encodeInvisible('секрет');
decodeInvisible(encoded); // 'секрет'
stripInvisible(`обычный текст${encoded}`); // 'обычный текст'Эти функции работают только с невидимым алфавитом и не разбирают общий контейнер. Для готовых моделей API используйте плагин или decodeTree().
beecrypt
Текст переводится в UTF-8, затем в base64, а каждая пара битов символа base64 заменяется одной из четырёх букв:
00 → ж
01 → ъ
10 → Ж
11 → Ъimport { decodeBeeCrypt, encodeBeeCrypt } from '@itd-api/crypto';
const encoded = encodeBeeCrypt('A'); // 'ъъжъъъжъжЪЪъжЪЪъ'
decodeBeeCrypt(encoded); // 'A'Шифротекст виден целиком, поэтому beecrypt применяется только ко всему полю. Внутренняя граница визуального span в таком поле является ошибкой: без отдельных контейнеров её невозможно восстановить. Разбор проверяет алфавит, корректность base64 и UTF-8; пробелы и переносы строк внутри шифротекста допускаются.
Пользовательский шифр
Фрагментный алгоритм объявляет стабильный числовой идентификатор и supportsFragments: true:
import { crypt, type Cipher } from '@itd-api/crypto';
const brackets: Cipher = {
name: 'brackets',
id: 5,
supportsFragments: true,
encode: (text) => `[${text}]`,
decode: (encoded) => {
if (!encoded.startsWith('[') || !encoded.endsWith(']')) return null;
return encoded.slice(1, -1);
},
};
itd.use(crypt({
ciphers: [brackets],
}));Переданный ciphers заменяет встроенный реестр. Если нужна совместимость со встроенными форматами, добавьте их явно:
import { BUILT_IN_CIPHERS, crypt } from '@itd-api/crypto';
itd.use(crypt({
ciphers: [...BUILT_IN_CIPHERS, brackets],
}));Контракт алгоритма:
name— уникальное непустое имя;id— уникальный неотрицательный безопасный целочисленный идентификатор;encode(text)возвращает строку и отсутствует у режима только для чтения;decode(text)возвращает исходный текст либоnull;supportsFragments: trueразрешает помещать результат в общий контейнер;requiresInvisibleAlphabet: trueзапрещает поля без подтверждённой сохранности невидимых символов.
Полное поле без внутренних границ visual-разметки отправляется как результат encode() без контейнера. Фрагментный шифр получает только точную нагрузку внутри контейнера. Он не ищет границы в исходном поле и не задаёт собственные маркеры. Результат encode() не должен содержать общие маркеры начала или конца.
Алгоритм, который не поддерживает отдельные участки, оставляет supportsFragments выключенным:
const wholeField: Cipher = {
name: 'whole-field-example',
id: 6,
encode: (text) => transform(text),
decode: (text) => tryRestore(text),
};Порядок реестра влияет на распознавание bare whole-field форматов при чтении. При отправке шифр всегда выбирается явно по имени или числовому идентификатору.
Подключаемые операции
Подключаемый модуль может объявить свои текстовые поля в метаданных операции:
const chats = itd.install({
name: 'chats',
operations: {
send: {
method: 'POST',
retrySafety: RetrySafety.Unsafe,
annotations: {
crypto: {
requestFields: [{
name: 'message',
spansField: 'spans',
maxLength: 2000,
preservesInvisibleAlphabet: true,
}],
},
},
},
},
// setup ...
});Вместо объекта можно передать одно имя строкой. Тогда поле считается сохраняющим невидимый алфавит, но не получает связанного массива разметки и ограничения длины.
Ошибки до запроса
Плагин выбрасывает CryptError до обращения к сети, если преобразование неоднозначно или небезопасно. В частности, проверяются:
- поддержка операции и поля;
- наличие строкового значения;
- существование шифра и корректность его идентификатора;
- возможность записи и поддержка фрагментов;
- сохранность невидимого алфавита;
- целочисленность, непустота и границы диапазонов;
- пересечения и дубликаты;
- пересечение с
link,mentionилиhashtag; - конфликт
encryptсоspans; - пустой
spans; - маркеры контейнера внутри нагрузки фрагмента;
- внутренние границы разметки у шифра целого поля;
- итоговый лимит длины.
Сообщение ошибки содержит operationId, имя поля и проблемный диапазон либо шифр. Сетевые и серверные ошибки продолжают использовать обычные типы ошибок itd-api.
Длина и производительность
Сервер считает длину content в кодовых единицах UTF-16. Подтверждённый предел — 1000; плагин проверяет его после преобразования и до сетевого запроса. Временно тот же предел применяется к displayName и bio, пока их фактические ограничения не измерены.
Одна ASCII-буква занимает в invisible четыре позиции, обычная кириллическая буква — восемь, четырёхбайтовый эмодзи — шестнадцать. Каждое деление зашифрованного диапазона по границе визуальной разметки добавляет ещё один контейнер и его служебные девять позиций.
Расшифровка строит результат с копированием только изменённых ветвей. Ответ без находок возвращается без копирования. Каждый текст и массив разметки преобразуется линейными проходами; шифры внутри контейнеров выбираются сразу по идентификатору, без полного перебора реестра.
Лицензия
MIT