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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
86
scripts/gen_kdf_vectors.py
Normal file
86
scripts/gen_kdf_vectors.py
Normal 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
137
src/ayla/crypto.cpp
Normal 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
70
src/ayla/crypto.hpp
Normal 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
180
src/ayla/envelope.cpp
Normal 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
31
src/ayla/envelope.hpp
Normal 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
128
src/ayla/httpc.cpp
Normal 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
32
src/ayla/httpc.hpp
Normal 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
253
src/ayla/json.hpp
Normal 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
|
||||
@@ -22,3 +22,7 @@ endfunction()
|
||||
|
||||
fgl_add_test(ayla_platform ayla/test_platform.cpp)
|
||||
fgl_add_test(ayla_httpd ayla/test_httpd.cpp)
|
||||
fgl_add_test(ayla_crypto ayla/test_crypto.cpp)
|
||||
fgl_add_test(ayla_envelope ayla/test_envelope.cpp)
|
||||
fgl_add_test(ayla_json ayla/test_json.cpp)
|
||||
fgl_add_test(ayla_httpc ayla/test_httpc.cpp)
|
||||
|
||||
29
tests/ayla/kdf_vectors.hpp
Normal file
29
tests/ayla/kdf_vectors.hpp
Normal file
@@ -0,0 +1,29 @@
|
||||
// СГЕНЕРИРОВАНО scripts/gen_kdf_vectors.py (эталон = APK AylaEncryption).
|
||||
// Не редактировать руками; регенерация:
|
||||
// python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
|
||||
namespace fgl::ayla::test {
|
||||
|
||||
struct KdfVector {
|
||||
const char* lanip_key;
|
||||
const char* random_1;
|
||||
const char* random_2;
|
||||
long long time_1;
|
||||
long long time_2;
|
||||
const char* plaintexts[4];
|
||||
const char* enc_base64[4]; // 0..2: Java-паддинг; 3: legacy без NUL
|
||||
const char* app_sign_msg0_b64;
|
||||
const char* dev_sign_msg1_b64;
|
||||
};
|
||||
|
||||
inline constexpr KdfVector kKdfVectors[] = {
|
||||
{"nRB152rvLrgpUyysvyS6", "Unajei61hBtnAXvL", "8q8tgzOx60ehhAtF", 274989731965726LL, 847349374141033LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"hk/Iha4XexydQeV7cQ9K4rtNo4hMRPKaCBIINcuU0yo=", "FZnpg9JTsS+56Eh+80V9RlFn7iCVWlGU3xq9Wm8s9GfqLnq49I/V386EqnAMTAT3hZvq46WDGRnymwmlJF+gtOCoTlzvkD/2ozKtXeFMLtidTmqzDddOHP+uPNEXZcuVsfpuN2dLPctqTmM827lrbpwctKvuNTmzd1L6gpdgSQ4e9V4sd+9OA/fI1Eu4E1maJ+Tm4QdpKADrj50oh2qRsOqdjhAaWOjT5lgauLISqno=", "SZa7swUooRnYenDVlVocX8IoLIK9QZm1wybIEVE44g1Q63SDQ4ulpbOvgvTkmTuN4krAGJA6qKt7pm2HYhyIDhMz/fmNgee+FrK/C3Fdy1bFPl8SESR8UL9R7eN2TSxrtK51+uyFvm6DMvft6zmjZnfdyRkjfCN0gI8bGLXN4yU=", "l1DRivEQAazsO+2uWd0qdc00P6qjw5nIK7skXfYn9Bo="}, "IODB6lZl2ClQ/CHa3e0GMbG466jlZ59up4XgVP/00wY=", "sKBzGqhFIPb8DL8KbK0Np2Zag9C80JS8oHLiDXTu2DE="},
|
||||
{"e3YfUELFmkeD4BWjSSnYxA==", "cxtcwrhtsyAcHm8S", "wfdv8OHvq0h03YnY", 761048435275066LL, 11876266770736LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"AhUcfcYews8/ip360hj3RbKoCt8hwgEA3791gG1IQSM=", "S5PS+KqJ9vIu65Fd+hcnvllI5R34enCE7BT6ukZjdRoVTIEOhIQCzusUcu0vlm2ZLqJwSRBYPFlQKkGh5VDXyJZLQLj+tFZDCf2n4d6wPcFBHsH3VEZy8yl+odPXc6ogAEwtyJhBZ9fURrNgQiveWNG42zTQ13dElqU2RD1bmfilJe0FF84cdre8bHJm7HyWAg5XdQ4qOVzZhH+EWE1XfWeegPDoIsi5ch8QJOiK7KY=", "qbEQTeBvTYYDfFUVwf5oK8slPxW3vUk9jbN6z3sAoQ17s1OiaqHJQrYFr0dDQIvuQgqAcj8+lRtc97papq4Fgpz/QAKCDIZujClJR9KWHBUrTqE8j89QjwjaQZ8ze+hzqIvebwt2C7vM/YivEJz9aSuxi1iLcCcEhnR+VlHoeBw=", "+aAezE8EmyfdRzJnIrsFC3NYxsZRswDkhPNc7qypCZ8="}, "4sW8EmpwLVz/pPN7MZ0Art+uz7uZbZI7lDBuI0Lzjtw=", "h8bqQ1umSr3pTReBno61eTSsK5TCA+2xGAP+uzzx6FE="},
|
||||
{"deQG9G4zw74OZBzgL31jxCI=", "AlUvHAXene8hTnpq", "rSvwucWBRIQgcG0i", 299350142996752LL, 501892609552265LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"QSdv/i/vwK6JIni4wmMstgf0PiRc/fAPKkszlQrHeV8=", "q5t4RkNU8bu67S15mi27dT28UwWCcVQLh/PgBshhhQVljm950hqGsKPJMSNy5Bul41Ut6bd6sPGdUuFl82FPecIkloDTAx0e0htDjaESEcdUTao4Uxn4yLILG8PKHGog2bHHfGnG7AIk9XFNKnxXCIRtJetgu57gWTlFSxf4UIG+8zBZmb1Zn0R6csUkrFLCAzjfQzx430Gagcw1+lyfgL4EoZgx6SJhWQQ9mM/uyxE=", "eY/Y17nxXaSGSNKjq0XEDYw+Ks55Sze7bZw5yKGnwI6iS8uODSBila54I630XOp/mlAvdVE09StUi7xPAkrCG0gDJ7DsMLFONZR3HDWf7f63iYpdOOQlXRnH8SnQ7pmu4mqRp2JE/NqdWCWT6AMUzXOWDUbxfl3OShLrZf29uDU=", "8RWdrVA+NRbqkT4aXTgm0djTjfyz5rC2fGoh+RfRqow="}, "Z5Ah10Y/qJngN2DUJhhkEytTZsFCOiV2TaeTh61X5pc=", "TcQdYNIoQXEeDZd9ZJWAn9tayz8Zc6/ZR2MtnvQ6/xc="},
|
||||
{"v8CQFGwiB+/z9UUEGdu7rZ0a", "27XqoPSfZ4553E3K", "rbwC6T3cXdodlhIS", 257649300961690LL, 851173235636538LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"1c7PN6ycazQ1MjiwUmeMQLpp3LuYx3HTuxnS2kFgAw4=", "73cUheq6HOJlHzhuRMf1M5owaQFEw0wXvzsRB1nvGOPpNyLJKilrEVYkyeDH7MzqXRf9thxswnSLOfgK20NHyUozTe41BE/qz7/oVvFquPOPq6bjUWADzugjjOyDAezXyCb0Z+QvgnqruYjQHJJMxe3U2dcypLUXiScDui694bugFVhnIoQTRFyW0LxBff/ZCp3DBrAZUD178MseY9/h/kEx+Rc8qJzLUjvBHuqGqK8=", "7LRiFwjfdrHgksMvV6HwQM/kK8Yeiwgl+QYljJhboya3HzMa1MmGNPxfy0X8nck77ShFeSSpethG1Gk9/jfhFIeIqLoTNSGi/STwfE0S8RKjKx+wIaBSO2xyTbCx8s9g/+Jvn+wORpUdT45wz5sX1c/LjVJkItcjE/gtGPrDICY=", "hyr7N37WMcA/1SOjPceqwnjdCAhn1HWluLPw39EgiL8="}, "fWNCY5DVgVWIKQZoepEUxYE7yaKfxVsR7Bv3zQIkpRo=", "h/9MHTMSW84EFo96BQW2X73cUi0rILDWQoSIqTlEGew="},
|
||||
};
|
||||
inline constexpr size_t kKdfVectorCount = sizeof(kKdfVectors) / sizeof(kKdfVectors[0]);
|
||||
|
||||
} // namespace fgl::ayla::test
|
||||
171
tests/ayla/test_crypto.cpp
Normal file
171
tests/ayla/test_crypto.cpp
Normal file
@@ -0,0 +1,171 @@
|
||||
// Тесты криптографии Ayla LAN: KDF по векторам (эталон = APK), CBC-цепочка,
|
||||
// подписи, оба варианта паддинга.
|
||||
#include "kdf_vectors.hpp"
|
||||
#include "doctest/doctest.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "ayla/crypto.hpp"
|
||||
|
||||
using fgl::ayla::test::kKdfVectorCount;
|
||||
using fgl::ayla::test::kKdfVectors;
|
||||
|
||||
namespace {
|
||||
|
||||
// base64 для сравнения в тестах.
|
||||
std::string b64(const uint8_t* data, size_t len) {
|
||||
static const char kTable[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
uint32_t v = data[i] << 16;
|
||||
if (i + 1 < len) v |= data[i + 1] << 8;
|
||||
if (i + 2 < len) v |= data[i + 2];
|
||||
out += kTable[(v >> 18) & 63];
|
||||
out += kTable[(v >> 12) & 63];
|
||||
out += (i + 1 < len) ? kTable[(v >> 6) & 63] : '=';
|
||||
out += (i + 2 < len) ? kTable[v & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("KDF+envelope: векторы APK (шифрование app-направления, цепочка)") {
|
||||
for (size_t v = 0; v < kKdfVectorCount; v++) {
|
||||
CAPTURE(v);
|
||||
const auto& vec = kKdfVectors[v];
|
||||
fgl::ayla::SessionCrypto sc;
|
||||
REQUIRE(sc.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
|
||||
vec.time_2));
|
||||
|
||||
// Отправитель всегда использует Java-паддинг (>=1 NUL) — сверяем 0..2.
|
||||
// (Вектор 3 — legacy-приём без NUL; проверяется в decrypt-тесте.)
|
||||
for (int m = 0; m < 3; m++) {
|
||||
CAPTURE(m);
|
||||
uint8_t ct[1024];
|
||||
long ct_len = sc.app.encrypt(ct, sizeof(ct),
|
||||
reinterpret_cast<const uint8_t*>(vec.plaintexts[m]),
|
||||
strlen(vec.plaintexts[m]));
|
||||
REQUIRE(ct_len > 0);
|
||||
CHECK(b64(ct, static_cast<size_t>(ct_len)) == std::string(vec.enc_base64[m]));
|
||||
}
|
||||
|
||||
// Подписи направлений.
|
||||
uint8_t mac[32];
|
||||
sc.app.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[0]),
|
||||
strlen(vec.plaintexts[0]));
|
||||
CHECK(b64(mac, sizeof(mac)) == std::string(vec.app_sign_msg0_b64));
|
||||
sc.dev.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[1]),
|
||||
strlen(vec.plaintexts[1]));
|
||||
CHECK(b64(mac, sizeof(mac)) == std::string(vec.dev_sign_msg1_b64));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Расшифровка векторов: независимый приёмник, цепочка, оба паддинга") {
|
||||
for (size_t v = 0; v < kKdfVectorCount; v++) {
|
||||
const auto& vec = kKdfVectors[v];
|
||||
// «Приёмник» — свежая сессия: цепочка стартует с iv_seed.
|
||||
fgl::ayla::SessionCrypto rx;
|
||||
REQUIRE(rx.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
|
||||
vec.time_2));
|
||||
|
||||
// base64-декодирование вектора (таблица для теста).
|
||||
auto unb64 = [](const char* in, uint8_t* out) -> size_t {
|
||||
auto val = [](char c) -> int {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
};
|
||||
size_t len = strlen(in), out_len = 0;
|
||||
uint32_t acc = 0;
|
||||
int bits = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (in[i] == '=') break;
|
||||
int d = val(in[i]);
|
||||
if (d < 0) return 0;
|
||||
acc = (acc << 6) | static_cast<uint32_t>(d);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[out_len++] = static_cast<uint8_t>((acc >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return out_len;
|
||||
};
|
||||
|
||||
for (int m = 0; m < 4; m++) {
|
||||
uint8_t ct[1024];
|
||||
size_t ct_len = unb64(vec.enc_base64[m], ct);
|
||||
REQUIRE(ct_len > 0);
|
||||
REQUIRE(ct_len % 16 == 0);
|
||||
uint8_t pt[1024] = {};
|
||||
long pt_len = rx.app.decrypt(pt, sizeof(pt), ct, ct_len);
|
||||
REQUIRE(pt_len > 0);
|
||||
// Открытый текст совпадает (NUL-паддинг снят; legacy-вариант — точно).
|
||||
CHECK(pt_len == static_cast<long>(strlen(vec.plaintexts[m])));
|
||||
CHECK(memcmp(pt, vec.plaintexts[m], pt_len) == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("dev-направление: roundtrip цепочки encrypt→decrypt") {
|
||||
const char* key = "UnitTestKey01234567890==";
|
||||
const char* msgs[] = {
|
||||
"{\"seq_no\":0,\"data\":{\"name\":\"a\",\"value\":1}}",
|
||||
"{\"seq_no\":1,\"data\":{\"name\":\"b\",\"value\":2}}",
|
||||
"{\"seq_no\":2,\"data\":{}}",
|
||||
};
|
||||
fgl::ayla::SessionCrypto tx, rx;
|
||||
REQUIRE(tx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
|
||||
REQUIRE(rx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
uint8_t ct[512];
|
||||
long ct_len = tx.dev.encrypt(ct, sizeof(ct),
|
||||
reinterpret_cast<const uint8_t*>(msgs[i]),
|
||||
strlen(msgs[i]));
|
||||
REQUIRE(ct_len > 0);
|
||||
uint8_t pt[512];
|
||||
long pt_len = rx.dev.decrypt(pt, sizeof(pt), ct, static_cast<size_t>(ct_len));
|
||||
REQUIRE(pt_len == static_cast<long>(strlen(msgs[i])));
|
||||
CHECK(memcmp(pt, msgs[i], pt_len) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("encrypt/decrypt: граничные случаи") {
|
||||
fgl::ayla::SessionCrypto sc;
|
||||
REQUIRE(sc.init("k", "a", "b", 1, 2));
|
||||
|
||||
uint8_t out[2048];
|
||||
// out_cap меньше padded — ошибка.
|
||||
CHECK(sc.app.encrypt(out, 1, reinterpret_cast<const uint8_t*>("xx"), 2) == -1);
|
||||
// Некратная длина шифротекста — ошибка расшифровки.
|
||||
uint8_t junk[17] = {};
|
||||
CHECK(sc.app.decrypt(out, sizeof(out), junk, 17) == -1);
|
||||
// Нулевая длина — ошибка.
|
||||
CHECK(sc.app.decrypt(out, sizeof(out), junk, 0) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("DirectionCrypto: смешение encrypt/decrypt одного направления запрещено") {
|
||||
fgl::ayla::SessionCrypto sc;
|
||||
REQUIRE(sc.init("k", "a", "b", 1, 2));
|
||||
uint8_t out[64];
|
||||
// Фиксируем режим шифрованием.
|
||||
REQUIRE(sc.app.encrypt(out, sizeof(out),
|
||||
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == 32);
|
||||
// Расшифровка тем же направлением — отказ (misuse-защита одной цепочки).
|
||||
uint8_t pt[64];
|
||||
CHECK(sc.app.decrypt(pt, sizeof(pt), out, 16) == -1);
|
||||
// Обратный порядок для dev-направления.
|
||||
fgl::ayla::SessionCrypto sc2;
|
||||
REQUIRE(sc2.init("k", "a", "b", 1, 2));
|
||||
uint8_t junk[16] = {};
|
||||
REQUIRE(sc2.dev.decrypt(pt, sizeof(pt), junk, 16) >= 0);
|
||||
CHECK(sc2.dev.encrypt(out, sizeof(out),
|
||||
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == -1);
|
||||
}
|
||||
178
tests/ayla/test_envelope.cpp
Normal file
178
tests/ayla/test_envelope.cpp
Normal file
@@ -0,0 +1,178 @@
|
||||
// Тесты конверта {"enc","sign"}: roundtrip, цепочка, порча подписи.
|
||||
#include "doctest/doctest.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "ayla/crypto.hpp"
|
||||
#include "ayla/envelope.hpp"
|
||||
|
||||
TEST_CASE("envelope: roundtrip и CBC-цепочка (3 сообщения)") {
|
||||
const char* key = "UnitTestKey01234567890==";
|
||||
fgl::ayla::SessionCrypto tx, rx;
|
||||
REQUIRE(tx.init(key, "rnd1111rnd1111aa", "rnd2222rnd2222bb", 42, 43));
|
||||
REQUIRE(rx.init(key, "rnd1111rnd1111aa", "rnd2222rnd2222bb", 42, 43));
|
||||
|
||||
const char* payloads[] = {
|
||||
"{}",
|
||||
"{\"cmds\":[{\"cmd\":{\"cmd_id\":7,\"method\":\"GET\","
|
||||
"\"resource\":\"property.json?name=x\",\"data\":\"\","
|
||||
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}",
|
||||
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
|
||||
"\"name\":\"n\",\"value\":9,\"id\":\"zz\"}}]}",
|
||||
};
|
||||
char envelopes[3][fgl::ayla::kEnvelopeMaxB64];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
REQUIRE(fgl::ayla::envelope_pack(tx.app, i, payloads[i], envelopes[i],
|
||||
sizeof(envelopes[i])));
|
||||
}
|
||||
// Разные шифротексты (цепочка движется).
|
||||
CHECK(std::string(envelopes[0]) != std::string(envelopes[1]));
|
||||
|
||||
// Приёмник с той же цепочкой расшифровывает по порядку.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
// Разбор конверта на enc/sign (в тесте — вручную, простым поиском).
|
||||
std::string env(envelopes[i]);
|
||||
size_t e1 = env.find("\"enc\":\"") + 7;
|
||||
size_t e2 = env.find('"', e1);
|
||||
size_t s1 = env.find("\"sign\":\"") + 8;
|
||||
size_t s2 = env.find('"', s1);
|
||||
REQUIRE(e2 != std::string::npos);
|
||||
REQUIRE(s2 != std::string::npos);
|
||||
std::string enc = env.substr(e1, e2 - e1);
|
||||
std::string sign = env.substr(s1, s2 - s1);
|
||||
|
||||
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||
int64_t seq = -1;
|
||||
REQUIRE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(), json,
|
||||
sizeof(json), &seq));
|
||||
CHECK(seq == i);
|
||||
std::string expected = "{\"seq_no\":" + std::to_string(i) + ",\"data\":" +
|
||||
payloads[i] + "}";
|
||||
CHECK(std::string(json) == expected);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("envelope: порча подписи отклоняется, цепочка продолжает движение") {
|
||||
const char* key = "UnitTestKey01234567890==";
|
||||
fgl::ayla::SessionCrypto tx, rx;
|
||||
REQUIRE(tx.init(key, "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", 1, 2));
|
||||
REQUIRE(rx.init(key, "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", 1, 2));
|
||||
|
||||
char e0[fgl::ayla::kEnvelopeMaxB64], e1[fgl::ayla::kEnvelopeMaxB64];
|
||||
REQUIRE(fgl::ayla::envelope_pack(tx.app, 0, "{}", e0, sizeof(e0)));
|
||||
REQUIRE(fgl::ayla::envelope_pack(tx.app, 1, "{\"a\":1}", e1, sizeof(e1)));
|
||||
|
||||
auto split = [](const char* env, std::string* enc, std::string* sign) {
|
||||
std::string s(env);
|
||||
size_t e1 = s.find("\"enc\":\"") + 7, e2 = s.find('"', e1);
|
||||
size_t g1 = s.find("\"sign\":\"") + 8, g2 = s.find('"', g1);
|
||||
*enc = s.substr(e1, e2 - e1);
|
||||
*sign = s.substr(g1, g2 - g1);
|
||||
};
|
||||
std::string enc0, sign0, enc1, sign1;
|
||||
split(e0, &enc0, &sign0);
|
||||
split(e1, &enc1, &sign1);
|
||||
|
||||
// Портим подпись первого сообщения: распаковка обязана провалиться,
|
||||
// но расшифровка (и движение цепочки) уже произошла.
|
||||
std::string bad = sign0;
|
||||
bad[bad.size() - 2] = (bad[bad.size() - 2] == 'A') ? 'B' : 'A';
|
||||
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||
int64_t seq = -1;
|
||||
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc0.c_str(), bad.c_str(),
|
||||
json, sizeof(json), &seq));
|
||||
// Второе сообщение — валидное, цепочка корректна.
|
||||
REQUIRE(fgl::ayla::envelope_unpack(rx.app, enc1.c_str(), sign1.c_str(), json,
|
||||
sizeof(json), &seq));
|
||||
CHECK(seq == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("envelope: некорректный base64 и повреждённый шифротекст") {
|
||||
const char* key = "UnitTestKey01234567890==";
|
||||
fgl::ayla::SessionCrypto tx, rx;
|
||||
REQUIRE(tx.init(key, "cccccccccccccccc", "dddddddddddddddd", 5, 6));
|
||||
REQUIRE(rx.init(key, "cccccccccccccccc", "dddddddddddddddd", 5, 6));
|
||||
|
||||
char env[fgl::ayla::kEnvelopeMaxB64];
|
||||
REQUIRE(fgl::ayla::envelope_pack(tx.app, 0, "{}", env, sizeof(env)));
|
||||
std::string s(env);
|
||||
size_t e1 = s.find("\"enc\":\"") + 7, e2 = s.find('"', e1);
|
||||
size_t g1 = s.find("\"sign\":\"") + 8, g2 = s.find('"', g1);
|
||||
std::string enc = s.substr(e1, e2 - e1);
|
||||
std::string sign = s.substr(g1, g2 - g1);
|
||||
|
||||
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||
int64_t seq = -1;
|
||||
// Мусорный base64.
|
||||
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, "!!!not-base64!!!", sign.c_str(),
|
||||
json, sizeof(json), &seq));
|
||||
// Порча шифротекста: подпись не сойдётся (подводного камня нет — просто
|
||||
// проверяем, что это отклоняется).
|
||||
std::string broken = enc;
|
||||
if (broken[10] == 'A') {
|
||||
broken[10] = 'B';
|
||||
} else {
|
||||
broken[10] = 'A';
|
||||
}
|
||||
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, broken.c_str(), sign.c_str(),
|
||||
json, sizeof(json), &seq));
|
||||
// Оригинал после двух провалов уже не расшифруется (цепочка ушла) —
|
||||
// таково требование протокола; восстановление только re-key.
|
||||
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(),
|
||||
json, sizeof(json), &seq));
|
||||
}
|
||||
|
||||
namespace {
|
||||
// base64 для ручной сборки конвертов в тестах.
|
||||
std::string tb64(const uint8_t* data, size_t len) {
|
||||
static const char kTable[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
uint32_t v = data[i] << 16;
|
||||
if (i + 1 < len) v |= data[i + 1] << 8;
|
||||
if (i + 2 < len) v |= data[i + 2];
|
||||
out += kTable[(v >> 18) & 63];
|
||||
out += kTable[(v >> 12) & 63];
|
||||
out += (i + 1 < len) ? kTable[(v >> 6) & 63] : '=';
|
||||
out += (i + 2 < len) ? kTable[v & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("envelope: extract_seq_no устойчив к обрыву после escape (OOB-регрессия)") {
|
||||
// Аутентифицированный plaintext с незакрытой строкой, заканчивающейся '\'
|
||||
// (раньше — чтение за границей буфера, поймано ASan в ревью круга 1).
|
||||
// Конверты собираем вручную: sign + encrypt по битому plaintext.
|
||||
const char* key = "UnitTestKey01234567890==";
|
||||
fgl::ayla::SessionCrypto tx, rx;
|
||||
REQUIRE(tx.init(key, "eeeeeeeeeeeeeeee", "ffffffffffffffff", 7, 8));
|
||||
REQUIRE(rx.init(key, "eeeeeeeeeeeeeeee", "ffffffffffffffff", 7, 8));
|
||||
|
||||
// seq_no отсутствует/после битой части — сканер обязан ПРОПУСКАТЬ
|
||||
// повреждённое значение (именно на пропуске был OOB).
|
||||
const char* cases[] = {
|
||||
"{\"a\":\"x\\", // обрыв в строке-значении верхнего уровня
|
||||
"{\"a\":{\"b\":\"x\\", // обрыв в строке внутри вложенного объекта
|
||||
"{\"a\":[1,2,\"x\\", // обрыв в строке внутри массива
|
||||
};
|
||||
for (const char* plain : cases) {
|
||||
CAPTURE(plain);
|
||||
uint8_t mac[32];
|
||||
tx.app.sign(mac, reinterpret_cast<const uint8_t*>(plain), strlen(plain));
|
||||
uint8_t ct[fgl::ayla::kEnvelopeMaxPlain + 16];
|
||||
long ct_len = tx.app.encrypt(ct, sizeof(ct),
|
||||
reinterpret_cast<const uint8_t*>(plain),
|
||||
strlen(plain));
|
||||
REQUIRE(ct_len > 0);
|
||||
std::string enc = tb64(ct, static_cast<size_t>(ct_len));
|
||||
std::string sign = tb64(mac, sizeof(mac));
|
||||
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||
int64_t seq = -1;
|
||||
// unpack обязан вернуть false (битый JSON) и не выйти за границы.
|
||||
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(),
|
||||
json, sizeof(json), &seq));
|
||||
}
|
||||
}
|
||||
106
tests/ayla/test_httpc.cpp
Normal file
106
tests/ayla/test_httpc.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
// Тесты HTTP-клиента против in-process httpd.
|
||||
#include "doctest/doctest.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "ayla/httpc.hpp"
|
||||
#include "ayla/httpd.hpp"
|
||||
#include "ayla/platform/platform.hpp"
|
||||
|
||||
namespace {
|
||||
struct Ctx {
|
||||
fgl::ayla::HttpRequest last;
|
||||
int calls = 0;
|
||||
int status_to_return = 200;
|
||||
};
|
||||
|
||||
bool handler(const fgl::ayla::HttpRequest& req, fgl::ayla::HttpResponse& resp,
|
||||
void* ctx) {
|
||||
auto* c = static_cast<Ctx*>(ctx);
|
||||
c->last = req;
|
||||
c->calls++;
|
||||
resp.status = c->status_to_return;
|
||||
static const uint8_t kBody[] = "{\"x\":1}";
|
||||
resp.body = kBody;
|
||||
resp.body_len = sizeof(kBody) - 1;
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("httpc: POST с телом и query, статус пробрасывается") {
|
||||
fgl::ayla::HttpServer srv;
|
||||
Ctx ctx;
|
||||
ctx.status_to_return = 202;
|
||||
REQUIRE(srv.start(0, handler, &ctx));
|
||||
const uint32_t ip = (127u << 24) | 1u; // для лога, не используется
|
||||
|
||||
fgl::ayla::HttpcRequest req;
|
||||
req.method = "POST";
|
||||
req.host = "127.0.0.1";
|
||||
req.port = srv.port();
|
||||
req.path = "/local_reg.json";
|
||||
req.query = "dsn=AC000W00REDACTED";
|
||||
const char body[] = "{\"local_reg\":{\"notify\":1}}";
|
||||
req.body = reinterpret_cast<const uint8_t*>(body);
|
||||
req.body_len = strlen(body);
|
||||
req.timeout_ms = 3000;
|
||||
|
||||
fgl::ayla::HttpcResponse resp;
|
||||
REQUIRE(fgl::ayla::httpc_perform(req, &resp));
|
||||
CHECK(resp.transport_ok);
|
||||
CHECK(resp.status == 202);
|
||||
CHECK(ctx.calls == 1);
|
||||
CHECK(std::string(ctx.last.method) == "POST");
|
||||
CHECK(std::string(ctx.last.target) == "/local_reg.json");
|
||||
CHECK(std::string(ctx.last.query) == "dsn=AC000W00REDACTED");
|
||||
CHECK(ctx.last.body_len == strlen(body));
|
||||
CHECK(memcmp(ctx.last.body, body, strlen(body)) == 0);
|
||||
srv.stop();
|
||||
(void)ip;
|
||||
}
|
||||
|
||||
TEST_CASE("httpc: ошибки транспорта (соединение отвергнуто)") {
|
||||
// Занимаем и освобождаем порт — соединение точно отвергнётся.
|
||||
fgl::ayla::HttpServer tmp;
|
||||
REQUIRE(tmp.start(0, nullptr, nullptr));
|
||||
const uint16_t port = tmp.port();
|
||||
tmp.stop();
|
||||
fgl::plat::sleep_ms(100);
|
||||
|
||||
fgl::ayla::HttpcRequest req;
|
||||
req.method = "PUT";
|
||||
req.host = "127.0.0.1";
|
||||
req.port = port;
|
||||
req.timeout_ms = 2000;
|
||||
fgl::ayla::HttpcResponse resp;
|
||||
bool ok = fgl::ayla::httpc_perform(req, &resp);
|
||||
// Либо connect сразу отклонён (false), либо таймаут — оба варианта ошибки.
|
||||
if (ok) {
|
||||
FAIL("ожидаля отказ соединения");
|
||||
}
|
||||
CHECK_FALSE(resp.transport_ok);
|
||||
}
|
||||
|
||||
TEST_CASE("httpc: PUT без тела, ответ 503 c телом (дренаж)") {
|
||||
fgl::ayla::HttpServer srv;
|
||||
Ctx ctx;
|
||||
ctx.status_to_return = 503;
|
||||
REQUIRE(srv.start(0, handler, &ctx));
|
||||
|
||||
fgl::ayla::HttpcRequest req;
|
||||
req.method = "PUT";
|
||||
req.host = "127.0.0.1";
|
||||
req.port = srv.port();
|
||||
req.path = "/local_reg.json";
|
||||
req.body = nullptr;
|
||||
req.body_len = 0;
|
||||
req.timeout_ms = 3000;
|
||||
|
||||
fgl::ayla::HttpcResponse resp;
|
||||
REQUIRE(fgl::ayla::httpc_perform(req, &resp));
|
||||
CHECK(resp.status == 503);
|
||||
CHECK(ctx.calls == 1);
|
||||
CHECK(ctx.last.body_len == 0);
|
||||
srv.stop();
|
||||
}
|
||||
@@ -209,7 +209,9 @@ TEST_CASE("httpd: бесконечный стрим заголовков зав
|
||||
const char* filler = "X-Pad: 0123456789012345678901234567890123456789\r\n";
|
||||
size_t flen = strlen(filler);
|
||||
for (int i = 0; i < 80; i++) { // ~4КБ — больше лимита
|
||||
REQUIRE(fgl::plat::tcp_send(fd, filler, flen) == static_cast<long>(flen));
|
||||
if (fgl::plat::tcp_send(fd, filler, flen) != static_cast<long>(flen)) {
|
||||
break; // сервер уже закрыл соединение (лимит превышен) — RST допустим
|
||||
}
|
||||
}
|
||||
// Сервер должен перестать читать и закрыть соединение: recv завершается
|
||||
// (EOF или RST после close с непрочитанными данными), а не висит вечно.
|
||||
|
||||
156
tests/ayla/test_json.cpp
Normal file
156
tests/ayla/test_json.cpp
Normal file
@@ -0,0 +1,156 @@
|
||||
// Тесты JSON: writer (фиксированный буфер, вложенность, переполнение)
|
||||
// и парсер (jsmn) — форматы протокола.
|
||||
#include "doctest/doctest.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "ayla/json.hpp"
|
||||
|
||||
using fgl::ayla::json::Doc;
|
||||
using fgl::ayla::json::Writer;
|
||||
|
||||
TEST_CASE("json writer: формы протокола") {
|
||||
// datapoint-команда (properties)
|
||||
char buf[512];
|
||||
Writer w(buf, sizeof(buf));
|
||||
w.begin_object(); // {
|
||||
w.key("properties");
|
||||
w.begin_array();
|
||||
w.begin_object();
|
||||
w.key("property");
|
||||
w.begin_object();
|
||||
w.key("base_type"); w.string("integer");
|
||||
w.key("name"); w.string("fan_speed");
|
||||
w.key("value"); w.integer(3);
|
||||
w.key("id"); w.string("ab12cd34");
|
||||
w.end_object();
|
||||
w.end_object();
|
||||
w.end_array();
|
||||
w.end_object();
|
||||
REQUIRE(w.ok());
|
||||
CHECK(std::string(buf) ==
|
||||
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
|
||||
"\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}");
|
||||
|
||||
// GET-команда (cmds)
|
||||
Writer w2(buf, sizeof(buf));
|
||||
w2.begin_object();
|
||||
w2.key("cmds");
|
||||
w2.begin_array();
|
||||
w2.begin_object();
|
||||
w2.key("cmd");
|
||||
w2.begin_object();
|
||||
w2.key("cmd_id"); w2.integer(5);
|
||||
w2.key("method"); w2.string("GET");
|
||||
w2.key("resource"); w2.string("property.json?name=op");
|
||||
w2.key("data"); w2.string("");
|
||||
w2.key("uri"); w2.string("/local_lan/property/datapoint.json");
|
||||
w2.end_object();
|
||||
w2.end_object();
|
||||
w2.end_array();
|
||||
w2.end_object();
|
||||
REQUIRE(w2.ok());
|
||||
CHECK(std::string(buf) ==
|
||||
"{\"cmds\":[{\"cmd\":{\"cmd_id\":5,\"method\":\"GET\","
|
||||
"\"resource\":\"property.json?name=op\",\"data\":\"\","
|
||||
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}");
|
||||
|
||||
// key_exchange ответ + массив целых + bool/null
|
||||
Writer w3(buf, sizeof(buf));
|
||||
w3.begin_object();
|
||||
w3.key("random_2"); w3.string("abcd1234");
|
||||
w3.key("time_2"); w3.integer(-123);
|
||||
w3.key("flags");
|
||||
w3.begin_array();
|
||||
w3.integer(1); w3.integer(2); w3.integer(3);
|
||||
w3.end_array();
|
||||
w3.key("ok"); w3.boolean(true);
|
||||
w3.key("nothing"); w3.literal("null");
|
||||
w3.end_object();
|
||||
REQUIRE(w3.ok());
|
||||
CHECK(std::string(buf) ==
|
||||
"{\"random_2\":\"abcd1234\",\"time_2\":-123,\"flags\":[1,2,3],"
|
||||
"\"ok\":true,\"nothing\":null}");
|
||||
}
|
||||
|
||||
TEST_CASE("json writer: экранирование и переполнение") {
|
||||
char buf[64];
|
||||
Writer w(buf, sizeof(buf));
|
||||
w.begin_object();
|
||||
w.key("a\"b\\c"); // ключ с кавычкой и обратным слешем
|
||||
w.string("x\ny\"z");
|
||||
w.end_object();
|
||||
REQUIRE(w.ok());
|
||||
CHECK(std::string(buf) == "{\"a\\\"b\\\\c\":\"x\\ny\\\"z\"}");
|
||||
|
||||
// Переполнение буфера — ok() = false, не выходим за границы.
|
||||
Writer w2(buf, sizeof(buf));
|
||||
w2.begin_object();
|
||||
w2.key("data");
|
||||
w2.string("0123456789012345678901234567890123456789012345678901234567890123456789");
|
||||
w.end_object();
|
||||
CHECK_FALSE(w2.ok());
|
||||
CHECK(strlen(buf) < sizeof(buf)); // NUL-терминировано всегда
|
||||
}
|
||||
|
||||
TEST_CASE("json parser: форматы протокола") {
|
||||
// datapoint push
|
||||
char buf[128];
|
||||
strcpy(buf, "{\"name\":\"fan_speed\",\"value\":3,\"metadata\":{}}");
|
||||
Doc doc;
|
||||
REQUIRE(doc.parse(buf));
|
||||
char name[32];
|
||||
REQUIRE(doc.get_string("name", name, sizeof(name)));
|
||||
CHECK(std::string(name) == "fan_speed");
|
||||
int64_t value = -1;
|
||||
REQUIRE(doc.get_int("value", &value));
|
||||
CHECK(value == 3);
|
||||
bool b = true;
|
||||
CHECK_FALSE(doc.get_bool("value", &b)); // не bool
|
||||
CHECK_FALSE(doc.get_string("absent", name, sizeof(name)));
|
||||
|
||||
// key_exchange
|
||||
strcpy(buf, "{\"key_exchange\":{\"ver\":1,\"proto\":1,\"key_id\":62999,"
|
||||
"\"random_1\":\"ab+/12\",\"time_1\":-987654321,\"sec\":\"\"}}");
|
||||
REQUIRE(doc.parse(buf));
|
||||
int64_t ver = -1, key_id = -1, time_1 = -1;
|
||||
REQUIRE(doc.get_int("ver", &ver));
|
||||
REQUIRE(doc.get_int("key_id", &key_id));
|
||||
REQUIRE(doc.get_int("time_1", &time_1));
|
||||
char rnd[32];
|
||||
REQUIRE(doc.get_string("random_1", rnd, sizeof(rnd)));
|
||||
CHECK(ver == 1);
|
||||
CHECK(key_id == 62999);
|
||||
CHECK(time_1 == -987654321);
|
||||
CHECK(std::string(rnd) == "ab+/12");
|
||||
|
||||
// envelope payload
|
||||
strcpy(buf, "{\"seq_no\":42,\"data\":{\"name\":\"x\",\"value\":true}}");
|
||||
REQUIRE(doc.parse(buf));
|
||||
int64_t seq = -1;
|
||||
REQUIRE(doc.get_int("seq_no", &seq));
|
||||
CHECK(seq == 42);
|
||||
bool v = false;
|
||||
REQUIRE(doc.get_bool("value", &v));
|
||||
CHECK(v);
|
||||
|
||||
// Мусор
|
||||
strcpy(buf, "not json");
|
||||
CHECK_FALSE(doc.parse(buf));
|
||||
strcpy(buf, "[1,2]");
|
||||
CHECK_FALSE(doc.parse(buf)); // верхний уровень — объект
|
||||
}
|
||||
|
||||
TEST_CASE("json parser: экранированные строки") {
|
||||
char buf[96];
|
||||
strcpy(buf, "{\"name\":\"a\\\"b\\\\c\\nd\"}");
|
||||
Doc doc;
|
||||
REQUIRE(doc.parse(buf));
|
||||
char out[32];
|
||||
REQUIRE(doc.get_string("name", out, sizeof(out)));
|
||||
CHECK(std::string(out) == "a\"b\\c\nd");
|
||||
// Не помещается в out
|
||||
char small[4];
|
||||
CHECK_FALSE(doc.get_string("name", small, sizeof(small)));
|
||||
}
|
||||
20
third_party/jsmn/LICENSE
vendored
Normal file
20
third_party/jsmn/LICENSE
vendored
Normal 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
471
third_party/jsmn/jsmn.h
vendored
Normal 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 */
|
||||
Reference in New Issue
Block a user