Files
fgl-aircon/tests/ayla/test_envelope.cpp
Petr Polezhaev e91d4605b1 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.
2026-09-22 15:46:18 +03:00

179 lines
8.3 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тесты конверта {"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));
}
}