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:
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user