core(M1): ayla-криптография, конверт, JSON (jsmn), HTTP-клиент

- crypto: KDF Ayla (двойной HMAC, suffix 0x30/31/32; app/dev направления),
  AES-256-CBC с непрерывной цепочкой (iv обновляется mbedtls на месте),
  Java-паддинг >=1 NUL; mode-latch против misuse (encrypt|decrypt);
  zeroize ключей при повторном init; векторы из APK (4 сессии × 4 сообщения,
  включая legacy-приём без NUL) — scripts/gen_kdf_vectors.py.
- envelope: pack/unpack {"enc","sign"}; расшифровка (движение цепочки)
  ДО проверки подписи; сравнение подписи в константном времени;
  extract_seq_no — depth-1 сканер без лимита токенов (OOB после escape
  исправлен, регресс-тесты по ASan-репро ревьюера).
- json: Writer (фикс. буфер, стек глубин, escape, ok()=false при
  переполнении) + Doc на jsmn (64 токена, unescape, overflow-guard).
- httpc: блокирующий POST/PUT для local_reg (статус 200-599, дренаж,
  shutdown перед close).
- third_party/jsmn (MIT, JSMN_STATIC).
- CMake: mbedtls системный (/usr/include/mbedtls3) или FetchContent;
  IDF: PRIV_REQUIRES mbedtls.
- CI: 3 конфигурации — gcc-Release, gcc-Debug+ASan/UBSan, clang-Release;
  6/6 тестов стабильно; ESP-IDF esp32 build complete.
Ревью под-агентом: 3 круга (OOB-блокер + тестовые флаки закрыты), APPROVED.
This commit is contained in:
2026-09-22 15:45:20 +03:00
parent b44004627b
commit e91d4605b1
19 changed files with 2120 additions and 3 deletions

View File

