Шифрование полей и всего тела#
Письмо проходит две стадии. Сначала Eazy SDK преобразует subject и body внутри модели, затем
JSON-кодек создаёт байты и второй алгоритм шифрует их целиком. HMAC из предыдущей главы видит уже
шифротекст, поэтому сервер проверяет ровно те байты, которые получил.
Стадия |
Вход |
Результат |
|---|---|---|
Преобразование полей |
|
строки с префиксом |
JSON-кодек |
модель с преобразованными полями |
компактный UTF-8 JSON |
Преобразование байтов |
сериализованный JSON |
байты с префиксом |
Подпись |
окончательные байты |
|
Входящий ответ |
зашифрованные байты |
обычный |
→ Request · POST /api/v1/messages/send
Authorization: Bearer session-1
Content-Type: application/vnd.mail.encrypted+json
X-Mail-Signature: cebf73d9b5258813cd412be5173cf14633cf6cf26812c08ddbfc595cb58ddb1a
mail-body:eyJib2R5IjoibWFpbC1maWVsZDowSnJRdTlHTzBZZlF1Q0RRdjlHQTBMalFzdEMxMExmUmc5R0NJTkN5SU5DLzBZL1JndEM5MExqUmh0R0RMZz09IiwicmVjaXBpZW50IjoiZ3JhY2VAbWFpbC5leGFtcGxlIiwic3ViamVjdCI6Im1haWwtZmllbGQ6MEtIUXRkQzYwWURRdGRHQzBMM1FzTkdQSU5DeTBZSFJndEdBMExYUmg5Q3cifQ==
← Response · 200 OK
Content-Type: application/vnd.mail.encrypted+json
mail-body:eyJzdGF0dXMiOiJvayIsImJvZHkiOnsib3V0Y29tZSI6InNlbnQiLCJtZXNzYWdlX2lkIjoxMDB9fQ==
Алгоритмы принадлежат приложению. Учебные реализации кодируют значение в Base64 с различимым
префиксом; CryptoContext они принимают по тому же протоколу, что и настоящий алгоритм из KMS
или хранилища ключей.
# examples/mail/crypto.py
@dataclass(frozen=True, slots=True)
class DemoFieldCipher:
name: str = "mail-fields-v1"
def encrypt(self, value: FrozenValue, *, context: CryptoContext) -> FrozenValue:
_ = context
if not isinstance(value, str):
raise TypeError("the teaching field cipher accepts strings")
return "mail-field:" + base64.b64encode(value.encode()).decode()
def decrypt(self, value: FrozenValue, *, context: CryptoContext) -> FrozenValue:
_ = context
if not isinstance(value, str) or not value.startswith("mail-field:"):
raise ValueError("unexpected teaching field ciphertext")
return base64.b64decode(value.removeprefix("mail-field:")).decode()
@dataclass(frozen=True, slots=True)
class DemoBodyCipher:
name: str = "mail-body-v1"
def encrypt(self, value: bytes, *, context: CryptoContext) -> bytes:
_ = context
return b"mail-body:" + base64.b64encode(value)
def decrypt(self, value: bytes, *, context: CryptoContext) -> bytes:
_ = context
if not value.startswith(b"mail-body:"):
raise ValueError("unexpected teaching body ciphertext")
return base64.b64decode(value.removeprefix(b"mail-body:"))
FIELD_CIPHER = DemoFieldCipher()
BODY_CIPHER = DemoBodyCipher()
Профиль задаёт порядок стадий и пределы размера. encrypt_field(...) получает тип модели и путь,
который проверяет типизатор. encrypt_encoded(...) работает позже, уже с сериализованными байтами.
Обратное преобразование байтов нужно для зашифрованного ответа.
# examples/mail/http/encryption.py
MAIL_CRYPTO = payload_crypto(
"mail-send-v1",
outbound=encrypt_outbound(
encrypt_field(NewMessage, lambda message: message.subject, using=FIELD_CIPHER),
encrypt_field(NewMessage, lambda message: message.body, using=FIELD_CIPHER),
encoded=encrypt_encoded(
using=BODY_CIPHER,
max_input_bytes=16_000,
max_output_bytes=24_000,
),
),
inbound=decrypt_inbound(
encoded=decrypt_encoded(
using=BODY_CIPHER,
max_input_bytes=24_000,
max_output_bytes=16_000,
)
),
)
MAIL_WIRE = http_encrypted(
content_type=MAIL_ENCRYPTED_CONTENT_TYPE,
clear_content_type="application/json",
plaintext_statuses=frozenset({401, 403}),
)
Операция связывает профиль с контрактом представления. Внешний Content-Type принадлежит зашифрованному
формату, а clear_content_type говорит декодеру, что после расшифровки лежит JSON.
plaintext_statuses оставляет ответы 401 и 403 открытыми: сервер возвращает такие отказы до
расшифровки тела. Подпись остаётся той же, но исполняется после обеих исходящих стадий.
# examples/mail/http/encryption.py
@dataclass(frozen=True, slots=True, kw_only=True)
class SendEncryptedMessage(HttpOperation[SendResult]):
__http__ = Http.post(
"/api/v1/messages/send",
success={200: Json(SendEnvelope)},
errors={200: (Json(RejectedEnvelope), RecipientRejected)},
security=MAIL_BEARER,
signing=MAIL_HMAC,
crypto=MAIL_CRYPTO,
wire=Wire(encrypted=MAIL_WIRE),
)
body: Annotated[NewMessage, JsonBody()]
class EncryptedMailApi(SyncApi):
base_url = "https://mail.example"
send = op(SendEncryptedMessage)
sent = op(FindSent)
После отправки обычный GET ищет письмо по открытой теме. Результат подтверждает, что сайт расшифровал поле перед сохранением, а вызывающий получил обычную модель ответа.
# examples/mail/http/encryption.py
def send_encrypted_message() -> tuple[int, str]:
identity = Identity(
auth=(MAIL_BEARER.static("session-1"),),
key_provider=signing_key,
)
subject = "Секретная встреча"
with mail_client() as client:
api = EncryptedMailApi(client, identity=identity)
sent = api.send(
body=NewMessage(
recipient="grace@mail.example",
subject=subject,
body="Ключи привезут в пятницу.",
)
)
found = api.sent(recipient="grace@mail.example", subject=subject)
return sent.message_id or 0, found.items[0].subject
Файл операции целиком
"""Encrypt selected mail fields and then the serialized HTTP body."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from eazy_sdk import Http, HttpOperation, Identity, SyncApi, op
from eazy_sdk.crypto import (
decrypt_encoded,
decrypt_inbound,
encrypt_encoded,
encrypt_field,
encrypt_outbound,
http_encrypted,
payload_crypto,
)
from eazy_sdk.request import Wire
from eazy_sdk.request.markers import JsonBody
from eazy_sdk.response import Json
from examples.mail.crypto import (
BODY_CIPHER,
FIELD_CIPHER,
MAIL_ENCRYPTED_CONTENT_TYPE,
)
from examples.mail.http._client import mail_client
from examples.mail.http.send import (
MAIL_BEARER,
MAIL_HMAC,
FindSent,
NewMessage,
RecipientRejected,
RejectedEnvelope,
SendEnvelope,
SendResult,
signing_key,
)
# region docs: mail-encryption-profile
# examples/mail/http/encryption.py
MAIL_CRYPTO = payload_crypto(
"mail-send-v1",
outbound=encrypt_outbound(
encrypt_field(NewMessage, lambda message: message.subject, using=FIELD_CIPHER),
encrypt_field(NewMessage, lambda message: message.body, using=FIELD_CIPHER),
encoded=encrypt_encoded(
using=BODY_CIPHER,
max_input_bytes=16_000,
max_output_bytes=24_000,
),
),
inbound=decrypt_inbound(
encoded=decrypt_encoded(
using=BODY_CIPHER,
max_input_bytes=24_000,
max_output_bytes=16_000,
)
),
)
MAIL_WIRE = http_encrypted(
content_type=MAIL_ENCRYPTED_CONTENT_TYPE,
clear_content_type="application/json",
plaintext_statuses=frozenset({401, 403}),
)
# endregion docs: mail-encryption-profile
# region docs: encrypted-mail-operation
# examples/mail/http/encryption.py
@dataclass(frozen=True, slots=True, kw_only=True)
class SendEncryptedMessage(HttpOperation[SendResult]):
__http__ = Http.post(
"/api/v1/messages/send",
success={200: Json(SendEnvelope)},
errors={200: (Json(RejectedEnvelope), RecipientRejected)},
security=MAIL_BEARER,
signing=MAIL_HMAC,
crypto=MAIL_CRYPTO,
wire=Wire(encrypted=MAIL_WIRE),
)
body: Annotated[NewMessage, JsonBody()]
class EncryptedMailApi(SyncApi):
base_url = "https://mail.example"
send = op(SendEncryptedMessage)
sent = op(FindSent)
# endregion docs: encrypted-mail-operation
# region docs: encrypted-mail-flow
# examples/mail/http/encryption.py
def send_encrypted_message() -> tuple[int, str]:
identity = Identity(
auth=(MAIL_BEARER.static("session-1"),),
key_provider=signing_key,
)
subject = "Секретная встреча"
with mail_client() as client:
api = EncryptedMailApi(client, identity=identity)
sent = api.send(
body=NewMessage(
recipient="grace@mail.example",
subject=subject,
body="Ключи привезут в пятницу.",
)
)
found = api.sent(recipient="grace@mail.example", subject=subject)
return sent.message_id or 0, found.items[0].subject
# endregion docs: encrypted-mail-flow
def main() -> None:
message_id, subject = send_encrypted_message()
print(f"encrypted message: {message_id}")
print(f"found clear subject: {subject}")
if __name__ == "__main__":
main()
Файл учебных алгоритмов целиком
"""Reversible teaching ciphers shared by the mail SDK and its local site."""
from __future__ import annotations
import base64
from dataclasses import dataclass
from eazy_sdk.crypto import CryptoContext, FrozenValue
MAIL_ENCRYPTED_CONTENT_TYPE = "application/vnd.mail.encrypted+json"
# region docs: mail-demo-ciphers
# examples/mail/crypto.py
@dataclass(frozen=True, slots=True)
class DemoFieldCipher:
name: str = "mail-fields-v1"
def encrypt(self, value: FrozenValue, *, context: CryptoContext) -> FrozenValue:
_ = context
if not isinstance(value, str):
raise TypeError("the teaching field cipher accepts strings")
return "mail-field:" + base64.b64encode(value.encode()).decode()
def decrypt(self, value: FrozenValue, *, context: CryptoContext) -> FrozenValue:
_ = context
if not isinstance(value, str) or not value.startswith("mail-field:"):
raise ValueError("unexpected teaching field ciphertext")
return base64.b64decode(value.removeprefix("mail-field:")).decode()
@dataclass(frozen=True, slots=True)
class DemoBodyCipher:
name: str = "mail-body-v1"
def encrypt(self, value: bytes, *, context: CryptoContext) -> bytes:
_ = context
return b"mail-body:" + base64.b64encode(value)
def decrypt(self, value: bytes, *, context: CryptoContext) -> bytes:
_ = context
if not value.startswith(b"mail-body:"):
raise ValueError("unexpected teaching body ciphertext")
return base64.b64decode(value.removeprefix(b"mail-body:"))
FIELD_CIPHER = DemoFieldCipher()
BODY_CIPHER = DemoBodyCipher()
# endregion docs: mail-demo-ciphers
__all__ = [
"BODY_CIPHER",
"FIELD_CIPHER",
"MAIL_ENCRYPTED_CONTENT_TYPE",
"DemoBodyCipher",
"DemoFieldCipher",
]
Запуск: uv run python -m examples.mail.http.encryption.
encrypted message: 100
found clear subject: Секретная встреча
JavaScript учебной страницы сам шифрует поля и тело перед fetch и расшифровывает ответ;
браузерная операция только заполняет форму и распознаёт
три исхода композера.
Предупреждение
Base64 не обеспечивает секретность. Эти обратимые алгоритмы показывают места подключения;
в приложении передайте алгоритм с проверенной схемой, управлением ключами и уникальным nonce.