core(M1): ayla-криптография, конверт, JSON (jsmn), HTTP-клиент

- crypto: KDF Ayla (двойной HMAC, suffix 0x30/31/32; app/dev направления),
  AES-256-CBC с непрерывной цепочкой (iv обновляется mbedtls на месте),
  Java-паддинг >=1 NUL; mode-latch против misuse (encrypt|decrypt);
  zeroize ключей при повторном init; векторы из APK (4 сессии × 4 сообщения,
  включая legacy-приём без NUL) — scripts/gen_kdf_vectors.py.
- envelope: pack/unpack {"enc","sign"}; расшифровка (движение цепочки)
  ДО проверки подписи; сравнение подписи в константном времени;
  extract_seq_no — depth-1 сканер без лимита токенов (OOB после escape
  исправлен, регресс-тесты по ASan-репро ревьюера).
- json: Writer (фикс. буфер, стек глубин, escape, ok()=false при
  переполнении) + Doc на jsmn (64 токена, unescape, overflow-guard).
- httpc: блокирующий POST/PUT для local_reg (статус 200-599, дренаж,
  shutdown перед close).
- third_party/jsmn (MIT, JSMN_STATIC).
- CMake: mbedtls системный (/usr/include/mbedtls3) или FetchContent;
  IDF: PRIV_REQUIRES mbedtls.
- CI: 3 конфигурации — gcc-Release, gcc-Debug+ASan/UBSan, clang-Release;
  6/6 тестов стабильно; ESP-IDF esp32 build complete.
Ревью под-агентом: 3 круга (OOB-блокер + тестовые флаки закрыты), APPROVED.
This commit is contained in:
2026-09-22 15:45:20 +03:00
parent b44004627b
commit e91d4605b1
19 changed files with 2120 additions and 3 deletions

171
tests/ayla/test_crypto.cpp Normal file
View File

