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

View File

@@ -22,3 +22,7 @@ endfunction()
fgl_add_test(ayla_platform ayla/test_platform.cpp)
fgl_add_test(ayla_httpd ayla/test_httpd.cpp)
fgl_add_test(ayla_crypto ayla/test_crypto.cpp)
fgl_add_test(ayla_envelope ayla/test_envelope.cpp)
fgl_add_test(ayla_json ayla/test_json.cpp)
fgl_add_test(ayla_httpc ayla/test_httpc.cpp)

View File

@@ -0,0 +1,29 @@
// СГЕНЕРИРОВАНО scripts/gen_kdf_vectors.py (эталон = APK AylaEncryption).
// Не редактировать руками; регенерация:
// python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp
#pragma once
#include <cstddef>
namespace fgl::ayla::test {
struct KdfVector {
const char* lanip_key;
const char* random_1;
const char* random_2;
long long time_1;
long long time_2;
const char* plaintexts[4];
const char* enc_base64[4]; // 0..2: Java-паддинг; 3: legacy без NUL
const char* app_sign_msg0_b64;
const char* dev_sign_msg1_b64;
};
inline constexpr KdfVector kKdfVectors[] = {
{"nRB152rvLrgpUyysvyS6", "Unajei61hBtnAXvL", "8q8tgzOx60ehhAtF", 274989731965726LL, 847349374141033LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"hk/Iha4XexydQeV7cQ9K4rtNo4hMRPKaCBIINcuU0yo=", "FZnpg9JTsS+56Eh+80V9RlFn7iCVWlGU3xq9Wm8s9GfqLnq49I/V386EqnAMTAT3hZvq46WDGRnymwmlJF+gtOCoTlzvkD/2ozKtXeFMLtidTmqzDddOHP+uPNEXZcuVsfpuN2dLPctqTmM827lrbpwctKvuNTmzd1L6gpdgSQ4e9V4sd+9OA/fI1Eu4E1maJ+Tm4QdpKADrj50oh2qRsOqdjhAaWOjT5lgauLISqno=", "SZa7swUooRnYenDVlVocX8IoLIK9QZm1wybIEVE44g1Q63SDQ4ulpbOvgvTkmTuN4krAGJA6qKt7pm2HYhyIDhMz/fmNgee+FrK/C3Fdy1bFPl8SESR8UL9R7eN2TSxrtK51+uyFvm6DMvft6zmjZnfdyRkjfCN0gI8bGLXN4yU=", "l1DRivEQAazsO+2uWd0qdc00P6qjw5nIK7skXfYn9Bo="}, "IODB6lZl2ClQ/CHa3e0GMbG466jlZ59up4XgVP/00wY=", "sKBzGqhFIPb8DL8KbK0Np2Zag9C80JS8oHLiDXTu2DE="},
{"e3YfUELFmkeD4BWjSSnYxA==", "cxtcwrhtsyAcHm8S", "wfdv8OHvq0h03YnY", 761048435275066LL, 11876266770736LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"AhUcfcYews8/ip360hj3RbKoCt8hwgEA3791gG1IQSM=", "S5PS+KqJ9vIu65Fd+hcnvllI5R34enCE7BT6ukZjdRoVTIEOhIQCzusUcu0vlm2ZLqJwSRBYPFlQKkGh5VDXyJZLQLj+tFZDCf2n4d6wPcFBHsH3VEZy8yl+odPXc6ogAEwtyJhBZ9fURrNgQiveWNG42zTQ13dElqU2RD1bmfilJe0FF84cdre8bHJm7HyWAg5XdQ4qOVzZhH+EWE1XfWeegPDoIsi5ch8QJOiK7KY=", "qbEQTeBvTYYDfFUVwf5oK8slPxW3vUk9jbN6z3sAoQ17s1OiaqHJQrYFr0dDQIvuQgqAcj8+lRtc97papq4Fgpz/QAKCDIZujClJR9KWHBUrTqE8j89QjwjaQZ8ze+hzqIvebwt2C7vM/YivEJz9aSuxi1iLcCcEhnR+VlHoeBw=", "+aAezE8EmyfdRzJnIrsFC3NYxsZRswDkhPNc7qypCZ8="}, "4sW8EmpwLVz/pPN7MZ0Art+uz7uZbZI7lDBuI0Lzjtw=", "h8bqQ1umSr3pTReBno61eTSsK5TCA+2xGAP+uzzx6FE="},
{"deQG9G4zw74OZBzgL31jxCI=", "AlUvHAXene8hTnpq", "rSvwucWBRIQgcG0i", 299350142996752LL, 501892609552265LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"QSdv/i/vwK6JIni4wmMstgf0PiRc/fAPKkszlQrHeV8=", "q5t4RkNU8bu67S15mi27dT28UwWCcVQLh/PgBshhhQVljm950hqGsKPJMSNy5Bul41Ut6bd6sPGdUuFl82FPecIkloDTAx0e0htDjaESEcdUTao4Uxn4yLILG8PKHGog2bHHfGnG7AIk9XFNKnxXCIRtJetgu57gWTlFSxf4UIG+8zBZmb1Zn0R6csUkrFLCAzjfQzx430Gagcw1+lyfgL4EoZgx6SJhWQQ9mM/uyxE=", "eY/Y17nxXaSGSNKjq0XEDYw+Ks55Sze7bZw5yKGnwI6iS8uODSBila54I630XOp/mlAvdVE09StUi7xPAkrCG0gDJ7DsMLFONZR3HDWf7f63iYpdOOQlXRnH8SnQ7pmu4mqRp2JE/NqdWCWT6AMUzXOWDUbxfl3OShLrZf29uDU=", "8RWdrVA+NRbqkT4aXTgm0djTjfyz5rC2fGoh+RfRqow="}, "Z5Ah10Y/qJngN2DUJhhkEytTZsFCOiV2TaeTh61X5pc=", "TcQdYNIoQXEeDZd9ZJWAn9tayz8Zc6/ZR2MtnvQ6/xc="},
{"v8CQFGwiB+/z9UUEGdu7rZ0a", "27XqoPSfZ4553E3K", "rbwC6T3cXdodlhIS", 257649300961690LL, 851173235636538LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"1c7PN6ycazQ1MjiwUmeMQLpp3LuYx3HTuxnS2kFgAw4=", "73cUheq6HOJlHzhuRMf1M5owaQFEw0wXvzsRB1nvGOPpNyLJKilrEVYkyeDH7MzqXRf9thxswnSLOfgK20NHyUozTe41BE/qz7/oVvFquPOPq6bjUWADzugjjOyDAezXyCb0Z+QvgnqruYjQHJJMxe3U2dcypLUXiScDui694bugFVhnIoQTRFyW0LxBff/ZCp3DBrAZUD178MseY9/h/kEx+Rc8qJzLUjvBHuqGqK8=", "7LRiFwjfdrHgksMvV6HwQM/kK8Yeiwgl+QYljJhboya3HzMa1MmGNPxfy0X8nck77ShFeSSpethG1Gk9/jfhFIeIqLoTNSGi/STwfE0S8RKjKx+wIaBSO2xyTbCx8s9g/+Jvn+wORpUdT45wz5sX1c/LjVJkItcjE/gtGPrDICY=", "hyr7N37WMcA/1SOjPceqwnjdCAhn1HWluLPw39EgiL8="}, "fWNCY5DVgVWIKQZoepEUxYE7yaKfxVsR7Bv3zQIkpRo=", "h/9MHTMSW84EFo96BQW2X73cUi0rILDWQoSIqTlEGew="},
};
inline constexpr size_t kKdfVectorCount = sizeof(kKdfVectors) / sizeof(kKdfVectors[0]);
} // namespace fgl::ayla::test

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);
}

