#Формат JWE для полей ПДн
Статус: Доступно на ячейках EN и ID. На ячейке RU формата нет: маршрут
PUT /me/encryption-keyотвечает404, полеpii_encryptionвGET /meотсутствует, а эта страница не показывается.
Назначение: как получать значения полей с персональными данными (ПДн) в виде зашифрованных строк JWE (RFC 7516), которые может прочитать только вы. Ключевую пару вы создаёте у себя; платформе передаётся только открытый ключ.
#Содержание
- Как это устроено
- Какие ключи принимаются
- Создание ключа
- Регистрация открытого ключа
- Режимы
- Заголовок Encryption-Key-Id
- Как выглядит ответ
- Ротация ключа
- Расшифровка: Node.js
- Расшифровка: Python
- Ошибки
- Границы формата
#Как это устроено
- Вы создаёте у себя пару ключей и регистрируете открытый ключ в интеграции. Платформа ключей не создаёт, закрытый ключ не получает и не хранит.
- При чтении платформа заменяет значения ПДн-полей строками компактной JWE, сформированными вашим открытым ключом.
- Расшифровать их может только держатель закрытого ключа, в том числе если ответ проходит через ваши промежуточные серверы, очереди и журналы.
Формат дополняет HTTPS, а не заменяет его, и сочетается с выбором полей: шифруются только те ПДн-поля, которые вы назвали в fields= (Минимизация и выбор полей). Если ПДн-полей в запросе нет, шифровать нечего и ответ приходит как обычно.
Формат действует на ответах чтения диалогов, сообщений и лидов (GET /conversations, GET /conversations/{id}, GET /conversations/{id}/messages, GET /leads, GET /leads/{id}). События вебхуков сегодня не несут ПДн; если событие будет их нести, значения заменяются тем же образом и теми же правилами.
#Какие ключи принимаются
Контент всегда шифруется алгоритмом A256GCM. Алгоритм обёртки ключа выбирает ваш ключ (поле alg в JWK обязательно).
| Ячейка | alg | Кривые / длина | Примечание |
|---|---|---|---|
| EN | ECDH-ES+A256KW | P-256, P-384, X25519 | EC — kty: "EC", X25519 — kty: "OKP" |
| EN | RSA-OAEP-256 | модуль RSA от 2048 бит | |
| ID | ECDH-ES+A256KW | P-384, X25519 | P-256 не принимается |
| ID | RSA-OAEP-256, RSA-OAEP-384 | модуль RSA от 3072 бит | предпочтительнее RSA-OAEP-384 |
Не принимаются: RSA1_5, RSA-OAEP (SHA-1), dir, ECDH-ES без обёртки, короткие RSA, показатель степени RSA меньше 65537 или чётный, симметричные ключи (kty: "oct"), ключи с use, отличным от enc. Действующий список ячейки возвращает GET /me в поле pii_encryption.accepted.
#Создание ключа
Закрытый ключ остаётся у вас: не отправляйте его платформе и не кладите в репозиторий.
Node.js (jose):
import { generateKeyPair, exportJWK } from 'jose';
const { publicKey, privateKey } = await generateKeyPair('ECDH-ES+A256KW', {
crv: 'P-384',
extractable: true,
});
const kid = 'key-2026-10'; // свой идентификатор: буквы, цифры, . _ : -
const pub = { ...(await exportJWK(publicKey)), kid, alg: 'ECDH-ES+A256KW', use: 'enc' };
const prv = { ...(await exportJWK(privateKey)), kid, alg: 'ECDH-ES+A256KW', use: 'enc' };
console.log(JSON.stringify(pub)); // это регистрируем
// prv сохраните в своём хранилище секретов
Python (jwcrypto):
import json
from jwcrypto import jwk
key = jwk.JWK.generate(kty="EC", crv="P-384", kid="key-2026-10", alg="ECDH-ES+A256KW", use="enc")
print(key.export_public()) # это регистрируем
secret = key.export_private() # сохраните в своём хранилище секретов
Без kid платформа использует отпечаток ключа (RFC 7638), но свой понятный kid удобнее при ротации. kid: 1–128 знаков из букв, цифр и . _ : -.
#Регистрация открытого ключа
PUT /api/public/v1/me/encryption-key, область webhooks:manage, только EN и ID.
curl -sS -X PUT "$API_BASE_URL/me/encryption-key" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"jwk": {"kty":"EC","crv":"P-384","x":"…","y":"…","kid":"key-2026-10","alg":"ECDH-ES+A256KW","use":"enc"}}'
Ответ 200:
{
"mode": "optional",
"key": {
"kid": "key-2026-10",
"alg": "ECDH-ES+A256KW",
"kty": "EC",
"crv": "P-384",
"rsa_bits": null,
"thumbprint": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
"created_at": "2026-10-12T10:00:00.000Z"
},
"previous_key": null,
"accepted": { "alg": ["ECDH-ES+A256KW", "RSA-OAEP-256", "RSA-OAEP-384"], "crv": ["P-384", "X25519"], "min_rsa_bits": 3072, "enc": "A256GCM" },
"changed": true
}
- Тот же ключ повторно:
changed: false, ничего не меняется. - Закрытые части JWK (
d,p,q,dp,dq,qi,oth,k) отклоняются сразу: закрытый ключ не должен оказаться у платформы даже по ошибке. - Ключ принимается только после пробного шифрования: испорченная точка кривой или модуль RSA дают отказ при регистрации, а не при первом ответе с данными.
- Состояние видно в
GET /me: полеpii_encryption(mode,key,previous_key,accepted). На RU поля нет. - Ключ можно ввести и в кабинете: Настройки → API → интеграция → блок «Формат JWE для персональных данных» (там же меняется режим и удаляется ключ).
Основные отказы: 422 validation_failed с полем /jwk и кодом причины (private_key_not_allowed, symmetric_key_not_allowed, alg_required, alg_not_allowed, curve_not_allowed, key_too_short, weak_exponent, use_not_enc, kid_invalid, invalid_jwk); 409 invalid_state, если этот kid уже принадлежит другому ключу интеграции (дайте новому ключу новый kid).
#Режимы
| Режим | Что приходит в ПДн-полях |
|---|---|
off | значения как есть, по HTTPS, и только если названы в fields= |
optional | если ключ зарегистрирован, значения приходят строками JWE; если ключа нет, как есть |
required | только строками JWE; без зарегистрированного ключа запрос ПДн-полей получает 409 encryption_key_required, открытых данных нет |
Начальный режим off. Регистрация ключа переводит off в optional. Режим required включается и снимается только в кабинете, через API он не понижается: так интеграция не может случайно вернуться к открытым значениям.
Запрос без ПДн-полей 409 encryption_key_required не вызывает: он вообще не содержит ПДн.
#Заголовок Encryption-Key-Id
Заголовок запроса Encryption-Key-Id: <kid> выбирает, каким из зарегистрированных ключей шифровать этот ответ: действующим или прежним (в окне ротации). Заголовок также включает JWE для запроса даже в режиме off: клиент явно ждёт шифр.
Неизвестный kid, или прежний ключ после окончания окна, даёт 422 validation_failed с полем /headers/Encryption-Key-Id и кодом unknown_key_id.
#Как выглядит ответ
Шифруется значение поля, а не весь объект: форма ответа не меняется, поле остаётся на своём месте, но вместо значения лежит строка JWE. Рядом с объектом появляется _enc — список путей полей этого объекта, которые заменены шифром.
{
"id": "9a7d4e10-0000-4000-8000-000000000002",
"status": "new",
"title": "eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImN0eSI6Impzb24i…",
"contact": {
"id": "3b9e1f22-0000-4000-8000-000000000003",
"email": "eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImN0eSI6Impzb24i…",
"phone": null
},
"_enc": ["contact.email", "title"]
}
Правила:
- Открытый текст шифра: JSON исходного значения в UTF-8 (
"[email protected]", а у вложений массив). После расшифровки применяйтеJSON.parse, и вы получите значение исходного типа. - Защищённый заголовок JWE:
{"alg": …, "enc": "A256GCM", "kid": …, "cty": "json"}. Поkidвыбирайте ключ при ротации. nullи отсутствующее поле не шифруются: раскрывать нечего._encс пустым списком значит, что шифрование действовало, но ПДн в объекте не было. Нет_enc— шифрование к этому ответу не применялось (режимoff, нет ПДн-полей или нет ключа в режимеoptional).- В списках
_encстоит у каждого объекта вdata[].
#Ротация ключа
- Создайте новую пару с новым
kid. - Зарегистрируйте новый открытый ключ тем же
PUT /me/encryption-key. Он становится основным сразу. - Прежний ключ 7 суток остаётся доступным по
Encryption-Key-Id: сервер, который ещё не получил новый закрытый ключ, может дочитать данные. Срок виден вGET /me:pii_encryption.previous_key.expires_at. - После срока удалите прежний закрытый ключ там, где он больше не нужен.
Тот же kid у другого ключа не принимается (409), иначе получатель не отличил бы старый шифр от нового.
#Расшифровка: Node.js
import { compactDecrypt, importJWK } from 'jose';
const privateKey = await importJWK(JSON.parse(process.env.PRIVATE_JWK), 'ECDH-ES+A256KW');
async function decryptField(token) {
const { plaintext } = await compactDecrypt(token, privateKey, {
keyManagementAlgorithms: ['ECDH-ES+A256KW'],
contentEncryptionAlgorithms: ['A256GCM'],
});
return JSON.parse(new TextDecoder().decode(plaintext));
}
// Заменяет в объекте строки JWE исходными значениями по списку _enc.
async function decryptObject(obj) {
const out = structuredClone(obj);
for (const path of obj._enc ?? []) {
const [top, nested] = path.split('.');
if (nested === undefined) out[top] = await decryptField(obj[top]);
else out[top][nested] = await decryptField(obj[top][nested]);
}
delete out._enc;
return out;
}
const lead = await (await fetch(`${API_BASE_URL}/leads/${leadId}?fields=status,contact.email,title`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})).json();
console.log(await decryptObject(lead));
Для ключа RSA замените ECDH-ES+A256KW на свой alg (RSA-OAEP-256 или RSA-OAEP-384).
#Расшифровка: Python
import copy
import json
import os
from jwcrypto import jwe, jwk
private_key = jwk.JWK.from_json(os.environ["PRIVATE_JWK"])
def decrypt_field(token):
t = jwe.JWE(algs=["ECDH-ES+A256KW", "A256GCM"])
t.deserialize(token, key=private_key)
return json.loads(t.payload)
def decrypt_object(obj):
out = copy.deepcopy(obj)
for path in obj.get("_enc", []):
top, _, nested = path.partition(".")
if nested:
out[top][nested] = decrypt_field(obj[top][nested])
else:
out[top] = decrypt_field(obj[top])
out.pop("_enc", None)
return out
Для ключа RSA добавьте в algs свой alg (RSA-OAEP-256 или RSA-OAEP-384).
#Ошибки
| Код | Статус | Когда |
|---|---|---|
encryption_key_required | 409 | режим required, ключ не зарегистрирован, в запросе есть ПДн-поля |
validation_failed (unknown_key_id) | 422 | в Encryption-Key-Id указан незарегистрированный kid или прежний ключ вне окна ротации |
validation_failed (/jwk) | 422 | ключ не принят при регистрации (см. выше) |
invalid_state | 409 | kid уже принадлежит другому ключу интеграции |
not_found | 404 | PUT /me/encryption-key на ячейке RU |
service_unavailable | 503 | не удалось прочитать настройку шифрования: данные открытыми не отдаются |
Каталог кодов: Соглашения.
#Границы формата
- Это одна из мер защиты, а не замена остальных: вы сами отвечаете за хранение закрытого ключа, ротацию и разбор инцидентов на своей стороне.
- Формат не меняет ролей сторон (кто оператор, кто обработчик) и не отменяет правил передачи данных за границу.
- Реализация использует общедоступные стандарты (RFC 7516, 7517, 7518) и не является отдельным криптографическим модулем, прошедшим оценку регулятора.
- Правовые основания по ячейкам — Защита данных по ячейкам.