@@ -0,0 +1,171 @@
// Тесты криптографии Ayla LAN: KDF по векторам (эталон = APK), CBC-цепочка,
// подписи, оба варианта паддинга.
#include "kdf_vectors.hpp"
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/crypto.hpp"
using fgl::ayla::test::kKdfVectorCount;
using fgl::ayla::test::kKdfVectors;
namespace {
// base64 для сравнения в тестах.
std::string b64(const uint8_t* data, size_t len) {
static const char kTable[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
for (size_t i = 0; i < len; i += 3) {
uint32_t v = data[i] << 16;
if (i + 1 < len) v |= data[i + 1] << 8;
if (i + 2 < len) v |= data[i + 2];
out += kTable[(v >> 18) & 63];
out += kTable[(v >> 12) & 63];
out += (i + 1 < len) ? kTable[(v >> 6) & 63] : '=';
out += (i + 2 < len) ? kTable[v & 63] : '=';
}
return out;
}
} // namespace
TEST_CASE("KDF+envelope: векторы APK (шифрование app-направления, цепочка)") {
for (size_t v = 0; v < kKdfVectorCount; v++) {
CAPTURE(v);
const auto& vec = kKdfVectors[v];
fgl::ayla::SessionCrypto sc;
REQUIRE(sc.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
vec.time_2));
// Отправитель всегда использует Java-паддинг (>=1 NUL) — сверяем 0..2.
// (Вектор 3 — legacy-приём без NUL; проверяется в decrypt-тесте.)
for (int m = 0; m < 3; m++) {
CAPTURE(m);
uint8_t ct[1024];
long ct_len = sc.app.encrypt(ct, sizeof(ct),
reinterpret_cast<const uint8_t*>(vec.plaintexts[m]),
strlen(vec.plaintexts[m]));
REQUIRE(ct_len > 0);
CHECK(b64(ct, static_cast<size_t>(ct_len)) == std::string(vec.enc_base64[m]));
}
// Подписи направлений.
uint8_t mac[32];
sc.app.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[0]),
strlen(vec.plaintexts[0]));
CHECK(b64(mac, sizeof(mac)) == std::string(vec.app_sign_msg0_b64));
sc.dev.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[1]),
strlen(vec.plaintexts[1]));
CHECK(b64(mac, sizeof(mac)) == std::string(vec.dev_sign_msg1_b64));
}
}
TEST_CASE("Расшифровка векторов: независимый приёмник, цепочка, оба паддинга") {
for (size_t v = 0; v < kKdfVectorCount; v++) {
const auto& vec = kKdfVectors[v];
// «Приёмник» — свежая сессия: цепочка стартует с iv_seed.
fgl::ayla::SessionCrypto rx;
REQUIRE(rx.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
vec.time_2));
// base64-декодирование вектора (таблица для теста).
auto unb64 = [](const char* in, uint8_t* out) -> size_t {
auto val = [](char c) -> int {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
};
size_t len = strlen(in), out_len = 0;
uint32_t acc = 0;
int bits = 0;
for (size_t i = 0; i < len; i++) {
if (in[i] == '=') break;
int d = val(in[i]);
if (d < 0) return 0;
acc = (acc << 6) | static_cast<uint32_t>(d);
bits += 6;
if (bits >= 8) {
bits -= 8;
out[out_len++] = static_cast<uint8_t>((acc >> bits) & 0xff);
}
}
return out_len;
};
for (int m = 0; m < 4; m++) {
uint8_t ct[1024];
size_t ct_len = unb64(vec.enc_base64[m], ct);
REQUIRE(ct_len > 0);
REQUIRE(ct_len % 16 == 0);
uint8_t pt[1024] = {};
long pt_len = rx.app.decrypt(pt, sizeof(pt), ct, ct_len);
REQUIRE(pt_len > 0);
// Открытый текст совпадает (NUL-паддинг снят; legacy-вариант — точно).
CHECK(pt_len == static_cast<long>(strlen(vec.plaintexts[m])));
CHECK(memcmp(pt, vec.plaintexts[m], pt_len) == 0);
}
}
}
TEST_CASE("dev-направление: roundtrip цепочки encrypt→decrypt") {
const char* key = "UnitTestKey01234567890==";
const char* msgs[] = {
"{\"seq_no\":0,\"data\":{\"name\":\"a\",\"value\":1}}",
"{\"seq_no\":1,\"data\":{\"name\":\"b\",\"value\":2}}",
"{\"seq_no\":2,\"data\":{}}",
};
fgl::ayla::SessionCrypto tx, rx;
REQUIRE(tx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
REQUIRE(rx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
for (int i = 0; i < 3; i++) {
uint8_t ct[512];
long ct_len = tx.dev.encrypt(ct, sizeof(ct),
reinterpret_cast<const uint8_t*>(msgs[i]),
strlen(msgs[i]));
REQUIRE(ct_len > 0);
uint8_t pt[512];
long pt_len = rx.dev.decrypt(pt, sizeof(pt), ct, static_cast<size_t>(ct_len));
REQUIRE(pt_len == static_cast<long>(strlen(msgs[i])));
CHECK(memcmp(pt, msgs[i], pt_len) == 0);
}
}
TEST_CASE("encrypt/decrypt: граничные случаи") {
fgl::ayla::SessionCrypto sc;
REQUIRE(sc.init("k", "a", "b", 1, 2));
uint8_t out[2048];
// out_cap меньше padded — ошибка.
CHECK(sc.app.encrypt(out, 1, reinterpret_cast<const uint8_t*>("xx"), 2) == -1);
// Некратная длина шифротекста — ошибка расшифровки.
uint8_t junk[17] = {};
CHECK(sc.app.decrypt(out, sizeof(out), junk, 17) == -1);
// Нулевая длина — ошибка.
CHECK(sc.app.decrypt(out, sizeof(out), junk, 0) == -1);
}
TEST_CASE("DirectionCrypto: смешение encrypt/decrypt одного направления запрещено") {
fgl::ayla::SessionCrypto sc;
REQUIRE(sc.init("k", "a", "b", 1, 2));
uint8_t out[64];
// Фиксируем режим шифрованием.
REQUIRE(sc.app.encrypt(out, sizeof(out),
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == 32);
// Расшифровка тем же направлением — отказ (misuse-защита одной цепочки).
uint8_t pt[64];
CHECK(sc.app.decrypt(pt, sizeof(pt), out, 16) == -1);
// Обратный порядок для dev-направления.
fgl::ayla::SessionCrypto sc2;
REQUIRE(sc2.init("k", "a", "b", 1, 2));
uint8_t junk[16] = {};
REQUIRE(sc2.dev.decrypt(pt, sizeof(pt), junk, 16) >= 0);
CHECK(sc2.dev.encrypt(out, sizeof(out),
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == -1);
}