View File

@@ -0,0 +1,178 @@
// Тесты конверта {"enc","sign"}: roundtrip, цепочка, порча подписи.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/crypto.hpp"
#include "ayla/envelope.hpp"
TEST_CASE("envelope: roundtrip и CBC-цепочка (3 сообщения)") {
const char* key = "UnitTestKey01234567890==";
fgl::ayla::SessionCrypto tx, rx;
REQUIRE(tx.init(key, "rnd1111rnd1111aa", "rnd2222rnd2222bb", 42, 43));
REQUIRE(rx.init(key, "rnd1111rnd1111aa", "rnd2222rnd2222bb", 42, 43));
const char* payloads[] = {
"{}",
"{\"cmds\":[{\"cmd\":{\"cmd_id\":7,\"method\":\"GET\","
"\"resource\":\"property.json?name=x\",\"data\":\"\","
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}",
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
"\"name\":\"n\",\"value\":9,\"id\":\"zz\"}}]}",
};
char envelopes[3][fgl::ayla::kEnvelopeMaxB64];
for (int i = 0; i < 3; i++) {
REQUIRE(fgl::ayla::envelope_pack(tx.app, i, payloads[i], envelopes[i],
sizeof(envelopes[i])));
}
// Разные шифротексты (цепочка движется).
CHECK(std::string(envelopes[0]) != std::string(envelopes[1]));
// Приёмник с той же цепочкой расшифровывает по порядку.
for (int i = 0; i < 3; i++) {
// Разбор конверта на enc/sign (в тесте — вручную, простым поиском).
std::string env(envelopes[i]);
size_t e1 = env.find("\"enc\":\"") + 7;
size_t e2 = env.find('"', e1);
size_t s1 = env.find("\"sign\":\"") + 8;
size_t s2 = env.find('"', s1);
REQUIRE(e2 != std::string::npos);
REQUIRE(s2 != std::string::npos);
std::string enc = env.substr(e1, e2 - e1);
std::string sign = env.substr(s1, s2 - s1);
char json[fgl::ayla::kEnvelopeMaxPlain];
int64_t seq = -1;
REQUIRE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(), json,
sizeof(json), &seq));
CHECK(seq == i);
std::string expected = "{\"seq_no\":" + std::to_string(i) + ",\"data\":" +
payloads[i] + "}";
CHECK(std::string(json) == expected);
}
}
TEST_CASE("envelope: порча подписи отклоняется, цепочка продолжает движение") {
const char* key = "UnitTestKey01234567890==";
fgl::ayla::SessionCrypto tx, rx;
REQUIRE(tx.init(key, "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", 1, 2));
REQUIRE(rx.init(key, "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", 1, 2));
char e0[fgl::ayla::kEnvelopeMaxB64], e1[fgl::ayla::kEnvelopeMaxB64];
REQUIRE(fgl::ayla::envelope_pack(tx.app, 0, "{}", e0, sizeof(e0)));
REQUIRE(fgl::ayla::envelope_pack(tx.app, 1, "{\"a\":1}", e1, sizeof(e1)));
auto split = [](const char* env, std::string* enc, std::string* sign) {
std::string s(env);
size_t e1 = s.find("\"enc\":\"") + 7, e2 = s.find('"', e1);
size_t g1 = s.find("\"sign\":\"") + 8, g2 = s.find('"', g1);
*enc = s.substr(e1, e2 - e1);
*sign = s.substr(g1, g2 - g1);
};
std::string enc0, sign0, enc1, sign1;
split(e0, &enc0, &sign0);
split(e1, &enc1, &sign1);
// Портим подпись первого сообщения: распаковка обязана провалиться,
// но расшифровка (и движение цепочки) уже произошла.
std::string bad = sign0;
bad[bad.size() - 2] = (bad[bad.size() - 2] == 'A') ? 'B' : 'A';
char json[fgl::ayla::kEnvelopeMaxPlain];
int64_t seq = -1;
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc0.c_str(), bad.c_str(),
json, sizeof(json), &seq));
// Второе сообщение — валидное, цепочка корректна.
REQUIRE(fgl::ayla::envelope_unpack(rx.app, enc1.c_str(), sign1.c_str(), json,
sizeof(json), &seq));
CHECK(seq == 1);
}
TEST_CASE("envelope: некорректный base64 и повреждённый шифротекст") {
const char* key = "UnitTestKey01234567890==";
fgl::ayla::SessionCrypto tx, rx;
REQUIRE(tx.init(key, "cccccccccccccccc", "dddddddddddddddd", 5, 6));
REQUIRE(rx.init(key, "cccccccccccccccc", "dddddddddddddddd", 5, 6));
char env[fgl::ayla::kEnvelopeMaxB64];
REQUIRE(fgl::ayla::envelope_pack(tx.app, 0, "{}", env, sizeof(env)));
std::string s(env);
size_t e1 = s.find("\"enc\":\"") + 7, e2 = s.find('"', e1);
size_t g1 = s.find("\"sign\":\"") + 8, g2 = s.find('"', g1);
std::string enc = s.substr(e1, e2 - e1);
std::string sign = s.substr(g1, g2 - g1);
char json[fgl::ayla::kEnvelopeMaxPlain];
int64_t seq = -1;
// Мусорный base64.
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, "!!!not-base64!!!", sign.c_str(),
json, sizeof(json), &seq));
// Порча шифротекста: подпись не сойдётся (подводного камня нет — просто
// проверяем, что это отклоняется).
std::string broken = enc;
if (broken[10] == 'A') {
broken[10] = 'B';
} else {
broken[10] = 'A';
}
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, broken.c_str(), sign.c_str(),
json, sizeof(json), &seq));
// Оригинал после двух провалов уже не расшифруется (цепочка ушла) —
// таково требование протокола; восстановление только re-key.
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(),
json, sizeof(json), &seq));
}
namespace {
// base64 для ручной сборки конвертов в тестах.
std::string tb64(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("envelope: extract_seq_no устойчив к обрыву после escape (OOB-регрессия)") {
// Аутентифицированный plaintext с незакрытой строкой, заканчивающейся '\'
// (раньше — чтение за границей буфера, поймано ASan в ревью круга 1).
// Конверты собираем вручную: sign + encrypt по битому plaintext.
const char* key = "UnitTestKey01234567890==";
fgl::ayla::SessionCrypto tx, rx;
REQUIRE(tx.init(key, "eeeeeeeeeeeeeeee", "ffffffffffffffff", 7, 8));
REQUIRE(rx.init(key, "eeeeeeeeeeeeeeee", "ffffffffffffffff", 7, 8));
// seq_no отсутствует/после битой части — сканер обязан ПРОПУСКАТЬ
// повреждённое значение (именно на пропуске был OOB).
const char* cases[] = {
"{\"a\":\"x\\", // обрыв в строке-значении верхнего уровня
"{\"a\":{\"b\":\"x\\", // обрыв в строке внутри вложенного объекта
"{\"a\":[1,2,\"x\\", // обрыв в строке внутри массива
};
for (const char* plain : cases) {
CAPTURE(plain);
uint8_t mac[32];
tx.app.sign(mac, reinterpret_cast<const uint8_t*>(plain), strlen(plain));
uint8_t ct[fgl::ayla::kEnvelopeMaxPlain + 16];
long ct_len = tx.app.encrypt(ct, sizeof(ct),
reinterpret_cast<const uint8_t*>(plain),
strlen(plain));
REQUIRE(ct_len > 0);
std::string enc = tb64(ct, static_cast<size_t>(ct_len));
std::string sign = tb64(mac, sizeof(mac));
char json[fgl::ayla::kEnvelopeMaxPlain];
int64_t seq = -1;
// unpack обязан вернуть false (битый JSON) и не выйти за границы.
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(),
json, sizeof(json), &seq));
}
}

