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:
@@ -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)
|
||||
|
||||
29
tests/ayla/kdf_vectors.hpp
Normal file
29
tests/ayla/kdf_vectors.hpp
Normal 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
171
tests/ayla/test_crypto.cpp
Normal 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);
|
||||
}
|
||||
178
tests/ayla/test_envelope.cpp
Normal file
178
tests/ayla/test_envelope.cpp
Normal 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
106
tests/ayla/test_httpc.cpp
Normal 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();
|
||||
}
|
||||
@@ -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
156
tests/ayla/test_json.cpp
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user