From e91d4605b11a4be93c13630195a6cbac6ee9c195 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 22 Sep 2026 15:45:20 +0300 Subject: [PATCH] =?UTF-8?q?core(M1):=20ayla-=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D1=8F,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D0=B2=D0=B5=D1=80=D1=82,=20JSON=20(jsmn),=20HTTP-=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CMakeLists.txt | 50 +++- scripts/ci.sh | 17 ++ scripts/gen_kdf_vectors.py | 86 +++++++ src/ayla/crypto.cpp | 137 ++++++++++ src/ayla/crypto.hpp | 70 ++++++ src/ayla/envelope.cpp | 180 +++++++++++++ src/ayla/envelope.hpp | 31 +++ src/ayla/httpc.cpp | 128 ++++++++++ src/ayla/httpc.hpp | 32 +++ src/ayla/json.hpp | 253 +++++++++++++++++++ tests/CMakeLists.txt | 4 + tests/ayla/kdf_vectors.hpp | 29 +++ tests/ayla/test_crypto.cpp | 171 +++++++++++++ tests/ayla/test_envelope.cpp | 178 +++++++++++++ tests/ayla/test_httpc.cpp | 106 ++++++++ tests/ayla/test_httpd.cpp | 4 +- tests/ayla/test_json.cpp | 156 ++++++++++++ third_party/jsmn/LICENSE | 20 ++ third_party/jsmn/jsmn.h | 471 +++++++++++++++++++++++++++++++++++ 19 files changed, 2120 insertions(+), 3 deletions(-) create mode 100644 scripts/gen_kdf_vectors.py create mode 100644 src/ayla/crypto.cpp create mode 100644 src/ayla/crypto.hpp create mode 100644 src/ayla/envelope.cpp create mode 100644 src/ayla/envelope.hpp create mode 100644 src/ayla/httpc.cpp create mode 100644 src/ayla/httpc.hpp create mode 100644 src/ayla/json.hpp create mode 100644 tests/ayla/kdf_vectors.hpp create mode 100644 tests/ayla/test_crypto.cpp create mode 100644 tests/ayla/test_envelope.cpp create mode 100644 tests/ayla/test_httpc.cpp create mode 100644 tests/ayla/test_json.cpp create mode 100644 third_party/jsmn/LICENSE create mode 100644 third_party/jsmn/jsmn.h diff --git a/CMakeLists.txt b/CMakeLists.txt index fc4a7ed..5f7166a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,18 +9,23 @@ if(ESP_PLATFORM) # --------------------------------------------------------------------------- idf_component_register( SRCS + "src/ayla/crypto.cpp" + "src/ayla/envelope.cpp" + "src/ayla/httpc.cpp" "src/ayla/httpd.cpp" "src/ayla/log.cpp" "src/ayla/platform/esp-idf/platform.cpp" INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS + "." "src" PRIV_REQUIRES lwip esp_timer esp_hw_support pthread + mbedtls ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror @@ -34,16 +39,57 @@ else() project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX) option(FGL_BUILD_TESTS "Build tests" ON) + option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF) + + # ---- mbedtls ---- + if(NOT FGL_BUNDLED_MBEDTLS) + find_path(MBEDTLS_INCLUDE_DIR mbedtls/aes.h + HINTS /usr/include/mbedtls3 /usr/include + ) + find_library(MBEDTLS_CRYPTO_LIB + NAMES mbedcrypto-3 mbedcrypto + ) + if(NOT MBEDTLS_INCLUDE_DIR OR NOT MBEDTLS_CRYPTO_LIB) + message(STATUS "System Mbed TLS not found - falling back to bundled") + set(FGL_BUNDLED_MBEDTLS ON) + endif() + endif() + if(FGL_BUNDLED_MBEDTLS) + include(FetchContent) + FetchContent_Declare(mbedtls + GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git + GIT_TAG v3.6.2 + GIT_SHALLOW TRUE + ) + set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE) + set(ENABLE_TESTING OFF CACHE BOOL "" FORCE) + set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE) + set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(mbedtls) + set(FGL_MBEDTLS_TARGET mbedcrypto STATIC) + set(FGL_MBEDTLS_INCLUDE_DIR "") + else() + set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}") + set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}") + endif() add_library(fgl-aircon STATIC + src/ayla/crypto.cpp + src/ayla/envelope.cpp + src/ayla/httpc.cpp src/ayla/httpd.cpp src/ayla/log.cpp src/ayla/platform/posix/platform.cpp ) target_include_directories(fgl-aircon - PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" - PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}" + "${FGL_MBEDTLS_INCLUDE_DIR}" + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" ) + target_link_libraries(fgl-aircon PUBLIC ${FGL_MBEDTLS_TARGET}) target_compile_features(fgl-aircon PUBLIC cxx_std_20) target_compile_options(fgl-aircon PRIVATE -Wall -Wextra -Werror diff --git a/scripts/ci.sh b/scripts/ci.sh index 8cb33b6..7f8050f 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -10,4 +10,21 @@ cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build "$BUILD_DIR" ctest --test-dir "$BUILD_DIR" --output-on-failure + +# Санитайзеры (gcc): ловим OOB/UB в ядре и тестах. +SAN_DIR="${SAN_DIR:-build-san}" +cmake -S . -B "$SAN_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" +cmake --build "$SAN_DIR" +ctest --test-dir "$SAN_DIR" --output-on-failure + +# clang (если доступен): отдельный компилятор = отдельные диагностики. +if command -v clang++ >/dev/null 2>&1; then + CLANG_DIR="${CLANG_DIR:-build-clang}" + cmake -S . -B "$CLANG_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_COMPILER=clang++ + cmake --build "$CLANG_DIR" + ctest --test-dir "$CLANG_DIR" --output-on-failure +else + echo "clang++ не найден — сборка пропущена" +fi echo "CI OK" diff --git a/scripts/gen_kdf_vectors.py b/scripts/gen_kdf_vectors.py new file mode 100644 index 0000000..c944134 --- /dev/null +++ b/scripts/gen_kdf_vectors.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Регенерация tests/ayla/kdf_vectors.hpp. Эталон — APK AylaEncryption +# (совпадает с tools/probe_reference.py). Зависимость: pycryptodome. +# Запуск: python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp +import base64, hmac, json, random, string +from Crypto.Cipher import AES + +random.seed(20260922) + +def rand_token(n=16, alpha=string.ascii_letters + string.digits): + return "".join(random.choice(alpha) for _ in range(n)) + +def cstr(s): + return json.dumps(s) + +vectors = [] +for i in range(4): + lanip_key = base64.b64encode(random.randbytes(15 + i)).decode() + rnd1 = rand_token(16, string.ascii_letters + string.digits + "+/") + rnd2 = rand_token(16) + t1 = random.randrange(10**12, 10**15) + t2 = random.randrange(10**12, 10**15) + k = lanip_key.encode(); b1, b2 = rnd1.encode(), rnd2.encode() + s1, s2 = str(t1).encode(), str(t2).encode() + + def m(msg, suf): + msg = msg + bytes([suf]) + return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256") + + A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1 + app_sign, dev_sign = m(A, 0x30), m(D, 0x30) + e = AES.new(m(A, 0x31), AES.MODE_CBC, m(A, 0x32)[:16]) + msgs = [ + '{"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"}}]}}', + ] + encs = [] + for mtext in msgs: + raw = mtext.encode() + n = ((len(raw) + 1 + 15) // 16) * 16 # Java-паддинг (>=1 NUL) + encs.append(base64.b64encode(e.encrypt(raw.ljust(n, b"\x00"))).decode()) + legacy_msg = '{"seq_no":3,"data":{"pad":"XX"}}' # 32 байта, без NUL + assert len(legacy_msg) % 16 == 0 and legacy_msg[-1] != "\x00" + encs.append(base64.b64encode(e.encrypt(legacy_msg.encode())).decode()) + msgs.append(legacy_msg) + + vectors.append(dict( + lanip_key=lanip_key, random_1=rnd1, random_2=rnd2, + time_1=t1, time_2=t2, plaintexts=msgs, enc_base64=encs, + app_sign_msg0=base64.b64encode(hmac.digest(app_sign, msgs[0].encode(), "sha256")).decode(), + dev_sign_msg1=base64.b64encode(hmac.digest(dev_sign, msgs[1].encode(), "sha256")).decode())) + +lines = [] +lines.append("// СГЕНЕРИРОВАНО scripts/gen_kdf_vectors.py (эталон = APK AylaEncryption).") +lines.append("// Не редактировать руками; регенерация:") +lines.append("// python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp") +lines.append("#pragma once") +lines.append("#include ") +lines.append("") +lines.append("namespace fgl::ayla::test {") +lines.append("") +lines.append("struct KdfVector {") +lines.append(" const char* lanip_key;") +lines.append(" const char* random_1;") +lines.append(" const char* random_2;") +lines.append(" long long time_1;") +lines.append(" long long time_2;") +lines.append(" const char* plaintexts[4];") +lines.append(" const char* enc_base64[4]; // 0..2: Java-паддинг; 3: legacy без NUL") +lines.append(" const char* app_sign_msg0_b64;") +lines.append(" const char* dev_sign_msg1_b64;") +lines.append("};") +lines.append("") +lines.append("inline constexpr KdfVector kKdfVectors[] = {") +for v in vectors: + pt = ", ".join(cstr(m) for m in v["plaintexts"]) + en = ", ".join(cstr(e) for e in v["enc_base64"]) + lines.append(f" {{{cstr(v['lanip_key'])}, {cstr(v['random_1'])}, {cstr(v['random_2'])}, " + f"{v['time_1']}LL, {v['time_2']}LL, {{{pt}}}, {{{en}}}, " + f"{cstr(v['app_sign_msg0'])}, {cstr(v['dev_sign_msg1'])}}},") +lines.append("};") +lines.append("inline constexpr size_t kKdfVectorCount = sizeof(kKdfVectors) / sizeof(kKdfVectors[0]);") +lines.append("") +lines.append("} // namespace fgl::ayla::test") +print("\n".join(lines)) diff --git a/src/ayla/crypto.cpp b/src/ayla/crypto.cpp new file mode 100644 index 0000000..789075f --- /dev/null +++ b/src/ayla/crypto.cpp @@ -0,0 +1,137 @@ +#include "ayla/crypto.hpp" + +#include +#include + +#include "mbedtls/platform_util.h" + +namespace fgl::ayla { + +namespace { + +void hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data, + size_t data_len, uint8_t out[kSha256Len]) { + const mbedtls_md_info_t* md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_hmac(md, key, key_len, data, data_len, out); +} + +} // namespace + +bool DirectionCrypto::derive(const uint8_t* key, size_t key_len, + const uint8_t* msg, size_t msg_len, + uint8_t out[kSha256Len]) { + // key = HMAC_k( HMAC_k(msg) || msg ) + if (msg_len > sizeof(uint8_t) * (kSha256Len + 192) - kSha256Len) { + return false; // невозможно по протоколу (<=73 байт) + } + uint8_t inner[kSha256Len]; + hmac_sha256(key, key_len, msg, msg_len, inner); + uint8_t buf[kSha256Len + 192]; + memcpy(buf, inner, kSha256Len); + memcpy(buf + kSha256Len, msg, msg_len); + hmac_sha256(key, key_len, buf, kSha256Len + msg_len, out); + return true; +} + +bool DirectionCrypto::init(const char* lanip_key, const char* rnd_a, + const char* rnd_b, int64_t t_a, int64_t t_b) { + if (lanip_key == nullptr || rnd_a == nullptr || rnd_b == nullptr) return false; + initialized_ = false; + mode_ = 0; + // затираем предыдущий материал + mbedtls_platform_zeroize(sign_key_, sizeof(sign_key_)); + mbedtls_platform_zeroize(aes_key_, sizeof(aes_key_)); + mbedtls_platform_zeroize(chain_iv_, sizeof(chain_iv_)); + + uint8_t msg[128]; + size_t off = 0; + auto append = [&](const char* s) { + size_t n = strlen(s); + if (off + n > sizeof(msg)) return false; + memcpy(msg + off, s, n); + off += n; + return true; + }; + char t_a_buf[24], t_b_buf[24]; + snprintf(t_a_buf, sizeof(t_a_buf), "%lld", static_cast(t_a)); + snprintf(t_b_buf, sizeof(t_b_buf), "%lld", static_cast(t_b)); + if (!append(rnd_a) || !append(rnd_b) || !append(t_a_buf) || !append(t_b_buf) || + off + 1 > sizeof(msg)) { + return false; + } + + const uint8_t* k = reinterpret_cast(lanip_key); + size_t klen = strlen(lanip_key); + + uint8_t block[kSha256Len]; + msg[off] = 0x30; // sign key + if (!derive(k, klen, msg, off + 1, block)) return false; + memcpy(sign_key_, block, kHmacKeyLen); + + msg[off] = 0x31; // aes key + if (!derive(k, klen, msg, off + 1, block)) return false; + memcpy(aes_key_, block, kAesKeyLen); + + msg[off] = 0x32; // iv seed (первые 16 байт) + if (!derive(k, klen, msg, off + 1, block)) return false; + memcpy(chain_iv_, block, kAesBlockLen); + + mbedtls_aes_init(&aes_enc_); + mbedtls_aes_init(&aes_dec_); + if (mbedtls_aes_setkey_enc(&aes_enc_, aes_key_, 256) != 0) return false; + if (mbedtls_aes_setkey_dec(&aes_dec_, aes_key_, 256) != 0) return false; + + initialized_ = true; + return true; +} + +long DirectionCrypto::encrypt(uint8_t* out, size_t out_cap, const uint8_t* in, + size_t in_len) { + if (!initialized_) return -1; + if (mode_ == 0) mode_ = 1; + if (mode_ != 1) return -1; // направление уже в режиме расшифровки + // Java-паддинг: >=1 NUL, кратно 16. + size_t padded = ((in_len + 1 + kAesBlockLen - 1) / kAesBlockLen) * kAesBlockLen; + if (padded > out_cap) return -1; + uint8_t buf[kAesBlockLen * 128]; // до 2048 байт (kHttpdMaxBody) + if (padded > sizeof(buf)) return -1; + memcpy(buf, in, in_len); + memset(buf + in_len, 0, padded - in_len); + // mbedtls_aes_crypt_cbc использует и обновляет chain_iv_ на месте — + // это и есть состояние цепочки. + if (mbedtls_aes_crypt_cbc(&aes_enc_, MBEDTLS_AES_ENCRYPT, padded, chain_iv_, + buf, out) != 0) { + return -1; + } + return static_cast(padded); +} + +long DirectionCrypto::decrypt(uint8_t* out, size_t out_cap, const uint8_t* in, + size_t in_len) { + if (!initialized_) return -1; + if (mode_ == 0) mode_ = 2; + if (mode_ != 2) return -1; // направление уже в режиме шифрования + if (in_len == 0 || in_len % kAesBlockLen != 0 || in_len > out_cap) return -1; + if (mbedtls_aes_crypt_cbc(&aes_dec_, MBEDTLS_AES_DECRYPT, in_len, chain_iv_, + in, out) != 0) { + return -1; + } + // Снять завершающие NUL (по крайней мере один в Java-паддинге, но + // принимаем и строго-кратные длины без NUL). + size_t len = in_len; + while (len > 0 && out[len - 1] == 0) len--; + return static_cast(len); +} + +void DirectionCrypto::sign(uint8_t out[kSha256Len], const uint8_t* data, + size_t len) const { + hmac_sha256(sign_key_, kHmacKeyLen, data, len, out); +} + +bool SessionCrypto::init(const char* lanip_key, const char* random_1, + const char* random_2, int64_t time_1, int64_t time_2) { + return app.init(lanip_key, random_1, random_2, time_1, time_2) && + dev.init(lanip_key, random_2, random_1, time_2, time_1); +} + +} // namespace fgl::ayla diff --git a/src/ayla/crypto.hpp b/src/ayla/crypto.hpp new file mode 100644 index 0000000..04cd2de --- /dev/null +++ b/src/ayla/crypto.hpp @@ -0,0 +1,70 @@ +// Сессионная криптография Ayla LAN (docs/PROTOCOL.md §3). +// KDF: key = HMAC-SHA256_k(HMAC-SHA256_k(msg) || msg), msg = rnd_a|rnd_b|t_a|t_b|X, +// X: 0x30 sign / 0x31 crypto / 0x32 iv(первые 16 байт). +// Направления: app (rnd1,rnd2,t1,t2) — исходящие; dev (rnd2,rnd1,t2,t1) — входящие. +// ВАЖНО: AES-CBC-цепочка непрерывна в рамках сессии (каждое сообщение +// продолжает цепочку с последнего шифроблока предыдущего). +#pragma once + +#include +#include + +#include "mbedtls/aes.h" +#include "mbedtls/md.h" + +namespace fgl::ayla { + +constexpr size_t kSha256Len = 32; +constexpr size_t kAesKeyLen = 32; // AES-256 +constexpr size_t kAesBlockLen = 16; +constexpr size_t kHmacKeyLen = 32; + +class DirectionCrypto { + public: + // Выводит ключи направления. rnd_a/rnd_b — 16-символьные токены, + // t_a/t_b — 64-битные «времена» (материал KDF, значение произвольно). + // app: (random_1, random_2, time_1, time_2); dev: (random_2, random_1, time_2, time_1). + bool init(const char* lanip_key, const char* rnd_a, const char* rnd_b, + int64_t t_a, int64_t t_b); + + // Шифрует in (любая длина) с zero-паддингом (>=1 NUL, кратно 16). + // out_cap должен быть >= padded_len = ((in_len+1)/16+1)*16. + // Возвращает длину шифротекста или -1. Продолжает CBC-цепочку. + // ВАЖНО: у направления ОДНА цепочка; первый вызов фиксирует режим + // (шифрование либо расшифровка) — смешение режимов возвращает -1. + long encrypt(uint8_t* out, size_t out_cap, const uint8_t* in, size_t in_len); + + // Расшифровывает in (длина кратна 16), снимает завершающие NUL. + // Возвращает длину открытого текста или -1. Продолжает CBC-цепочку + // (даже если далее подпись не сойдётся — состояние обязано двигаться). + long decrypt(uint8_t* out, size_t out_cap, const uint8_t* in, size_t in_len); + + // HMAC-SHA256(sign_key, data) — подпись/проверка сообщений направления. + void sign(uint8_t out[kSha256Len], const uint8_t* data, size_t len) const; + + bool initialized() const { return initialized_; } + + private: + static bool derive(const uint8_t* key, size_t key_len, const uint8_t* msg, + size_t msg_len, uint8_t out[kSha256Len]); + + bool initialized_ = false; + uint8_t mode_ = 0; // 0 не зафиксирован, 1 шифрование, 2 расшифровка + uint8_t sign_key_[kHmacKeyLen]; + uint8_t aes_key_[kAesKeyLen]; + uint8_t chain_iv_[kAesBlockLen]; // текущее состояние CBC-цепочки + mbedtls_aes_context aes_enc_; + mbedtls_aes_context aes_dec_; +}; + +// Пара направлений одной сессии. +struct SessionCrypto { + DirectionCrypto app; // приложение -> модуль + DirectionCrypto dev; // модуль -> приложение + + // lanip_key — строка base64 КАК ЕСТЬ (utf-8 байты, не декодировать). + bool init(const char* lanip_key, const char* random_1, const char* random_2, + int64_t time_1, int64_t time_2); +}; + +} // namespace fgl::ayla diff --git a/src/ayla/envelope.cpp b/src/ayla/envelope.cpp new file mode 100644 index 0000000..659b954 --- /dev/null +++ b/src/ayla/envelope.cpp @@ -0,0 +1,180 @@ +#include "ayla/envelope.hpp" + +#include +#include +#include + +#include "mbedtls/base64.h" + + +namespace fgl::ayla { + +namespace { + +bool b64_encode(const uint8_t* in, size_t in_len, char* out, size_t out_cap) { + size_t olen = 0; + if (mbedtls_base64_encode(reinterpret_cast(out), out_cap, + &olen, in, in_len) != 0) { + return false; + } + return true; // mbedtls пишет NUL-терминатор +} + +bool b64_decode(const char* in, uint8_t* out, size_t out_cap, size_t* out_len) { + size_t olen = 0; + if (mbedtls_base64_decode(out, out_cap, &olen, + reinterpret_cast(in), + strlen(in)) != 0) { + return false; + } + *out_len = olen; + return true; +} + +// Извлекает seq_no верхнего уровня из '{"seq_no":N,"data":...}'. +// Ручной depth-1 сканер (без ограничений на размер data; вход уже +// проверен подписью). Понимает пробелы между лексемами. +bool extract_seq_no(const char* json, int64_t* seq_no) { + const char* p = json; + while (*p == ' ' || *p == '\t') p++; + if (*p != '{') return false; + p++; + for (;;) { + while (*p == ' ' || *p == ',' || *p == '\t') p++; + if (*p == '}' || *p == '\0') return false; + // ключ + if (*p != '"') return false; + const char* k = ++p; + while (*p != '"' && *p != '\0') p++; + if (*p != '"') return false; + size_t klen = static_cast(p - k); + p++; + while (*p == ' ' || *p == '\t') p++; + if (*p != ':') return false; + p++; + while (*p == ' ' || *p == '\t') p++; + if (klen == 6 && strncmp(k, "seq_no", 6) == 0) { + if (*p == '-' || (*p >= '0' && *p <= '9')) { + *seq_no = strtoll(p, nullptr, 10); + return true; + } + return false; + } + // пропустить значение другого ключа + if (*p == '{' || *p == '[') { + int depth = 0; + bool in_str = false; + for (; *p != '\0'; p++) { + if (in_str) { + if (*p == '\\') { + p++; + if (*p == '\0') return false; // обрыв после escape + } else if (*p == '"') { + in_str = false; + } + continue; + } + if (*p == '"') { + in_str = true; + } else if (*p == '{' || *p == '[') { + depth++; + } else if (*p == '}' || *p == ']') { + depth--; + if (depth == 0) { + p++; + break; + } + } + } + if (*p == '\0') return false; + } else if (*p == '"') { + p++; + while (*p != '"' && *p != '\0') { + if (*p == '\\') { + p++; + if (*p == '\0') return false; // обрыв после escape + } + p++; + } + if (*p != '"') return false; + p++; + } else { + while (*p != ',' && *p != '}' && *p != '\0') p++; + if (*p == '\0') return false; + } + } +} + +// Сравнение за константное время (аналог mbedtls_ct_memcmp, без зависимости +// от версии библиотеки). +int ct_memcmp(const uint8_t* a, const uint8_t* b, size_t n) { + uint8_t diff = 0; + for (size_t i = 0; i < n; i++) diff |= static_cast(a[i] ^ b[i]); + return diff != 0 ? 1 : 0; +} + +} // namespace + +bool envelope_pack(DirectionCrypto& dir, int64_t seq_no, + const char* data_json, char* out, size_t out_cap) { + if (data_json == nullptr || out == nullptr) return false; + + // Открытый текст: {"seq_no":N,"data":} + char plain[kEnvelopeMaxPlain]; + int n = snprintf(plain, sizeof(plain), "{\"seq_no\":%lld,\"data\":%s}", + static_cast(seq_no), data_json); + if (n <= 0 || static_cast(n) >= sizeof(plain)) return false; + size_t plain_len = static_cast(n); + + // Подпись — по байтам открытого текста без паддинга. + uint8_t mac[kSha256Len]; + dir.sign(mac, reinterpret_cast(plain), plain_len); + char sign_b64[64]; + if (!b64_encode(mac, sizeof(mac), sign_b64, sizeof(sign_b64))) return false; + + // Шифрование с zero-паддингом (>=1 NUL, кратно 16). + uint8_t ct[kEnvelopeMaxPlain + kAesBlockLen]; + long ct_len = dir.encrypt(ct, sizeof(ct), + reinterpret_cast(plain), plain_len); + if (ct_len < 0) return false; + char enc_b64[kEnvelopeMaxB64]; + if (!b64_encode(ct, static_cast(ct_len), enc_b64, sizeof(enc_b64))) { + return false; + } + + int m = snprintf(out, out_cap, "{\"enc\":\"%s\",\"sign\":\"%s\"}", enc_b64, + sign_b64); + return m > 0 && static_cast(m) < out_cap; +} + +bool envelope_unpack(DirectionCrypto& dir, const char* enc_b64, + const char* sign_b64, char* json_out, size_t out_cap, + int64_t* seq_no) { + if (enc_b64 == nullptr || sign_b64 == nullptr || json_out == nullptr) { + return false; + } + uint8_t ct[kEnvelopeMaxPlain + kAesBlockLen]; + size_t ct_len = 0; + if (!b64_decode(enc_b64, ct, sizeof(ct), &ct_len)) return false; + + // Расшифровка: CBC-цепочка двигается независимо от исхода проверки подписи. + uint8_t plain[kEnvelopeMaxPlain + kAesBlockLen]; + long plain_len = dir.decrypt(plain, sizeof(plain), ct, ct_len); + if (plain_len < 0 || static_cast(plain_len) >= out_cap) return false; + + // Проверка подписи по расшифрованным байтам (без паддинга); + // сравнение декодированных байт — константное время. + uint8_t mac[kSha256Len]; + dir.sign(mac, plain, static_cast(plain_len)); + uint8_t given[kSha256Len]; + size_t given_len = 0; + if (!b64_decode(sign_b64, given, sizeof(given), &given_len)) return false; + if (given_len != kSha256Len) return false; + if (ct_memcmp(mac, given, kSha256Len) != 0) return false; + + memcpy(json_out, plain, static_cast(plain_len)); + json_out[plain_len] = '\0'; + return extract_seq_no(json_out, seq_no); +} + +} // namespace fgl::ayla diff --git a/src/ayla/envelope.hpp b/src/ayla/envelope.hpp new file mode 100644 index 0000000..320072b --- /dev/null +++ b/src/ayla/envelope.hpp @@ -0,0 +1,31 @@ +// Защищённый конверт Ayla LAN: {"enc":"","sign":""}, +// открытый текст: {"seq_no":N,"data":} (docs/PROTOCOL.md §3.3). +#pragma once + +#include +#include + +#include "ayla/crypto.hpp" + +namespace fgl::ayla { + +constexpr size_t kEnvelopeMaxPlain = 1024; // открытый текст конверта +constexpr size_t kEnvelopeMaxB64 = 1800; // base64 шифротекста с запасом + +// Собирает открытый текст '{"seq_no":,"data":}' и упаковывает +// в конверт (envelope). dir — направление ОТПРАВИТЕЛЯ (app); состояние +// CBC-цепочки направления ДВИГАЕТСЯ. out — строка NUL-terminated. +// data_json — готовый JSON-объект (обычно построен json::Writer). +bool envelope_pack(DirectionCrypto& dir, int64_t seq_no, + const char* data_json, char* out, size_t out_cap); + +// Распаковывает конверт (enc_b64/sign_b64 — NUL-terminated) направлением dev: +// расшифровывает (цепочка движется даже при последующей ошибке подписи!), +// проверяет подпись. При успехе json_out содержит ОТКРЫТЫЙ ТЕКСТ +// ('{"seq_no":..,"data":..}') NUL-terminated, seq_no записан в *seq_no. +// Возвращает false при ошибке base64/расшифровки/подписи. +bool envelope_unpack(DirectionCrypto& dir, const char* enc_b64, + const char* sign_b64, char* json_out, size_t out_cap, + int64_t* seq_no); + +} // namespace fgl::ayla diff --git a/src/ayla/httpc.cpp b/src/ayla/httpc.cpp new file mode 100644 index 0000000..ae94088 --- /dev/null +++ b/src/ayla/httpc.cpp @@ -0,0 +1,128 @@ +#include "ayla/httpc.hpp" + +#include +#include +#include + +#include "ayla/log.hpp" +#include "ayla/platform/platform.hpp" + +namespace fgl::ayla { + +namespace { + +// Читает статус-строку "HTTP/1.x NNN ..."; возвращает код или -1. +int read_status(int fd) { + char line[64]; + size_t len = 0; + for (;;) { + if (len + 1 >= sizeof(line)) return -1; + uint8_t ch; + long n = fgl::plat::tcp_recv(fd, &ch, 1); + if (n <= 0) return -1; + if (ch == '\n') break; + line[len++] = static_cast(ch); + } + // "HTTP/1.1 202 Accepted" + if (len < 12 || strncmp(line, "HTTP/", 5) != 0) return -1; + const char* sp = strchr(line, ' '); + if (sp == nullptr) return -1; + int code = atoi(sp + 1); + return (code >= 200 && code <= 599) ? code : -1; +} + +// Дочитывает заголовки до пустой строки и тело по Content-Length (до лимита). +bool drain_response(int fd) { + char line[256]; + long content_length = 0; + for (;;) { + size_t i = 0; + for (;;) { + if (i + 1 >= sizeof(line)) return false; + uint8_t ch; + long n = fgl::plat::tcp_recv(fd, &ch, 1); + if (n <= 0) return false; + if (ch == '\n') break; + line[i++] = static_cast(ch); + } + if (i > 0 && line[i - 1] == '\r') i--; + line[i] = '\0'; + if (i == 0) break; // конец заголовков + if ((line[0] | 0x20) == 'c' && strncasecmp(line, "Content-Length:", 15) == 0) { + content_length = strtol(line + 15, nullptr, 10); + } + } + if (content_length > 0) { + uint8_t sink[256]; + long remaining = content_length; + if (remaining > 4096) remaining = 4096; // тела local_reg нет — ограничим + while (remaining > 0) { + size_t chunk = remaining < static_cast(sizeof(sink)) + ? static_cast(remaining) + : sizeof(sink); + long n = fgl::plat::tcp_recv(fd, sink, chunk); + if (n <= 0) break; + remaining -= n; + } + } + return true; +} + +} // namespace + +bool httpc_perform(const HttpcRequest& req, HttpcResponse* resp) { + if (resp == nullptr || req.host == nullptr) return false; + resp->status = 0; + resp->transport_ok = false; + + int fd = fgl::plat::tcp_connect(req.host, req.port, req.timeout_ms); + if (fd < 0) { + FGL_LOGD("httpc: connect %s:%u failed", req.host, + static_cast(req.port)); + return false; + } + fgl::plat::tcp_set_timeout(fd, req.timeout_ms, req.timeout_ms); + + char head[256]; + int n = snprintf(head, sizeof(head), + "%s %s%s%s HTTP/1.1\r\n" + "Host: %s\r\n" + "Accept: application/json\r\n" + "Connection: close\r\n" + "Content-Type: %s\r\n" + "Content-Length: %u\r\n" + "\r\n", + req.method, req.path, req.query != nullptr ? "?" : "", + req.query != nullptr ? req.query : "", req.host, + req.content_type, static_cast(req.body_len)); + if (n <= 0 || static_cast(n) >= sizeof(head)) { + fgl::plat::tcp_close(fd); + return false; + } + bool sent = fgl::plat::tcp_send(fd, head, static_cast(n)) == n; + if (sent && req.body != nullptr && req.body_len > 0) { + sent = fgl::plat::tcp_send(fd, req.body, req.body_len) == + static_cast(req.body_len); + } + if (!sent) { + FGL_LOGD("httpc: send failed"); + fgl::plat::tcp_close(fd); + return false; + } + + int status = read_status(fd); + if (status < 0) { + fgl::plat::tcp_close(fd); + return false; + } + drain_response(fd); + fgl::plat::tcp_shutdown(fd); + fgl::plat::tcp_close(fd); + + resp->status = status; + resp->transport_ok = true; + FGL_LOGD("httpc: %s %s -> %d", req.method, req.path, status); + return true; +} + +} // namespace fgl::ayla diff --git a/src/ayla/httpc.hpp b/src/ayla/httpc.hpp new file mode 100644 index 0000000..2d90b60 --- /dev/null +++ b/src/ayla/httpc.hpp @@ -0,0 +1,32 @@ +// Минимальный HTTP-клиент для исходящих запросов к модулю (local_reg). +// Один запрос — одно соединение; достаточно для keep-alive-цикла протокола +// (проверено на приборе: модуль принимает local_reg на новых соединениях). +#pragma once + +#include +#include + +namespace fgl::ayla { + +struct HttpcRequest { + const char* method = "PUT"; // "POST" | "PUT" + const char* host = nullptr; // dotted-quad или DNS-имя + uint16_t port = 80; + const char* path = "/local_reg.json"; + const char* query = nullptr; // без '?', может быть nullptr + const char* content_type = "application/json"; + const uint8_t* body = nullptr; + size_t body_len = 0; + uint32_t timeout_ms = 10000; +}; + +struct HttpcResponse { + int status = 0; // HTTP-код или 0 при ошибке транспорта + bool transport_ok = false; +}; + +// Выполняет запрос блокирующе. Тело ответа не читается (у local_reg оно +// пусто), но соединение закрывается корректно (shutdown перед close). +bool httpc_perform(const HttpcRequest& req, HttpcResponse* resp); + +} // namespace fgl::ayla diff --git a/src/ayla/json.hpp b/src/ayla/json.hpp new file mode 100644 index 0000000..0d51316 --- /dev/null +++ b/src/ayla/json.hpp @@ -0,0 +1,253 @@ +// Минимальный JSON: writer с фиксированным буфером + парсер (jsmn). +// Никаких аллокаций; экранирование строк — достаточное для протокола. +#pragma once + +#include +#include +#include +#include + +#define JSMN_STATIC +#include "third_party/jsmn/jsmn.h" + +namespace fgl::ayla::json { + +// --------------------------------------------------------------------------- +// Writer. Стек глубин <= 8; состояние контейнеров (были ли элементы). +// --------------------------------------------------------------------------- +class Writer { + public: + Writer(char* buf, size_t cap) : buf_(buf), cap_(cap) { + buf_[0] = '\0'; + } + + void begin_object() { value_comma(); put('{'); push('{'); } + void end_object() { pop(); put('}'); } + void begin_array() { value_comma(); put('['); push('['); } + void end_array() { pop(); put(']'); } + + // Ключ объекта; за ним должно следовать значение. + void key(const char* name) { + if (items_[depth_]) put(','); + items_[depth_] = true; + put('"'); + str_escape(name); + put_raw("\":", 2); + await_value_ = true; + } + + void string(const char* v) { + value_comma(); + put('"'); + str_escape(v); + put('"'); + } + void integer(int64_t v) { + value_comma(); + char tmp[24]; + int n = snprintf(tmp, sizeof(tmp), "%lld", static_cast(v)); + put_raw(tmp, static_cast(n)); + } + void boolean(bool v) { value_comma(); put_raw(v ? "true" : "false", v ? 4 : 5); } + void literal(const char* v) { value_comma(); put_raw(v, strlen(v)); } + void raw_json(const char* v) { value_comma(); put_raw(v, strlen(v)); } + + const char* c_str() const { return buf_; } + size_t size() const { return pos_; } + bool ok() const { return ok_; } + + private: + static constexpr int kMaxDepth = 8; + + // Запятая/маркер перед ЗНАЧЕНИЕМ (не ключом, не закрытием). + void value_comma() { + if (await_value_) { + await_value_ = false; + return; // ключ уже поставил ':' — запятая не нужна + } + if (items_[depth_]) put(','); + items_[depth_] = true; + } + void push(char c) { + if (depth_ + 1 >= kMaxDepth) { + ok_ = false; + return; + } + depth_++; + items_[depth_] = false; + (void)c; + } + void pop() { + if (depth_ > 0) depth_--; + await_value_ = false; + } + void put(char c) { + if (!ok_ || pos_ + 1 >= cap_) { + ok_ = false; + return; + } + buf_[pos_++] = c; + buf_[pos_] = '\0'; + } + void put_raw(const char* s, size_t n) { + if (!ok_ || pos_ + n + 1 > cap_) { + ok_ = false; + return; + } + memcpy(buf_ + pos_, s, n); + pos_ += n; + buf_[pos_] = '\0'; + } + void str_escape(const char* s) { + for (const char* p = s; *p != '\0'; p++) { + char c = *p; + if (c == '"' || c == '\\') { + put('\\'); + put(c); + } else if (c == '\n') { + put_raw("\\n", 2); + } else if (c == '\t') { + put_raw("\\t", 2); + } else if (c == '\r') { + put_raw("\\r", 2); + } else if (c == '\b') { + put_raw("\\b", 2); + } else if (c == '\f') { + put_raw("\\f", 2); + } else if (static_cast(c) < 0x20) { + char tmp[8]; + snprintf(tmp, sizeof(tmp), "\\u%04x", c); + put_raw(tmp, 6); + } else { + put(c); + } + } + } + + char* buf_; + size_t cap_; + size_t pos_ = 0; + bool ok_ = true; + int depth_ = 0; + bool items_[kMaxDepth + 1] = {}; + bool await_value_ = false; +}; + +// --------------------------------------------------------------------------- +// Parser (jsmn): токены поверх исходного буфера, без копирований. +// --------------------------------------------------------------------------- +constexpr int kMaxTokens = 64; + +class Doc { + public: + // Разбирает buf (NUL-terminated, будет модифицироваться при unescape). + // После вызова buf должен жить, пока используется Doc. + bool parse(char* buf) { + buf_ = buf; + jsmn_init(&p_); + count_ = jsmn_parse(&p_, buf, strlen(buf), tokens_, kMaxTokens); + return count_ >= 1 && tokens_[0].type == JSMN_OBJECT; + } + + // Значение ключа по имени (первое вхождение в порядке документа — + // глубина не различается; для форматов протокора ключи уникальны). + // Для строки возвращает указатель внутрь buf_ (БЕЗ кавычек, + // НЕ NUL-terminated) и длину. + bool find(const char* name, const char** out_start, size_t* out_len, + jsmntype_t* out_type = nullptr) const { + for (int i = 1; i + 1 < count_; i++) { + if (tokens_[i].type == JSMN_STRING && tokens_[i].size == 1) { + int len = tokens_[i].end - tokens_[i].start; + if (len == static_cast(strlen(name)) && + strncmp(buf_ + tokens_[i].start, name, len) == 0) { + const jsmntok_t& v = tokens_[i + 1]; + *out_start = buf_ + v.start; + *out_len = static_cast(v.end - v.start); + if (out_type != nullptr) *out_type = v.type; + return true; + } + } + } + return false; + } + + // Строка в out (снятие кавычек; поддержка \" \\ \/ \n \t \r \b \f). + bool get_string(const char* name, char* out, size_t out_cap) const { + const char* s; + size_t len; + jsmntype_t type; + if (!find(name, &s, &len, &type) || type != JSMN_STRING) return false; + size_t o = 0; + for (size_t i = 0; i < len; i++) { + if (o + 1 >= out_cap) return false; + char c = s[i]; + if (c == '\\' && i + 1 < len) { + char e = s[++i]; + switch (e) { + case '"': out[o++] = '"'; break; + case '\\': out[o++] = '\\'; break; + case '/': out[o++] = '/'; break; + case 'n': out[o++] = '\n'; break; + case 't': out[o++] = '\t'; break; + case 'r': out[o++] = '\r'; break; + case 'b': out[o++] = '\b'; break; + case 'f': out[o++] = '\f'; break; + default: return false; // \uXXXX не требуется протоколом + } + } else { + out[o++] = c; + } + } + out[o] = '\0'; + return true; + } + + bool get_int(const char* name, int64_t* out) const { + const char* s; + size_t len; + jsmntype_t type; + if (!find(name, &s, &len, &type) || type != JSMN_PRIMITIVE) return false; + int64_t v = 0; + bool neg = false; + size_t i = 0; + if (i < len && (s[i] == '-' || s[i] == '+')) { + neg = s[i] == '-'; + i++; + } + if (i >= len) return false; + for (; i < len; i++) { + if (s[i] < '0' || s[i] > '9') return false; + int64_t digit = s[i] - '0'; + if (v > (INT64_MAX - digit) / 10) return false; // переполнение + v = v * 10 + digit; + } + *out = neg ? -v : v; + return true; + } + + bool get_bool(const char* name, bool* out) const { + const char* s; + size_t len; + jsmntype_t type; + if (!find(name, &s, &len, &type) || type != JSMN_PRIMITIVE) return false; + if (len == 4 && strncmp(s, "true", 4) == 0) { + *out = true; + return true; + } + if (len == 5 && strncmp(s, "false", 5) == 0) { + *out = false; + return true; + } + return false; + } + + int tokens_used() const { return count_; } + + private: + char* buf_ = nullptr; + jsmn_parser p_; + jsmntok_t tokens_[kMaxTokens]; + int count_ = 0; +}; + +} // namespace fgl::ayla::json diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bd33e09..86c44f3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/ayla/kdf_vectors.hpp b/tests/ayla/kdf_vectors.hpp new file mode 100644 index 0000000..7b47324 --- /dev/null +++ b/tests/ayla/kdf_vectors.hpp @@ -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 + +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 diff --git a/tests/ayla/test_crypto.cpp b/tests/ayla/test_crypto.cpp new file mode 100644 index 0000000..b46d8b4 --- /dev/null +++ b/tests/ayla/test_crypto.cpp @@ -0,0 +1,171 @@ +// Тесты криптографии Ayla LAN: KDF по векторам (эталон = APK), CBC-цепочка, +// подписи, оба варианта паддинга. +#include "kdf_vectors.hpp" +#include "doctest/doctest.h" + +#include +#include + +#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(vec.plaintexts[m]), + strlen(vec.plaintexts[m])); + REQUIRE(ct_len > 0); + CHECK(b64(ct, static_cast(ct_len)) == std::string(vec.enc_base64[m])); + } + + // Подписи направлений. + uint8_t mac[32]; + sc.app.sign(mac, reinterpret_cast(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(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(d); + bits += 6; + if (bits >= 8) { + bits -= 8; + out[out_len++] = static_cast((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(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(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(ct_len)); + REQUIRE(pt_len == static_cast(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("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("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("1234567890123456"), 16) == -1); +} diff --git a/tests/ayla/test_envelope.cpp b/tests/ayla/test_envelope.cpp new file mode 100644 index 0000000..db03d37 --- /dev/null +++ b/tests/ayla/test_envelope.cpp @@ -0,0 +1,178 @@ +// Тесты конверта {"enc","sign"}: roundtrip, цепочка, порча подписи. +#include "doctest/doctest.h" + +#include +#include + +#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(plain), strlen(plain)); + uint8_t ct[fgl::ayla::kEnvelopeMaxPlain + 16]; + long ct_len = tx.app.encrypt(ct, sizeof(ct), + reinterpret_cast(plain), + strlen(plain)); + REQUIRE(ct_len > 0); + std::string enc = tb64(ct, static_cast(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)); + } +} diff --git a/tests/ayla/test_httpc.cpp b/tests/ayla/test_httpc.cpp new file mode 100644 index 0000000..457deef --- /dev/null +++ b/tests/ayla/test_httpc.cpp @@ -0,0 +1,106 @@ +// Тесты HTTP-клиента против in-process httpd. +#include "doctest/doctest.h" + +#include +#include + +#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); + 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(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(); +} diff --git a/tests/ayla/test_httpd.cpp b/tests/ayla/test_httpd.cpp index 5d26907..48e0e5f 100644 --- a/tests/ayla/test_httpd.cpp +++ b/tests/ayla/test_httpd.cpp @@ -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(flen)); + if (fgl::plat::tcp_send(fd, filler, flen) != static_cast(flen)) { + break; // сервер уже закрыл соединение (лимит превышен) — RST допустим + } } // Сервер должен перестать читать и закрыть соединение: recv завершается // (EOF или RST после close с непрочитанными данными), а не висит вечно. diff --git a/tests/ayla/test_json.cpp b/tests/ayla/test_json.cpp new file mode 100644 index 0000000..3f6440a --- /dev/null +++ b/tests/ayla/test_json.cpp @@ -0,0 +1,156 @@ +// Тесты JSON: writer (фиксированный буфер, вложенность, переполнение) +// и парсер (jsmn) — форматы протокола. +#include "doctest/doctest.h" + +#include +#include + +#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))); +} diff --git a/third_party/jsmn/LICENSE b/third_party/jsmn/LICENSE new file mode 100644 index 0000000..c84fb2e --- /dev/null +++ b/third_party/jsmn/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2010 Serge A. Zaitsev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/third_party/jsmn/jsmn.h b/third_party/jsmn/jsmn.h new file mode 100644 index 0000000..8ac14c1 --- /dev/null +++ b/third_party/jsmn/jsmn.h @@ -0,0 +1,471 @@ +/* + * MIT License + * + * Copyright (c) 2010 Serge Zaitsev + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +#ifndef JSMN_H +#define JSMN_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef JSMN_STATIC +#define JSMN_API static +#else +#define JSMN_API extern +#endif + +/** + * JSON type identifier. Basic types are: + * o Object + * o Array + * o String + * o Other primitive: number, boolean (true/false) or null + */ +typedef enum { + JSMN_UNDEFINED = 0, + JSMN_OBJECT = 1 << 0, + JSMN_ARRAY = 1 << 1, + JSMN_STRING = 1 << 2, + JSMN_PRIMITIVE = 1 << 3 +} jsmntype_t; + +enum jsmnerr { + /* Not enough tokens were provided */ + JSMN_ERROR_NOMEM = -1, + /* Invalid character inside JSON string */ + JSMN_ERROR_INVAL = -2, + /* The string is not a full JSON packet, more bytes expected */ + JSMN_ERROR_PART = -3 +}; + +/** + * JSON token description. + * type type (object, array, string etc.) + * start start position in JSON data string + * end end position in JSON data string + */ +typedef struct jsmntok { + jsmntype_t type; + int start; + int end; + int size; +#ifdef JSMN_PARENT_LINKS + int parent; +#endif +} jsmntok_t; + +/** + * JSON parser. Contains an array of token blocks available. Also stores + * the string being parsed now and current position in that string. + */ +typedef struct jsmn_parser { + unsigned int pos; /* offset in the JSON string */ + unsigned int toknext; /* next token to allocate */ + int toksuper; /* superior token node, e.g. parent object or array */ +} jsmn_parser; + +/** + * Create JSON parser over an array of tokens + */ +JSMN_API void jsmn_init(jsmn_parser *parser); + +/** + * Run JSON parser. It parses a JSON data string into and array of tokens, each + * describing + * a single JSON object. + */ +JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, + jsmntok_t *tokens, const unsigned int num_tokens); + +#ifndef JSMN_HEADER +/** + * Allocates a fresh unused token from the token pool. + */ +static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *tok; + if (parser->toknext >= num_tokens) { + return NULL; + } + tok = &tokens[parser->toknext++]; + tok->start = tok->end = -1; + tok->size = 0; +#ifdef JSMN_PARENT_LINKS + tok->parent = -1; +#endif + return tok; +} + +/** + * Fills token type and boundaries. + */ +static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type, + const int start, const int end) { + token->type = type; + token->start = start; + token->end = end; + token->size = 0; +} + +/** + * Fills next available token with JSON primitive. + */ +static int jsmn_parse_primitive(jsmn_parser *parser, const char *js, + const size_t len, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *token; + int start; + + start = parser->pos; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + switch (js[parser->pos]) { +#ifndef JSMN_STRICT + /* In strict mode primitive must be followed by "," or "}" or "]" */ + case ':': +#endif + case '\t': + case '\r': + case '\n': + case ' ': + case ',': + case ']': + case '}': + goto found; + default: + /* to quiet a warning from gcc*/ + break; + } + if (js[parser->pos] < 32 || js[parser->pos] >= 127) { + parser->pos = start; + return JSMN_ERROR_INVAL; + } + } +#ifdef JSMN_STRICT + /* In strict mode primitive must be followed by a comma/object/array */ + parser->pos = start; + return JSMN_ERROR_PART; +#endif + +found: + if (tokens == NULL) { + parser->pos--; + return 0; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + parser->pos = start; + return JSMN_ERROR_NOMEM; + } + jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos); +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + parser->pos--; + return 0; +} + +/** + * Fills next token with JSON string. + */ +static int jsmn_parse_string(jsmn_parser *parser, const char *js, + const size_t len, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *token; + + int start = parser->pos; + + /* Skip starting quote */ + parser->pos++; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + char c = js[parser->pos]; + + /* Quote: end of string */ + if (c == '\"') { + if (tokens == NULL) { + return 0; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + parser->pos = start; + return JSMN_ERROR_NOMEM; + } + jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos); +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + return 0; + } + + /* Backslash: Quoted symbol expected */ + if (c == '\\' && parser->pos + 1 < len) { + int i; + parser->pos++; + switch (js[parser->pos]) { + /* Allowed escaped symbols */ + case '\"': + case '/': + case '\\': + case 'b': + case 'f': + case 'r': + case 'n': + case 't': + break; + /* Allows escaped symbol \uXXXX */ + case 'u': + parser->pos++; + for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0'; + i++) { + /* If it isn't a hex character we have an error */ + if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */ + (js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */ + (js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */ + parser->pos = start; + return JSMN_ERROR_INVAL; + } + parser->pos++; + } + parser->pos--; + break; + /* Unexpected symbol */ + default: + parser->pos = start; + return JSMN_ERROR_INVAL; + } + } + } + parser->pos = start; + return JSMN_ERROR_PART; +} + +/** + * Parse JSON string and fill tokens. + */ +JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, + jsmntok_t *tokens, const unsigned int num_tokens) { + int r; + int i; + jsmntok_t *token; + int count = parser->toknext; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + char c; + jsmntype_t type; + + c = js[parser->pos]; + switch (c) { + case '{': + case '[': + count++; + if (tokens == NULL) { + break; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + return JSMN_ERROR_NOMEM; + } + if (parser->toksuper != -1) { + jsmntok_t *t = &tokens[parser->toksuper]; +#ifdef JSMN_STRICT + /* In strict mode an object or array can't become a key */ + if (t->type == JSMN_OBJECT) { + return JSMN_ERROR_INVAL; + } +#endif + t->size++; +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + } + token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY); + token->start = parser->pos; + parser->toksuper = parser->toknext - 1; + break; + case '}': + case ']': + if (tokens == NULL) { + break; + } + type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY); +#ifdef JSMN_PARENT_LINKS + if (parser->toknext < 1) { + return JSMN_ERROR_INVAL; + } + token = &tokens[parser->toknext - 1]; + for (;;) { + if (token->start != -1 && token->end == -1) { + if (token->type != type) { + return JSMN_ERROR_INVAL; + } + token->end = parser->pos + 1; + parser->toksuper = token->parent; + break; + } + if (token->parent == -1) { + if (token->type != type || parser->toksuper == -1) { + return JSMN_ERROR_INVAL; + } + break; + } + token = &tokens[token->parent]; + } +#else + for (i = parser->toknext - 1; i >= 0; i--) { + token = &tokens[i]; + if (token->start != -1 && token->end == -1) { + if (token->type != type) { + return JSMN_ERROR_INVAL; + } + parser->toksuper = -1; + token->end = parser->pos + 1; + break; + } + } + /* Error if unmatched closing bracket */ + if (i == -1) { + return JSMN_ERROR_INVAL; + } + for (; i >= 0; i--) { + token = &tokens[i]; + if (token->start != -1 && token->end == -1) { + parser->toksuper = i; + break; + } + } +#endif + break; + case '\"': + r = jsmn_parse_string(parser, js, len, tokens, num_tokens); + if (r < 0) { + return r; + } + count++; + if (parser->toksuper != -1 && tokens != NULL) { + tokens[parser->toksuper].size++; + } + break; + case '\t': + case '\r': + case '\n': + case ' ': + break; + case ':': + parser->toksuper = parser->toknext - 1; + break; + case ',': + if (tokens != NULL && parser->toksuper != -1 && + tokens[parser->toksuper].type != JSMN_ARRAY && + tokens[parser->toksuper].type != JSMN_OBJECT) { +#ifdef JSMN_PARENT_LINKS + parser->toksuper = tokens[parser->toksuper].parent; +#else + for (i = parser->toknext - 1; i >= 0; i--) { + if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) { + if (tokens[i].start != -1 && tokens[i].end == -1) { + parser->toksuper = i; + break; + } + } + } +#endif + } + break; +#ifdef JSMN_STRICT + /* In strict mode primitives are: numbers and booleans */ + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case 't': + case 'f': + case 'n': + /* And they must not be keys of the object */ + if (tokens != NULL && parser->toksuper != -1) { + const jsmntok_t *t = &tokens[parser->toksuper]; + if (t->type == JSMN_OBJECT || + (t->type == JSMN_STRING && t->size != 0)) { + return JSMN_ERROR_INVAL; + } + } +#else + /* In non-strict mode every unquoted value is a primitive */ + default: +#endif + r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens); + if (r < 0) { + return r; + } + count++; + if (parser->toksuper != -1 && tokens != NULL) { + tokens[parser->toksuper].size++; + } + break; + +#ifdef JSMN_STRICT + /* Unexpected char in strict mode */ + default: + return JSMN_ERROR_INVAL; +#endif + } + } + + if (tokens != NULL) { + for (i = parser->toknext - 1; i >= 0; i--) { + /* Unmatched opened object or array */ + if (tokens[i].start != -1 && tokens[i].end == -1) { + return JSMN_ERROR_PART; + } + } + } + + return count; +} + +/** + * Creates a new parser based over a given buffer with an array of tokens + * available. + */ +JSMN_API void jsmn_init(jsmn_parser *parser) { + parser->pos = 0; + parser->toknext = 0; + parser->toksuper = -1; +} + +#endif /* JSMN_HEADER */ + +#ifdef __cplusplus +} +#endif + +#endif /* JSMN_H */