106
tests/ayla/test_httpc.cpp Normal file
View File

@@ -0,0 +1,106 @@
// Тесты HTTP-клиента против in-process httpd.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/httpc.hpp"
#include "ayla/httpd.hpp"
#include "ayla/platform/platform.hpp"
namespace {
struct Ctx {
fgl::ayla::HttpRequest last;
int calls = 0;
int status_to_return = 200;
};
bool handler(const fgl::ayla::HttpRequest& req, fgl::ayla::HttpResponse& resp,
void* ctx) {
auto* c = static_cast<Ctx*>(ctx);
c->last = req;
c->calls++;
resp.status = c->status_to_return;
static const uint8_t kBody[] = "{\"x\":1}";
resp.body = kBody;
resp.body_len = sizeof(kBody) - 1;
return true;
}
} // namespace
TEST_CASE("httpc: POST с телом и query, статус пробрасывается") {
fgl::ayla::HttpServer srv;
Ctx ctx;
ctx.status_to_return = 202;
REQUIRE(srv.start(0, handler, &ctx));
const uint32_t ip = (127u << 24) | 1u; // для лога, не используется
fgl::ayla::HttpcRequest req;
req.method = "POST";
req.host = "127.0.0.1";
req.port = srv.port();
req.path = "/local_reg.json";
req.query = "dsn=AC000W00REDACTED";
const char body[] = "{\"local_reg\":{\"notify\":1}}";
req.body = reinterpret_cast<const uint8_t*>(body);
req.body_len = strlen(body);
req.timeout_ms = 3000;
fgl::ayla::HttpcResponse resp;
REQUIRE(fgl::ayla::httpc_perform(req, &resp));
CHECK(resp.transport_ok);
CHECK(resp.status == 202);
CHECK(ctx.calls == 1);
CHECK(std::string(ctx.last.method) == "POST");
CHECK(std::string(ctx.last.target) == "/local_reg.json");
CHECK(std::string(ctx.last.query) == "dsn=AC000W00REDACTED");
CHECK(ctx.last.body_len == strlen(body));
CHECK(memcmp(ctx.last.body, body, strlen(body)) == 0);
srv.stop();
(void)ip;
}
TEST_CASE("httpc: ошибки транспорта (соединение отвергнуто)") {
// Занимаем и освобождаем порт — соединение точно отвергнётся.
fgl::ayla::HttpServer tmp;
REQUIRE(tmp.start(0, nullptr, nullptr));
const uint16_t port = tmp.port();
tmp.stop();
fgl::plat::sleep_ms(100);
fgl::ayla::HttpcRequest req;
req.method = "PUT";
req.host = "127.0.0.1";
req.port = port;
req.timeout_ms = 2000;
fgl::ayla::HttpcResponse resp;
bool ok = fgl::ayla::httpc_perform(req, &resp);
// Либо connect сразу отклонён (false), либо таймаут — оба варианта ошибки.
if (ok) {
FAIL("ожидаля отказ соединения");
}
CHECK_FALSE(resp.transport_ok);
}
TEST_CASE("httpc: PUT без тела, ответ 503 c телом (дренаж)") {
fgl::ayla::HttpServer srv;
Ctx ctx;
ctx.status_to_return = 503;
REQUIRE(srv.start(0, handler, &ctx));
fgl::ayla::HttpcRequest req;
req.method = "PUT";
req.host = "127.0.0.1";
req.port = srv.port();
req.path = "/local_reg.json";
req.body = nullptr;
req.body_len = 0;
req.timeout_ms = 3000;
fgl::ayla::HttpcResponse resp;
REQUIRE(fgl::ayla::httpc_perform(req, &resp));
CHECK(resp.status == 503);
CHECK(ctx.calls == 1);
CHECK(ctx.last.body_len == 0);
srv.stop();
}