@@ -9,18 +9,23 @@ if(ESP_PLATFORM)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
idf_component_register( idf_component_register(
SRCS SRCS
"src/ayla/crypto.cpp"
"src/ayla/envelope.cpp"
"src/ayla/httpc.cpp"
"src/ayla/httpd.cpp" "src/ayla/httpd.cpp"
"src/ayla/log.cpp" "src/ayla/log.cpp"
"src/ayla/platform/esp-idf/platform.cpp" "src/ayla/platform/esp-idf/platform.cpp"
INCLUDE_DIRS INCLUDE_DIRS
"include" "include"
PRIV_INCLUDE_DIRS PRIV_INCLUDE_DIRS
"."
"src" "src"
PRIV_REQUIRES PRIV_REQUIRES
lwip lwip
esp_timer esp_timer
esp_hw_support esp_hw_support
pthread pthread
mbedtls
) )
target_compile_options(${COMPONENT_LIB} PRIVATE target_compile_options(${COMPONENT_LIB} PRIVATE
-Wall -Wextra -Werror -Wall -Wextra -Werror
@@ -34,16 +39,57 @@ else()
project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX) project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX)
option(FGL_BUILD_TESTS "Build tests" ON) 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 add_library(fgl-aircon STATIC
src/ayla/crypto.cpp
src/ayla/envelope.cpp
src/ayla/httpc.cpp
src/ayla/httpd.cpp src/ayla/httpd.cpp
src/ayla/log.cpp src/ayla/log.cpp
src/ayla/platform/posix/platform.cpp src/ayla/platform/posix/platform.cpp
) )
target_include_directories(fgl-aircon target_include_directories(fgl-aircon
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" PUBLIC
PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" "${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_features(fgl-aircon PUBLIC cxx_std_20)
target_compile_options(fgl-aircon PRIVATE target_compile_options(fgl-aircon PRIVATE
-Wall -Wextra -Werror -Wall -Wextra -Werror

View File

@@ -10,4 +10,21 @@ cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build "$BUILD_DIR" cmake --build "$BUILD_DIR"
ctest --test-dir "$BUILD_DIR" --output-on-failure 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" echo "CI OK"

View File

@@ -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 <cstddef>")
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))

137
src/ayla/crypto.cpp Normal file
View File

@@ -0,0 +1,137 @@
#include "ayla/crypto.hpp"
#include <cstdio>
#include <cstring>
#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<long long>(t_a));
snprintf(t_b_buf, sizeof(t_b_buf), "%lld", static_cast<long long>(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<const uint8_t*>(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<long>(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<long>(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

70
src/ayla/crypto.hpp Normal file
View File

@@ -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 <cstddef>
#include <cstdint>
#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

180
src/ayla/envelope.cpp Normal file
View File

@@ -0,0 +1,180 @@
#include "ayla/envelope.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#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<unsigned char*>(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<const unsigned char*>(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<size_t>(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<uint8_t>(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":<data_json>}
char plain[kEnvelopeMaxPlain];
int n = snprintf(plain, sizeof(plain), "{\"seq_no\":%lld,\"data\":%s}",
static_cast<long long>(seq_no), data_json);
if (n <= 0 || static_cast<size_t>(n) >= sizeof(plain)) return false;
size_t plain_len = static_cast<size_t>(n);
// Подпись — по байтам открытого текста без паддинга.
uint8_t mac[kSha256Len];
dir.sign(mac, reinterpret_cast<const uint8_t*>(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<const uint8_t*>(plain), plain_len);
if (ct_len < 0) return false;
char enc_b64[kEnvelopeMaxB64];
if (!b64_encode(ct, static_cast<size_t>(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<size_t>(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<size_t>(plain_len) >= out_cap) return false;
// Проверка подписи по расшифрованным байтам (без паддинга);
// сравнение декодированных байт — константное время.
uint8_t mac[kSha256Len];
dir.sign(mac, plain, static_cast<size_t>(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<size_t>(plain_len));
json_out[plain_len] = '\0';
return extract_seq_no(json_out, seq_no);
}
} // namespace fgl::ayla

31
src/ayla/envelope.hpp Normal file
View File

@@ -0,0 +1,31 @@
// Защищённый конверт Ayla LAN: {"enc":"<b64 AES-CBC>","sign":"<b64 HMAC>"},
// открытый текст: {"seq_no":N,"data":<json-объект>} (docs/PROTOCOL.md §3.3).
#pragma once
#include <cstddef>
#include <cstdint>
#include "ayla/crypto.hpp"
namespace fgl::ayla {
constexpr size_t kEnvelopeMaxPlain = 1024; // открытый текст конверта
constexpr size_t kEnvelopeMaxB64 = 1800; // base64 шифротекста с запасом
// Собирает открытый текст '{"seq_no":<seq>,"data":<data_json>}' и упаковывает
// в конверт (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

128
src/ayla/httpc.cpp Normal file
View File

@@ -0,0 +1,128 @@
#include "ayla/httpc.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#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<char>(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<char>(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<long>(sizeof(sink))
? static_cast<size_t>(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<unsigned>(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<unsigned>(req.body_len));
if (n <= 0 || static_cast<size_t>(n) >= sizeof(head)) {
fgl::plat::tcp_close(fd);
return false;
}
bool sent = fgl::plat::tcp_send(fd, head, static_cast<size_t>(n)) == n;
if (sent && req.body != nullptr && req.body_len > 0) {
sent = fgl::plat::tcp_send(fd, req.body, req.body_len) ==
static_cast<long>(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

32
src/ayla/httpc.hpp Normal file
View File

@@ -0,0 +1,32 @@
// Минимальный HTTP-клиент для исходящих запросов к модулю (local_reg).
// Один запрос — одно соединение; достаточно для keep-alive-цикла протокола
// (проверено на приборе: модуль принимает local_reg на новых соединениях).
#pragma once
#include <cstddef>
#include <cstdint>
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

253
src/ayla/json.hpp Normal file
View File

@@ -0,0 +1,253 @@
// Минимальный JSON: writer с фиксированным буфером + парсер (jsmn).
// Никаких аллокаций; экранирование строк — достаточное для протокола.
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#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<long long>(v));
put_raw(tmp, static_cast<size_t>(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<unsigned char>(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<int>(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<size_t>(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

View File

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

View File

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

171
tests/ayla/test_crypto.cpp Normal file
View File

@@ -0,0 +1,171 @@
// Тесты криптографии Ayla LAN: KDF по векторам (эталон = APK), CBC-цепочка,
// подписи, оба варианта паддинга.
#include "kdf_vectors.hpp"
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/crypto.hpp"
using fgl::ayla::test::kKdfVectorCount;
using fgl::ayla::test::kKdfVectors;
namespace {
// base64 для сравнения в тестах.
std::string b64(const uint8_t* data, size_t len) {
static const char kTable[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
for (size_t i = 0; i < len; i += 3) {
uint32_t v = data[i] << 16;
if (i + 1 < len) v |= data[i + 1] << 8;
if (i + 2 < len) v |= data[i + 2];
out += kTable[(v >> 18) & 63];
out += kTable[(v >> 12) & 63];
out += (i + 1 < len) ? kTable[(v >> 6) & 63] : '=';
out += (i + 2 < len) ? kTable[v & 63] : '=';
}
return out;
}
} // namespace
TEST_CASE("KDF+envelope: векторы APK (шифрование app-направления, цепочка)") {
for (size_t v = 0; v < kKdfVectorCount; v++) {
CAPTURE(v);
const auto& vec = kKdfVectors[v];
fgl::ayla::SessionCrypto sc;
REQUIRE(sc.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
vec.time_2));
// Отправитель всегда использует Java-паддинг (>=1 NUL) — сверяем 0..2.
// (Вектор 3 — legacy-приём без NUL; проверяется в decrypt-тесте.)
for (int m = 0; m < 3; m++) {
CAPTURE(m);
uint8_t ct[1024];
long ct_len = sc.app.encrypt(ct, sizeof(ct),
reinterpret_cast<const uint8_t*>(vec.plaintexts[m]),
strlen(vec.plaintexts[m]));
REQUIRE(ct_len > 0);
CHECK(b64(ct, static_cast<size_t>(ct_len)) == std::string(vec.enc_base64[m]));
}
// Подписи направлений.
uint8_t mac[32];
sc.app.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[0]),
strlen(vec.plaintexts[0]));
CHECK(b64(mac, sizeof(mac)) == std::string(vec.app_sign_msg0_b64));
sc.dev.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[1]),
strlen(vec.plaintexts[1]));
CHECK(b64(mac, sizeof(mac)) == std::string(vec.dev_sign_msg1_b64));
}
}
TEST_CASE("Расшифровка векторов: независимый приёмник, цепочка, оба паддинга") {
for (size_t v = 0; v < kKdfVectorCount; v++) {
const auto& vec = kKdfVectors[v];
// «Приёмник» — свежая сессия: цепочка стартует с iv_seed.
fgl::ayla::SessionCrypto rx;
REQUIRE(rx.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
vec.time_2));
// base64-декодирование вектора (таблица для теста).
auto unb64 = [](const char* in, uint8_t* out) -> size_t {
auto val = [](char c) -> int {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
};
size_t len = strlen(in), out_len = 0;
uint32_t acc = 0;
int bits = 0;
for (size_t i = 0; i < len; i++) {
if (in[i] == '=') break;
int d = val(in[i]);
if (d < 0) return 0;
acc = (acc << 6) | static_cast<uint32_t>(d);
bits += 6;
if (bits >= 8) {
bits -= 8;
out[out_len++] = static_cast<uint8_t>((acc >> bits) & 0xff);
}
}
return out_len;
};
for (int m = 0; m < 4; m++) {
uint8_t ct[1024];
size_t ct_len = unb64(vec.enc_base64[m], ct);
REQUIRE(ct_len > 0);
REQUIRE(ct_len % 16 == 0);
uint8_t pt[1024] = {};
long pt_len = rx.app.decrypt(pt, sizeof(pt), ct, ct_len);
REQUIRE(pt_len > 0);
// Открытый текст совпадает (NUL-паддинг снят; legacy-вариант — точно).
CHECK(pt_len == static_cast<long>(strlen(vec.plaintexts[m])));
CHECK(memcmp(pt, vec.plaintexts[m], pt_len) == 0);
}
}
}
TEST_CASE("dev-направление: roundtrip цепочки encrypt→decrypt") {
const char* key = "UnitTestKey01234567890==";
const char* msgs[] = {
"{\"seq_no\":0,\"data\":{\"name\":\"a\",\"value\":1}}",
"{\"seq_no\":1,\"data\":{\"name\":\"b\",\"value\":2}}",
"{\"seq_no\":2,\"data\":{}}",
};
fgl::ayla::SessionCrypto tx, rx;
REQUIRE(tx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
REQUIRE(rx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
for (int i = 0; i < 3; i++) {
uint8_t ct[512];
long ct_len = tx.dev.encrypt(ct, sizeof(ct),
reinterpret_cast<const uint8_t*>(msgs[i]),
strlen(msgs[i]));
REQUIRE(ct_len > 0);
uint8_t pt[512];
long pt_len = rx.dev.decrypt(pt, sizeof(pt), ct, static_cast<size_t>(ct_len));
REQUIRE(pt_len == static_cast<long>(strlen(msgs[i])));
CHECK(memcmp(pt, msgs[i], pt_len) == 0);
}
}
TEST_CASE("encrypt/decrypt: граничные случаи") {
fgl::ayla::SessionCrypto sc;
REQUIRE(sc.init("k", "a", "b", 1, 2));
uint8_t out[2048];
// out_cap меньше padded — ошибка.
CHECK(sc.app.encrypt(out, 1, reinterpret_cast<const uint8_t*>("xx"), 2) == -1);
// Некратная длина шифротекста — ошибка расшифровки.
uint8_t junk[17] = {};
CHECK(sc.app.decrypt(out, sizeof(out), junk, 17) == -1);
// Нулевая длина — ошибка.
CHECK(sc.app.decrypt(out, sizeof(out), junk, 0) == -1);
}
TEST_CASE("DirectionCrypto: смешение encrypt/decrypt одного направления запрещено") {
fgl::ayla::SessionCrypto sc;
REQUIRE(sc.init("k", "a", "b", 1, 2));
uint8_t out[64];
// Фиксируем режим шифрованием.
REQUIRE(sc.app.encrypt(out, sizeof(out),
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == 32);
// Расшифровка тем же направлением — отказ (misuse-защита одной цепочки).
uint8_t pt[64];
CHECK(sc.app.decrypt(pt, sizeof(pt), out, 16) == -1);
// Обратный порядок для dev-направления.
fgl::ayla::SessionCrypto sc2;
REQUIRE(sc2.init("k", "a", "b", 1, 2));
uint8_t junk[16] = {};
REQUIRE(sc2.dev.decrypt(pt, sizeof(pt), junk, 16) >= 0);
CHECK(sc2.dev.encrypt(out, sizeof(out),
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == -1);
}

View File

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

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

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

View File

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

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

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

20
third_party/jsmn/LICENSE vendored Normal file
View File

@@ -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.

471
third_party/jsmn/jsmn.h vendored Normal file
View File

@@ -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 <stddef.h>
#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 */