View File

@@ -209,7 +209,9 @@ TEST_CASE("httpd: бесконечный стрим заголовков зав
const char* filler = "X-Pad: 0123456789012345678901234567890123456789\r\n";
size_t flen = strlen(filler);
for (int i = 0; i < 80; i++) { // ~4КБ — больше лимита
REQUIRE(fgl::plat::tcp_send(fd, filler, flen) == static_cast<long>(flen));
if (fgl::plat::tcp_send(fd, filler, flen) != static_cast<long>(flen)) {
break; // сервер уже закрыл соединение (лимит превышен) — RST допустим
}
}
// Сервер должен перестать читать и закрыть соединение: recv завершается
// (EOF или RST после close с непрочитанными данными), а не висит вечно.

156
tests/ayla/test_json.cpp Normal file
View File

@@ -0,0 +1,156 @@
// Тесты JSON: writer (фиксированный буфер, вложенность, переполнение)
// и парсер (jsmn) — форматы протокола.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/json.hpp"
using fgl::ayla::json::Doc;
using fgl::ayla::json::Writer;
TEST_CASE("json writer: формы протокола") {
// datapoint-команда (properties)
char buf[512];
Writer w(buf, sizeof(buf));
w.begin_object(); // {
w.key("properties");
w.begin_array();
w.begin_object();
w.key("property");
w.begin_object();
w.key("base_type"); w.string("integer");
w.key("name"); w.string("fan_speed");
w.key("value"); w.integer(3);
w.key("id"); w.string("ab12cd34");
w.end_object();
w.end_object();
w.end_array();
w.end_object();
REQUIRE(w.ok());
CHECK(std::string(buf) ==
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
"\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}");
// GET-команда (cmds)
Writer w2(buf, sizeof(buf));
w2.begin_object();
w2.key("cmds");
w2.begin_array();
w2.begin_object();
w2.key("cmd");
w2.begin_object();
w2.key("cmd_id"); w2.integer(5);
w2.key("method"); w2.string("GET");
w2.key("resource"); w2.string("property.json?name=op");
w2.key("data"); w2.string("");
w2.key("uri"); w2.string("/local_lan/property/datapoint.json");
w2.end_object();
w2.end_object();
w2.end_array();
w2.end_object();
REQUIRE(w2.ok());
CHECK(std::string(buf) ==
"{\"cmds\":[{\"cmd\":{\"cmd_id\":5,\"method\":\"GET\","
"\"resource\":\"property.json?name=op\",\"data\":\"\","
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}");
// key_exchange ответ + массив целых + bool/null
Writer w3(buf, sizeof(buf));
w3.begin_object();
w3.key("random_2"); w3.string("abcd1234");
w3.key("time_2"); w3.integer(-123);
w3.key("flags");
w3.begin_array();
w3.integer(1); w3.integer(2); w3.integer(3);
w3.end_array();
w3.key("ok"); w3.boolean(true);
w3.key("nothing"); w3.literal("null");
w3.end_object();
REQUIRE(w3.ok());
CHECK(std::string(buf) ==
"{\"random_2\":\"abcd1234\",\"time_2\":-123,\"flags\":[1,2,3],"
"\"ok\":true,\"nothing\":null}");
}
TEST_CASE("json writer: экранирование и переполнение") {
char buf[64];
Writer w(buf, sizeof(buf));
w.begin_object();
w.key("a\"b\\c"); // ключ с кавычкой и обратным слешем
w.string("x\ny\"z");
w.end_object();
REQUIRE(w.ok());
CHECK(std::string(buf) == "{\"a\\\"b\\\\c\":\"x\\ny\\\"z\"}");
// Переполнение буфера — ok() = false, не выходим за границы.
Writer w2(buf, sizeof(buf));
w2.begin_object();
w2.key("data");
w2.string("0123456789012345678901234567890123456789012345678901234567890123456789");
w.end_object();
CHECK_FALSE(w2.ok());
CHECK(strlen(buf) < sizeof(buf)); // NUL-терминировано всегда
}
TEST_CASE("json parser: форматы протокола") {
// datapoint push
char buf[128];
strcpy(buf, "{\"name\":\"fan_speed\",\"value\":3,\"metadata\":{}}");
Doc doc;
REQUIRE(doc.parse(buf));
char name[32];
REQUIRE(doc.get_string("name", name, sizeof(name)));
CHECK(std::string(name) == "fan_speed");
int64_t value = -1;
REQUIRE(doc.get_int("value", &value));
CHECK(value == 3);
bool b = true;
CHECK_FALSE(doc.get_bool("value", &b)); // не bool
CHECK_FALSE(doc.get_string("absent", name, sizeof(name)));
// key_exchange
strcpy(buf, "{\"key_exchange\":{\"ver\":1,\"proto\":1,\"key_id\":62999,"
"\"random_1\":\"ab+/12\",\"time_1\":-987654321,\"sec\":\"\"}}");
REQUIRE(doc.parse(buf));
int64_t ver = -1, key_id = -1, time_1 = -1;
REQUIRE(doc.get_int("ver", &ver));
REQUIRE(doc.get_int("key_id", &key_id));
REQUIRE(doc.get_int("time_1", &time_1));
char rnd[32];
REQUIRE(doc.get_string("random_1", rnd, sizeof(rnd)));
CHECK(ver == 1);
CHECK(key_id == 62999);
CHECK(time_1 == -987654321);
CHECK(std::string(rnd) == "ab+/12");
// envelope payload
strcpy(buf, "{\"seq_no\":42,\"data\":{\"name\":\"x\",\"value\":true}}");
REQUIRE(doc.parse(buf));
int64_t seq = -1;
REQUIRE(doc.get_int("seq_no", &seq));
CHECK(seq == 42);
bool v = false;
REQUIRE(doc.get_bool("value", &v));
CHECK(v);
// Мусор
strcpy(buf, "not json");
CHECK_FALSE(doc.parse(buf));
strcpy(buf, "[1,2]");
CHECK_FALSE(doc.parse(buf)); // верхний уровень — объект
}
TEST_CASE("json parser: экранированные строки") {
char buf[96];
strcpy(buf, "{\"name\":\"a\\\"b\\\\c\\nd\"}");
Doc doc;
REQUIRE(doc.parse(buf));
char out[32];
REQUIRE(doc.get_string("name", out, sizeof(out)));
CHECK(std::string(out) == "a\"b\\c\nd");
// Не помещается в out
char small[4];
CHECK_FALSE(doc.get_string("name", small, sizeof(small)));
}